Skip to content

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.

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.

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.

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_instruction

Using 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.

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.

PatternTop frameFull unwindComplexityUse when
API callsntdllall-legitnoneProcess is unhooked (E3 says so)
Direct syscall (E4)your moduleyours + kernellowStack telemetry absent
Indirect syscall (E5)ntdllyours below ntdllmediumTop-frame checks live
Indirect + frame spoofntdll + forgedmostly-legithighFull-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.

TechniqueMechanismDefeatsExposed to
Gadget jump from clean mappingjmp [ntdll+X] where X = syscall;retTop-frame stack checks, hooksFull unwind below
Per-call SSN patchingSame gadget, EAX set per callStub-count signaturesNothing new
Hooked-gadget avoidanceChoose obscure-function gadgetsDirty-region gadgetsVendor-specific hook maps [verify]
Dynamic section mappingNtCreateSection clean map at runtimeStale/disk-tamper assumptionsSection-creation telemetry
Callback indirectionRoute call via legitimate callback (e.g. enums)Some sequence heuristicsBehavior correlation
Frame spoofing (preview)Forged intermediate frames via gadgetsFull-unwind analysisUnwind-metadata mismatches (E6)

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, unknown
MODULE_NTDLL = "ntdll.dll" # Microsoft-signed
MODULE_K32 = "kernel32.dll"
@dataclass
class 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())
  • Frame (lines 16–22) — one stack frame: module, function, and Microsoft-signed status. The signed bit 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. Run python indirect_sim.py and read the three verdicts as the arms race in six lines.

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.

  1. Verify — E3’s three checks confirm live ntdll is hooked and top-frame stack telemetry is likely active
  2. Clean map — NtCreateSection/NtMapViewOfSection map the pristine on-disk ntdll (read-only, image-backed)
  3. Resolve — Hell’s/Halo’s Gate (E4) produces current-build SSNs from the clean mapping
  4. Gadget scan — locate syscall;ret sequences in the clean mapping; prefer gadgets in obscure stubs
  5. Thunk — for each operation, set r10/rcx/EAX then JMP (not call) to the gadget; return address lands in ntdll
  6. Execute — NtAllocateVirtualMemory / NtWriteVirtualMemory / NtCreateThreadEx present a signed top frame to any stack walk
  7. Residual — full unwinds still see your frames below ntdll: minimize unsigned depth, or escalate to frame-spoofing research

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;
}
  • map_clean_ntdll (lines 21–33) — the E3 procedure via documented APIs (CreateFileMapping with SEC_IMAGE produces 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 for 0F 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, SSN in Microsoft-authored bytes), then replaces its syscall with jmp [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 syscall instruction executes inside clean ntdll — in a debugger, the return address on the stack at breakpoint syscall is an ntdll address.
$ 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 address

The 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.

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.

  1. 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.
  2. Add the full-unwind check from indirect_sim.py as a self-test: walk your own stack (RtlCaptureStackBackTrace) and assert the shape you intended.
  3. Gadget cache: scan once, store gadget addresses per stub class, verify image-region membership before each use.
  4. 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.
  5. Port ssn_of to parse the clean mapping’s export table manually (no GetProcAddress) — close the last documented API in the chain.
SourceIDWhat firesNotes
Sysmon10ProcessAccess grants with VM_EXECUTE changes toward targetsKernel callback — invocation-agnostic
Sysmon8CreateRemoteThread if the chain uses oneSame
EDR behavioral—Full-unwind classification: unsigned frame(s) below ntdll at sensitive callsThe direct counter to E5
ETWThreat-Intelligence providerMemory op correlation in PPL sensor context [verify access model]Survives invocation-path tricks
Sysmon5Raw-access/section-mapping analog of clean-ntdll mapping [verify mapping on build]The prep step’s telemetry
Suricatasid:1000001 (custom)Downstream C2 patternsNetwork layer unchanged
Wazuh[verify rule IDs vs ruleset]Chains Sysmon 8/10 + section eventsSequence 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.

  • Jump, never call, the gadget: a call pushes 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_READONLY clean 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.

  1. Run indirect_sim.py; write down the three verdict rows; then modify path_indirect() to spoof the bottom frame as signed and observe gen2’s output change — that edit is frame-spoofing’s promise, and its fragility.
  2. Build indirect_demo.c; in x64dbg, breakpoint the gadget and prove [rsp] is an ntdll address; repeat with E4’s syscall_demo and contrast.
  3. 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.
  4. Extension: port make_thunk to allocate RX-only and patch via VirtualProtect round-trip; verify functionality unchanged and note which heuristic (RWX-region syscalls) your change removes from the surface.
  5. 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.

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.

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