Tampered Syscalls: Executing from ntdll Without Writing a Single Stub
The arms race between EDRs and offensive tooling has driven syscall techniques through several generations. Direct syscalls (SysWhispers) moved the syscall instruction out of ntdll into user-allocated memory. Indirect syscalls jumped back into ntdll for the syscall instruction but still resolved SSNs from allocated stubs. Each generation solved one detection while creating another.
The core problem is that every approach that allocates memory to hold syscall logic leaves a forensic artifact: a memory region containing syscall instructions, SSN setup code, or jmp ntdll trampolines that doesn’t belong to any known module. EDRs learned to scan for this.
Krait’s tampered syscall implementation eliminates the allocated stub entirely. Every syscall executes from ntdll’s own code, at ntdll’s own addresses, with return addresses pointing into ntdll. No user memory is allocated for syscall logic at any point.
The Problem with Stubs
Traditional indirect syscall implementations follow this pattern:
- Resolve the SSN for the target function (e.g.,
NtAllocateVirtualMemory→ SSN 0x18) - Write setup code somewhere:
mov r10, rcx; mov eax, <SSN> jmpto thesyscallinstruction inside ntdll’s copy of the target function
This solves the return-address problem — the syscall instruction executes from ntdll’s .text, so kernel-side checks see a legitimate return address. But steps 2 and 3 still require allocated memory containing recognizable instruction patterns. Memory scanners (pe-sieve, Moneta, custom ETW consumers) can find these stubs by looking for mov eax, imm32 followed by jmp instructions in private memory regions.
Some implementations solve this by reusing ntdll’s own mov eax, <SSN> sequence for a different function and modifying RAX before the syscall fires. But this still requires hooking or patching ntdll’s code, which triggers integrity checks.
Krait’s Approach: The Decoy Function
Krait picks a rarely-called ntdll function as a decoy — selected at random from a pool of candidates during each build. The decoy rotates per build, so no two payloads share the same behavioral fingerprint. Each candidate has a straightforward prologue: mov r10, rcx; mov eax, <SSN>; ... syscall; ret. Every syscall the implant makes goes through this one function.
The trick is that the SSN loaded by the decoy function’s prologue is wrong for the target operation. A hardware breakpoint on DR3 catches execution at the syscall instruction, and a Vectored Exception Handler swaps in the correct SSN and arguments before the instruction fires.
Here’s the flow for a call to NtAllocateVirtualMemory:
1. Store real SSN + args in global TAMPERED_PARAMS struct
2. Call the decoy function(dummy args) — a normal function call
3. ntdll executes the prologue: mov r10, rcx; mov eax, <wrong SSN>
4. DR3 HWBP fires at the syscall instruction → EXCEPTION_SINGLE_STEP
5. VEH handler runs:
- Swaps RAX to the real SSN (NtAllocateVirtualMemory's number)
- Swaps R10, RDX, R8, R9 to the real arguments
6. Handler returns EXCEPTION_CONTINUE_EXECUTION
7. syscall executes with correct SSN + args, from ntdll's code
8. Return to caller — return address is inside ntdll
From the kernel’s perspective, the syscall originated from the decoy function inside ntdll, at a legitimate code address, with a legitimate return address. The SSN was modified in a debug register exception handler — a mechanism that leaves no memory artifact. Because the decoy is selected randomly at build time, each payload uses a different ntdll function — an EDR that learns to flag call-frequency anomalies on one decoy won’t catch the next build.
SSN Resolution
Krait resolves syscall numbers at runtime using the sorted Zw* export technique. All Zw* exports in ntdll are thin wrappers that share the same structure, and their SSNs are assigned in alphabetical order of their names. By sorting the Zw* export addresses and finding the target’s position in the sorted list, the SSN is derived without reading ntdll’s code bytes — which avoids triggering code-integrity monitoring.
The implementation builds a sorted table of all Zw* exports from ntdll’s export directory. For a target like NtAllocateVirtualMemory, it finds ZwAllocateVirtualMemory in the table and uses its index as the SSN. This is a SysWhispers2/HalosGate-style approach, well-documented in the literature. What’s different is what happens next — the SSN isn’t used in an allocated stub. It’s stored in a struct and injected at the syscall boundary via HWBP.
Register Allocation
Krait’s HWBP bypass for AMSI and ETW already uses DR0 and DR1. Tampered syscalls use DR3. This leaves DR2 free for future use (DLL load notification hooks or additional function interception).
The VEH handler checks the exception address against all registered breakpoints and dispatches accordingly:
- DR0 hit → AMSI bypass (set RAX to E_INVALIDARG, simulate
ret) - DR1 hit → ETW bypass (set RAX to 0, simulate
ret) - DR3 hit → syscall tamper (swap SSN + args from
TAMPERED_PARAMS)
All three interceptions share one VEH handler, registered once at implant init. The handler distinguishes breakpoints by comparing the exception address against the stored target addresses for each DR register.
What This Defeats
Return-address analysis — the syscall instruction executes from ntdll’s own .text section. Any check that validates the return address against loaded module ranges sees ntdll, not private memory.
Stub scanning — no memory region in the process contains syscall setup instructions. There is no mov eax, <SSN> in allocated memory. The only SSN manipulation happens in CPU registers inside an exception handler.
SSN integrity checks — the SSN loaded by ntdll’s own prologue code is never modified in memory. It’s overwritten in RAX by the VEH handler after the prologue has already executed. An integrity scan of ntdll’s code bytes finds them untouched.
Hook integrity checks — the decoy function is called normally, through its normal export address, with no patching. If an EDR checks for inline hooks (modified prologues, jmp instructions), it finds nothing.
Decoy Rotation
Early versions of this technique used a single hardcoded decoy function across all builds. That created a subtle fingerprint: if an EDR vendor profiled the call frequency of rarely-used Nt functions, a single function being called dozens of times per poll cycle would stand out across every Krait payload in the wild.
Krait now rotates the decoy at build time. The polymorphic engine selects from a pool of rarely-called Nt functions — each with the standard mov r10, rcx; mov eax, <SSN>; ... syscall; ret prologue required for the technique to work. The selected function’s name is hashed with the build’s unique djb2 seed and baked into the agent configuration. Template-mode deployments get the same treatment: the patcher picks a fresh decoy for every generated payload.
The result is that no two builds use the same decoy unless they happen to draw the same random selection. An EDR that builds a detection around call-frequency anomalies on one specific Nt function catches one payload, not all of them.
The Tradeoff
Tampered syscalls are slower than direct syscalls. Each call involves a real function call to the decoy, a hardware breakpoint exception, a VEH handler dispatch, and register manipulation. On a modern system, this adds roughly 5-10 microseconds per syscall.
For a C2 implant that makes a handful of Nt calls per poll cycle, this overhead is invisible. The poll interval is measured in seconds; the syscall overhead is measured in microseconds. The tradeoff is worth it because the alternative — any technique that allocates memory for syscall logic — creates a persistent artifact that survives between polls and is detectable by periodic memory scans.
Covered Functions
Krait’s tampered syscall implementation covers 15 Nt functions used across the implant’s operations:
- Memory:
NtAllocateVirtualMemory,NtProtectVirtualMemory,NtFreeVirtualMemory - Process/thread:
NtCreateThreadEx,NtOpenProcess,NtSuspendThread,NtResumeThread,NtGetContextThread,NtSetContextThread - Sections:
NtCreateSection,NtMapViewOfSection,NtUnmapViewOfSection - Handles:
NtDuplicateObject,NtClose - Sleep:
NtDelayExecution
These cover all security-sensitive operations the implant performs: memory allocation for BOF loading, thread manipulation for migration and injection, section operations for phantom DLL hollowing and module stomping, and sleep for the Ekko encryption cycle.
Every one of these calls goes through the same decoy function, the same hardware breakpoint, the same VEH handler. The implementation is uniform — there are no special cases or fallback paths that might behave differently under observation.