
Usermode detector that catches indirect syscalls. Traps Hell's Hall, Tartarus' Gate, RecycledGate, and VEH syscalls & Many more.
Copyright (C) 2026 Adam Zypherion <[email protected]> — licensed under GPL-3.0.
Indirect syscalls are simple once you have seen one. The loader walks ntdll, reads the SSN out of Nt* prologue, finds the 0F 05 two bytes further in, sets r10/rax/rdx/r8/r9 itself, and jmps straight at those two bytes. The syscall happens from inside ntdll. Your hook on the kernel32 export is never touched. Your hook on the ntdll export is never touched. [RSP] at the moment of the SYSCALL points back into the loaders RWX page, but nothing about the call looks unusual from outside.
HellHall proc
mov r10, rcx
mov eax, dwSSN
jmp qword ptr [qAddr]
ret
HellHall endp
The variants have names. Tartarus' Gate and RecycledGate use the syscall instruction belonging to one stub but the SSN of a different one, so even if you log the stub name your hook reports, it is a lie. VEH syscall triggers an access violation intentionally and uses its own VEH to rewrite the context so RIP lands on ntdll's syscall instruction with the registers already prepped. Hell's Gate does not use ntdll at all. The loader writes 0F 05 into its own RWX page and runs from there.
The kernel mode (KM) answer is a driver, i might actually get to this overtime and release an project but not happening anytime soon :D
PAGE_GUARD looks like it should work. Mark the page that holds the syscall bytes with PAGE_GUARD, catch the STATUS_GUARD_PAGE_VIOLATION in a VEH, inspect, redirect to a private trampoline, set the trap flag, single-step out, arm the page back. One trap per Nt call, no matter how the loader got there. It works against everything except Hell's Gate.
The problem is the OS does not cooperate. PAGE_GUARD is a one shot what does that mean? every time it fires the bit is cleared and you have to put it back. Your handler does syscalls (NtProtect to put the guard back, NtContinue to resume) and those syscalls have stubs and those stubs are on the page you just guarded. i worked around most of that with private syscall stubs built on a separate page we owned (allocate RWX, write mov r10,rcx; mov eax,SSN; syscall; ret, lock RX, never touch ntdll's), but it was fragile. Every Windows minor version changed the timing. Every thread the sample spawned was another race against the integrity worker that was rebuilding the guard. Demo success was around 30 to 50 percent across ten runs on the same machine .
Eventually I stopped trying to convince Windows that PAGE_GUARD should behave the way I wanted, and tried overwriting the byte instead.
https://github.com/user-attachments/assets/0cb670fd-e51b-413e-bf00-08f9297888ed
The byte at the syscall instruction is 0F 05. The first byte alone (0F) is the prefix for a family of two byte opcodes including SYSCALL, CPUID, and RDTSC. By itself it is not executable; the CPU needs the second byte to decode. If you replace the first byte with 0xCC (INT3), the pair becomes CC 05, which the CPU decodes as INT3 followed by a stray byte it never reaches. Any code path that lands on that address raises EXCEPTION_BREAKPOINT.
That is the whole mechanism. Our VEH catches the breakpoint, looks up which stub the address belongs to (we build the map at init time by enumerating ntdll's exports), runs three checks on whoever turned up, and sets Context->Rip to a private trampoline that does the real syscall and returns. The byte stays CC. The next caller hits it the same way so no page protection flipping back and forth.
To be clear: yes, this is hooking by byte overwrite. it is just not at the byte people usually mean by "ntdll hook". the classic EDR hook overwrites the first bytes of the stub with a JMP <my_func>:
ntdll!NtAllocateVirtualMemory:
E9 ?? ?? ?? ?? jmp my_hook ; overwrites "mov r10, rcx"
...
0F 05 syscall
C3 ret
that is exactly what indirect syscalls walk around. the loader reads the SSN itself and jumps straight at the 0F 05, the prologue (and your jmp) never runs, the hook never fires. what we do is overwrite the syscall byte itself:
ntdll!NtAllocateVirtualMemory:
4C 8B D1 mov r10, rcx ; untouched
B8 18 00 00 00 mov eax, 18h ; untouched
CC 05 int3 / 05 ; was 0F 05, we wrote CC over the 0F
C3 ret
now it does not matter how you got to that address. through the prologue, through an indirect jump that skips the prologue, through a VEH-syscall context rewrite that drops RIP there, whatever. if the CPU executes that byte, it traps. same family of technique as the classic hook, different location, completely different coverage.
The trampoline looks like this:
F3 0F 1E FA endbr64
49 89 CA mov r10, rcx
B8 <stub SSN> mov eax, ssn
0F 05 syscall
C3 ret
The three checks are unchanged from the PAGE_GUARD version because they were the right three checks; they just needed somewhere reliable to live.
Return address. [RSP] is where the syscall would have returned to. For a real call it is inside ntdll, kernel32, kernelbase, or one of the related runtime DLLs. For Hell's Hall it is inside whatever RWX page the loader is running from. We have a short list of trusted return targets built at init by calling GetModuleHandle for those names and reading the .text ranges out of their PE headers.
SSN. The stub's prologue has already run before it reaches the syscall byte, so eax holds whatever value was loaded into it. If the loader did a Tartarus swap, that value will not match the SSN we read out of this same stub at enumeration. We log the mismatch, and the trampoline writes the correct SSN before its own syscall, so the kernel function that runs is the one that belongs to the byte rather than the one the loader wanted. The technique gets logged and neutralised in the same step.
Stack walk. RtlVirtualUnwind from the current context, five frames up. Every frame's RIP should be inside a known module and should have a RUNTIME_FUNCTION entry. Shellcode and ROP gadgets fail this even when [RSP] itself looks trusted, which a loader can fake (it can predict roughly where its caller will be in memory and forge a believable return address there).
If any check fails, we push a small struct into a lock free ring and the drain thread prints it the next time it wakes
[!! hallwatch !!] indirect syscall (untrusted caller, wrong ssn for this stub)
syscall : NtAllocateVirtualMemory
syscall rip : 0x00007FF827660372
return addr : 0x00007FF7EED719D6
rax (ssn) : 0x0000000F (stub encodes 0x00000018)
thread : 26388
Hell's Gate is where INT3 stops helping. We never patched the loader's RWX page because we never knew it existed.