
Executive Summary
The sixth installment of the Wipeload series tackles the hardest engineering problem in a Chrome sandbox-escape chain: turning a raw kernel use-after-free (UAF) in the ALPC subsystem into a stable, weaponized read/write primitive that reaches out of the renderer and into a Medium-integrity process. The starting point is a bugcheck — a blue screen — and the destination is arbitrary code execution as cmd.exe launched from a non-sandboxed helper process. Getting there requires reclaiming a freed _ETHREAD kernel object with attacker-controlled data, defeating the pool allocator’s caching heuristics, and then abusing the LPC message-copy machinery to read and write another process’s address space.
This walkthrough reconstructs the full path in original prose: how the freed thread slab is recaptured through the dynamic lookaside cache, how a file-picker dialog is coerced into spraying threads inside the util_win process, how the LPCP_DATA_INFO copy path (NtReadRequestData/NtWriteRequestData) becomes a cross-process memory oracle, and finally how a two-stage gadget chain overwrites a COM allocator vtable to call WinExec while bypassing Control Flow Guard. It is a masterclass in modern Windows heap grooming and IPC abuse, and a reminder of how much surface a single dangling pointer can unlock.
1. From Crash to Primitive
The previous step in the series left off with a crash: the ALPC bug freed an object that the kernel kept using, and the mismatch produced a bugcheck. A blue screen proves the bug is real, but it is worthless as an exploit primitive. The whole of this chapter is about converting that instability into control. Three questions frame the work.
- Conversion: what freed object can be reclaimed, and with data the attacker chooses?
- Target selection: which process should receive the reclaimed object so that the primitive crosses the sandbox boundary?
- Execution hijack: how is the read/write turned into a program-counter change without tripping CFG?

The object at the center of everything is _ETHREAD, the kernel’s per-thread bookkeeping structure. The freed allocation that the bug leaves dangling is a thread slab; if the attacker can make the kernel hand that same slab back out and fill it with controlled bytes, the dangling reference now points at attacker data. That is the definition of a UAF-to-primitive conversion. The blue screen below is simply what happens when the reclaim is not orchestrated — the kernel dereferences freed memory that has been reused arbitrarily.

_ETHREAD is reused without control. Source: original article.1.1 The VS Allocator and the _ETHREAD Slab
To reclaim the slab reliably you have to understand exactly how it was allocated. On modern Windows the _ETHREAD object is roughly 0x900 bytes, but the actual pool block that backs it is larger — about 0xa80 bytes once header and alignment are accounted for. The block carries the pool tag 'Thre', and it is a protected object (the protection bit is set), which matters for how it is looked up and freed.
The allocation path is worth memorizing, because every link in it is something the attacker triggers indirectly by asking the OS to create a thread:
NtCreateThreadEx
-> PspAllocateThread
-> ObpAllocateObject
-> ExAllocatePool2 // VS allocator hands back the ~0xa80 'Thre' block
Blocks of this size class are managed by the VS (variable-size) segment heap allocator. The key insight is that the allocator does not immediately return a freed block to the global segment; instead it can park it in a per-size-class cache so that the next same-sized allocation is served instantly. That cache is the lever the exploit pulls.

'Thre' pool block layout in the VS allocator’s size class. Source: original article.
!pool confirming the tag and size of the freed thread slab. Source: original article.1.2 The Dynamic Lookaside
The cache in question is the _RTL_DYNAMIC_LOOKASIDE, a set of 64 buckets, one per small size class. Each bucket is a lock-free singly linked list (SLIST) that behaves as a stack: freeing a block pushes it onto the top of the SLIST, and allocating pops from the top. The behavior is therefore strictly LIFO — the most recently freed block of a given size is the first one handed back.
What makes the lookaside “dynamic” is that each bucket has a Depth field that controls how many blocks it is willing to cache. Depth starts at 0 and can grow to a maximum of 256. The allocator periodically retunes each bucket according to its hit/miss statistics:
- If the miss rate is below 0.5%, the bucket is “cold enough” and
Depthis shrunk. - If the miss rate is at or above 0.5%, demand is high, so
Depthis grown. - There is a floor: a warmed bucket will not shrink below a depth of 4.
The consequence is subtle and central to the exploit: a bucket that has seen no recent activity has Depth == 0 and will refuse to cache a freed block. Simply freeing the target thread will not place its slab into the SLIST unless the bucket has first been warmed by generating enough allocation traffic (and cache misses) to push Depth above zero.
2. Taking the Slab Back
With the caching model understood, the reclaim is a grooming exercise. Two independent conditions must both be satisfied for the freed slab to land in the right hands.
2.1 Two Reasons the Reclaim Doesn’t “Just Work”
Problem 1 — cold buckets. As established above, a bucket with Depth == 0 discards freed blocks instead of caching them. If the target thread is freed while its size-class bucket is cold, the slab bypasses the SLIST entirely and returns to the segment, where it is out of reach of the fast, deterministic reclaim. The bucket must be warmed first.

Problem 2 — competing allocations. Even with a warm bucket, the LIFO SLIST is shared. Any thread in the system that allocates a 0xa80-class block — including the renderer’s own worker threads — can pop the attacker’s freed slab before the intended process does. The reclaim is a race, and the attacker must both quiet the noise and make sure the winning allocation happens inside the target process.

The warming strategy is simply to create and destroy threads to drive the bucket’s Depth up. Each thread creation that misses the cache nudges the miss-rate heuristic above the 0.5% threshold, and the bucket grows until it will happily cache the target slab.

Depth so the freed slab is cached. Source: original article.2.2 Thread Spray via the File Picker
Winning the race means having the target process allocate a same-sized block at the right moment. The chosen target is util_win, a Chrome utility process that runs at Medium integrity with no sandbox — the perfect landing zone for a primitive that is supposed to escape the renderer. The problem is that a sandboxed renderer cannot directly make util_win create threads. The trick is to do it through a legitimate feature.
Calling window.showOpenFilePicker() from renderer JavaScript ultimately routes to UtilWin::CallExecuteSelectFile inside util_win. To display the classic Windows “Open File” dialog, that code instantiates the shell’s file-open dialog COM object:
// Inside util_win, triggered by window.showOpenFilePicker()
CoCreateInstance(CLSID_FileOpenDialog, nullptr,
CLSCTX_INPROC_SERVER,
IID_IFileOpenDialog, &dialog);
// COM apartment init spins up 8-16 worker threads
The important side effect is that COM initialization for the dialog spawns a burst of worker threads — typically 8 to 16 of them — each of which allocates an _ETHREAD in the target size class, inside util_win. That burst is the attacker’s reclaim window: if the target slab is sitting on top of the warm SLIST when those threads are created, one of them pops it.

util_win utility process — Medium IL, no sandbox — is the reclaim target. Source: original article.
2.3 prime_lookaside and the SELF-CHECK Filter
The reclaim is orchestrated in two beats. First, a prime_lookaside routine warms the bucket by creating a controlled sequence of eight threads, ensuring Depth is raised and the SLIST is primed just before the file-picker spray. Then the picker is triggered and the target slab is (hopefully) popped inside util_win.
Because the reclaim is a race, the attacker needs to know whether it succeeded and whether the winner was the right process. That is what the CTRL_MAGIC self-check provides. Two gates are evaluated:
- Gate A: a known field (call it
[g_CMalloc]) reads back the expectedWANTvalue, proving the reclaimed object lives where the attacker’s cross-process view expects it. - Gate B: a control word
[ctrl]does not equalCTRL_MAGIC, proving the slab was reclaimed by the foreign process and not re-grabbed by the renderer’s own allocation (which would have stamped the magic).
// Success only when BOTH gates pass
bool reclaim_ok =
(read_u64(g_CMalloc_addr) == WANT) && // Gate A
(read_u64(ctrl_addr) != CTRL_MAGIC); // Gate B
Only when both conditions hold does the exploit treat the reclaim as a clean, cross-process capture and move on. Otherwise it retries the priming-and-spray cycle. This filtering is what turns a flaky heap race into a repeatable primitive.

CTRL_MAGIC self-check: dual gates confirm a clean cross-process reclaim. Source: original article.
g_CMalloc address collision that proves the correct process won the slab. Source: original article.3. Reading Across Processes
Owning a reclaimed _ETHREAD that util_win believes is one of its live threads is powerful because of a specific kernel feature: the LPC request-data copy path. This is what upgrades the dangling pointer into an arbitrary cross-process read and (constrained) write.
3.1 LPCP_DATA_INFO — Cross-Process R/W
Two kernel APIs move data between the two ends of an LPC/ALPC conversation:
NtReadRequestData— copies bytes from the peer process into the caller’s buffer.NtWriteRequestData— copies bytes from the caller’s buffer into the peer process.
Both take the same shape of arguments: a port handle, a message (which carries a MessageId), a data-entry index, a local buffer, a length, and an optional return length. The kernel resolves which process to copy to or from through LpcpCopyRequestData, and this is where the reclaimed thread comes in. The resolution walks a chain of pointers:
LpcpCopyRequestData:
message = lookup_by_MessageId(port_queue, msg->MessageId)
thread = *(message + 0x20) // WaitingThread (_ETHREAD*)
process = *(thread + 0x220) // owning _EPROCESS
MiCopyVirtualMemory(from, fromAddr, to, toAddr, length, mode, ...)
The message’s WaitingThread at offset +0x20 is the thread whose slab the attacker just reclaimed. The kernel then reads the owning process from thread + 0x220 and copies memory to or from that process. Because the attacker controls the reclaimed slab, the attacker effectively controls which process the kernel treats as the copy peer — and that peer is util_win.
The security-relevant weaknesses are two. First, the kernel only NULL-checks the WaitingThread pointer; it does not verify the thread is still alive or that the object is genuinely a live waiter. A reclaimed, attacker-shaped object sails straight through. Second, the constraints are mild:
- Reads work against any mapped page —
.text,.rdata, anything readable. - Writes only succeed against user-mode-writable pages (
.dataand other RW sections). - Target addresses are frozen at the moment the message is sent (
BaseAddressis captured at kernel-copy time), so the addresses must be decided before transmission.

LpcpCopyRequestData flow that turns a reclaimed thread into a cross-process copy. Source: original article.3.2 Leaking the Message ID
Both copy APIs need a valid MessageId that the kernel issued. The attacker obtains it by calling NtAlpcSendWaitReceivePort in a receive-only mode (no message is sent). The kernel returns the next queued message into the receive buffer, and the kernel-assigned MessageId can be read out at buffer offset +0x18. That value is then reused as the MessageId for subsequent NtReadRequestData/NtWriteRequestData calls.
// Receive-only: pull a queued message, harvest its MessageId
NtAlpcSendWaitReceivePort(port, ALPC_MSGFLG_RELEASE_MESSAGE,
nullptr, nullptr,
recv_buf, &recv_len, nullptr, nullptr);
ULONG64 message_id = *(ULONG64*)(recv_buf + 0x18);
One more property makes address math trivial: KnownDLLs. Core system libraries such as ntdll, kernel32, combase, and chrome.dll are mapped at identical base addresses across every process in the same boot session. A GetModuleHandleA query in the renderer therefore yields addresses that are equally valid inside util_win. A vtable pointer read out of the renderer’s own combase applies verbatim to the target — no per-process base discovery is needed.

MessageId from a receive-only ALPC call. Source: original article.4. Hijacking Execution
Cross-process read/write is not yet code execution. The final move converts the write primitive into a controlled call, and it must survive Control Flow Guard.
4.1 Overwriting a vtable Pointer
Recall that writes only land on writable pages, so the target must live in a .data-style section. The chosen victim is g_CMalloc, the process-wide COM retail allocator object, at combase + 0x338348 in combase‘s .data. Its real vtable, CRetailMallocVtbl, sits at combase + 0x28a5f0 in read-only .rdata — which cannot be modified.
So instead of editing the real vtable, the attacker builds a fake vtable in writable free space (for example, unused bytes in ntdll‘s .data), copies the genuine CRetailMallocVtbl into it, and then swaps g_CMalloc‘s vtable pointer to the fake copy. The first three slots — QueryInterface, AddRef, Release — are preserved unchanged, because COM will legitimately call them during allocation and any garbage there would crash the flow. Only slot 3 (the allocation method) is redirected to the gadget entry point.
fake_vtbl = copy_of(CRetailMallocVtbl) // combase+0x28a5f0
fake_vtbl[0..2] = original // QueryInterface/AddRef/Release
fake_vtbl[3] = G1 // hijacked Alloc slot
write(g_CMalloc + 0, &fake_vtbl) // swap the vtable pointer

g_CMalloc‘s vtable pointer to a fake table with a hijacked allocation slot. Source: original article.4.2 Chaining the Gadgets
When util_win‘s dialog code next allocates COM memory, it calls slot 3 of the fake vtable with rcx = &g_CMalloc. That single controlled call drives a two-stage gadget chain whose job is to reshape registers into a WinExec("cmd.exe") call.
Gadget 1 lives in ntdll + 0xa36f0:
; G1 @ ntdll+0xa36f0 (rcx = &g_CMalloc)
mov rax, [rcx+0x20] ; rax = [g_CMalloc+0x20] = G2 address (planted)
call rax ; jump into Gadget 2
Because the attacker controls the bytes at g_CMalloc+0x20, G1 simply calls whatever address was planted there — the second gadget. Gadget 2 lives in chrome + 0x556235c and performs the register swap that stages the final call:
; G2 @ chrome+0x556235c (rcx still = &g_CMalloc)
mov rcx, [rcx+0x30] ; rcx = [g_CMalloc+0x30] = &SCR_obj
mov rax, [rcx+0x10] ; rax = [SCR_obj+0x10] = WinExec
call [__guard_dispatch_icall_fptr] ; unguarded dispatch -> WinExec(rcx,...)
The SCR_obj is a small attacker-built structure:
| Offset | Contents | Purpose |
|---|---|---|
+0x00 | "cmd.exe" (ANSI string) | Becomes rcx — the command line argument to WinExec |
+0x10 | kernel32!WinExec pointer | Loaded into rax and called |
SCR_obj layout consumed by Gadget 2. Source: original article.After G2 runs, rcx points at the "cmd.exe" string (the start of SCR_obj) and rax holds WinExec. The genius of the final instruction is that it invokes the target through call [__guard_dispatch_icall_fptr] — the very pointer CFG uses to validate indirect calls — but every intermediate target in the chain (G1, G2, WinExec) is a legitimate, CFG-valid function entry. CFG has nothing to reject, and cmd.exe launches at Medium integrity, outside the sandbox.

rcx="cmd.exe", rax=WinExec. Source: original article.The proof-of-concept below demonstrates the full chain firing end to end:
5. Outro
Step 6 closes the sandbox-escape arc. Starting from an ALPC use-after-free that only produced a bugcheck, the chain reclaimed the freed _ETHREAD slab through the dynamic lookaside cache, coerced util_win into spraying threads via the file-open dialog, verified the capture with a dual-gate self-check, weaponized NtReadRequestData/NtWriteRequestData into a cross-process read/write primitive, and finally overwrote a COM allocator vtable to drive a CFG-safe gadget chain into WinExec("cmd.exe"). The renderer began at Untrusted integrity; it ends with code execution at Medium integrity, fully outside the sandbox.

Medium integrity is not the end of the road, though — it is the doorway. The next installment, Wipeload Step 7, is authored by banda and covers Windows local privilege escalation: taking this Medium-IL foothold all the way to SYSTEM. In the series’ running metaphor, escaping the sandbox was breaking out of prison; the next step is becoming the Dragon Warrior.

Key Takeaways
- A kernel UAF becomes a primitive only after a controlled reclaim; the
_ETHREADslab (~0xa80, tag'Thre') is recaptured through the VS allocator’s dynamic lookaside cache. - The
_RTL_DYNAMIC_LOOKASIDEis LIFO with a per-bucketDepth(0–256); a cold bucket discards frees, so the attacker must warm it by generating cache misses before freeing the target. window.showOpenFilePicker()reachesutil_winand its dialog COM apartment spawns 8–16 threads — a controllable same-size allocation burst in a Medium-IL, non-sandboxed process.- A dual-gate
CTRL_MAGICself-check turns a flaky heap race into a deterministic, verified capture. LpcpCopyRequestDatatrusts the reclaimed thread at message+0x20and the process at thread+0x220with only a NULL check, yielding cross-process R/W viaNtRead/WriteRequestData.- KnownDLL base-address equality across processes makes leaked renderer addresses directly usable inside the target.
- Overwriting a writable COM vtable pointer (
g_CMalloc) and chaining two library gadgets reachesWinExecwhile every call target stays CFG-valid.
Defensive Recommendations
- Patch promptly. This chain begins with a specific ALPC UAF; keeping Windows and Chrome current closes the root cause that everything else depends on.
- Harden the kernel copy path. The abuse relies on
LpcpCopyRequestDatatrusting a thread object after only a NULL check — liveness and type validation on theWaitingThread/EPROCESSchain would break the primitive. - Constrain utility-process capability. A helper like
util_winrunning at Medium IL with no sandbox is a high-value landing zone; tighter integrity/AppContainer confinement on Chrome utility processes raises the cost of a successful reclaim. - Reduce deterministic reclaim. Pool-allocation hardening — randomized caching behavior, stronger type isolation for protected objects like
'Thre'— undermines LIFO-based grooming. - Monitor for grooming tells. Bursts of thread creation immediately followed by file-dialog COM activity in a renderer-adjacent process are an unusual pattern worth alerting on.
- Keep CFG and complementary mitigations enabled. While this chain stays CFG-valid, defense-in-depth (CET/shadow stacks, XFG) shrinks the set of usable gadget chains and can invalidate the specific dispatch used here.
- Watch for anomalous child processes. A browser utility process spawning
cmd.exeis a strong post-exploitation signal for EDR and behavioral rules.
Conclusion
Wipeload Step 6 is a compact tour of everything that makes modern Windows exploitation hard and fascinating at once: allocator internals that must be coaxed rather than commanded, IPC machinery repurposed into a memory oracle, and a mitigation-aware final call that never technically violates CFG. The lesson for defenders is that no single control stops this chain — it is defeated in depth, at the patch, the kernel validation, the process confinement, and the behavioral-detection layers together. For researchers, it is a template for how a lone dangling pointer, patiently groomed, becomes a doorway out of the sandbox.
Original text: “[Wipeload Step 6.] How to Escape the SandBox Painlessly” by gongjae at Hackyboiz (August 1, 2026), CC BY-SA 4.0.


