Skip to content

Userland Hook Detection

Userland API hooking is the EDR’s cheap set of eyes inside every process: rewrite the first bytes of the ntdll.dll syscall stubs, and every VirtualAlloc/WriteProcessMemory/CreateRemoteThread from that process announces itself before the kernel does anything. It is also the most fragile telemetry surface an EDR owns — the hooks live in a file on disk (ntdll.dll), a fixed known layout (mov r10, rcx; mov eax, SSN; syscall), and a region the monitored process itself can read. This module covers how hooks are installed, how to detect them from inside a process you control (the honest, offensive-recon framing: verifying your own tool’s environment before acting), and why hook detection is always paired with the syscall-based bypasses of E4 and E5.

A userland hook is a code detour: the first instructions of a target function are replaced with a jump/trampoline to sensor code, which logs arguments, optionally forwards to the original, and returns. On x64 Windows the target zone is the syscall stub region of ntdll.dll:

Stock ntdll!NtAllocateVirtualMemory: Hooked version (EDR):
┌──────────────────────────┐ ┌──────────────────────────┐
│ mov r10, rcx │ │ jmp <sensor stub> ◄────┼── detour
│ mov eax, 0x18 │ │ ... │
│ syscall │ │ (sensor logs args, then │
│ ret │ │ re-executes original) │
└──────────────────────────┘ └──────────────────────────┘
In-memory only — the on-disk ntdll.dll is untouched.
Other common hook sites: kernel32/kernelbase exports, .NET hosts,
script-engine entry points, AMSI!AmsiScanBuffer (E8's topic).

Hook detection means determining, from unprivileged code, whether those stubs still match the pristine bytes the OS shipped. Three canonical checks exist because each has blind spots; mature tooling runs all three and votes.

For the operator: running injection tooling inside a hooked process is self-reporting. Every technique module after this one assumes you can answer “is this process still wearing its hooks?” before your first suspicious call. For the defender: hook integrity is a detection surface in itself — vendors explicitly instrument for tampering, because a process that rewrites its own ntdll is not a privacy enthusiast, it is a payload (MDE and Falcon both treat hook removal as signal, not silence). Understanding the three detection primitives and their false-positive rates is therefore blue-team curriculum too: your legitimate security products (EMET-era tools, some debuggers, print drivers [verify]) also patch ntdll, and knowing which check they trip teaches you which check to trust.

1. Compare against the on-disk image (.text reparse). The cleanest baseline available: ntdll.dll exists on disk, unhooked, at C:\Windows\System32\ntdll.dll. Map a fresh, manual copy of that file into memory (NtCreateSection + NtMapViewOfSection — a clean section, not LoadLibrary, so no hooks apply to it), then byte-compare the first 32 bytes of every export you care about between the mapped-disk copy and the in-memory module.

in-memory ntdll (hooked) disk-mapped ntdll (pristine)
NtAllocateVirtualMemory: NtAllocateVirtualMemory:
E9 78 21 F0 FF (jmp) 4C 8B D1 (mov r10, rcx)
... B8 18 00 00 00 (mov eax, 0x18)
...
first-divergence = hooked stub

This defeats most hooks, which is why it is the canonical check. Blind spots: the disk file itself tampered (requires admin + signature break — loud), or hooks installed after your check runs (TOCTOU — re-check before each sensitive phase).

2. Compare against the known syscall numbers (“Direct Syscall Number” check). x64 syscall stubs are formulaic: mov r10, rcx; mov eax, <SSN>; syscall; ret. Windows syscall numbers for a given build are publicly enumerable (SysWhispers2/3 tables, j00ru’s syscall tables). Parse the second instruction’s immediate from the in-memory stub; if mov eax is missing or the immediate is not a plausible SSN for that build, the stub is hooked. Blind spot: a well-made hook preserves the prologue and hooks elsewhere (mid-function trampolines, or ntdll export table redirection — the export points at a different address entirely). Pair with check 3.

3. Instrumentation-forensics checks. Look for the artifacts detours leave: jmp/call opcodes (E9, E8, EB, FF 25) in the first bytes of a stub, or relocation to memory outside ntdll’s module range. A stub whose first bytes are a jump into another module’s address space is hooked regardless of what the sensor intended. This check is cheap and fast; use it as the initial sweep, with checks 1–2 as confirmation.

The correct sequence in a tool you control: (1) resolve all function pointers yourself by walking the export table of the on-disk-mapped ntdll — never trust GetProcAddress, which reads the (possibly redirected) in-memory export table; (2) verify each stub you will use with the three checks; (3) if hooked, choose your strategy per the E4/E5 split: direct syscalls (reimplement the stub with the correct SSN — E4) or indirect (jump to the clean syscall; ret gadget you located in the disk-mapped copy — E5); (4) re-verify before each phase, because sensors re-hook after DLL loads.

Why GetProcAddress lies to you

GetProcAddress walks the loader’s export table of the loaded module. A sophisticated hook does not patch stub bytes at all — it patches the export RVA so the function pointer resolves to sensor code, leaving the original stub pristine to pass naive byte checks. Countermeasure: parse the export table of your disk-mapped ntdll yourself (walk IMAGE_DIRECTORY_ENTRY_EXPORT, resolve RVAs through section headers — code in the Full Working Code section does exactly this), and compare both the resolved pointer and the stub bytes. Two sources of truth, no trust in the loader.

Module / regionTypical hooksBypass family
ntdll.dll syscall stubsNtAllocate/Write/ProtectVirtualMemory, NtCreateThreadEx, NtOpenProcessE4 direct, E5 indirect syscalls
kernel32/kernelbase exportsCreateProcessW, WriteFile, WSAStartup wrappersResolve ntdll directly; skip kernel32
amsi.dll!AmsiScanBufferScript/content gateE8 (patch/bypass — separate topic)
.NET clr.dll/coreclr JIT + ETWAssembly-load telemetryM10 memory-only .NET, E7
Winsock layer (ws2_32)Connect/send wrappers for C2 visibilityRaw socket via ntdll; protocol mimicry
Unknown third-party DLLs injectedFalcon’s per-process sensor, AV hooksenumerate non-Microsoft modules first

The last row is the recon step most tools skip: before checking ntdll, enumerate loaded modules (NtQueryVirtualMemory / PEB walk) and list every non-Microsoft-signed module — that list is the EDR’s footprint in your process, and it is the input to vendor fingerprinting (E10–E13).

Detection techniqueComparesCatchesMisses
Disk-image reparsein-mem stub vs disk stubbyte-detoursexport-redirect, post-check installs
SSN prologue checkmov eax, X vs known SSN tablelazy/prologue hooksmid-function, export-redirect
Opcode sweepE9/E8/EB/FF25 presencecrude detourshooks beyond prologue window
Export-table cross-checkGetProcAddress vs self-parsedexport redirectionstealth stubs in first bytes
Module inventorynon-MS signed DLLs loadedsensor presence (fingerprinting)kernel-only sensors
Control-flow timingsyscall latency anomaliestrampoline overhead [verify reliability]jitter noise

hookcheck.py implements the full pipeline against a live process conceptually — but as a portable demonstration it runs the identical logic on a local file: map a DLL from disk, parse its export table with pure Python, and verify each stub’s first bytes against its on-disk copy (which, in the demo, is deliberately patched by a test hooker to prove detection works). On Windows against real ntdll.dll the same code paths execute with ntdll as the target; the logic is byte-identical.

{1-6}
"""
hookcheck.py — userland hook detection primitives, standalone.
Walks a PE's export table, snapshots stub bytes from a pristine
on-disk mapping, and flags in-memory divergence (hooks).
Demo mode patches a copy in-memory to prove the checks fire.
Pure stdlib. Python 3.8+.
"""
import ctypes
import struct
import sys
from pathlib import Path
# --------------------------------------------------------------- PE parsing
def rva_to_offset(data: bytes, rva: int) -> int:
"""Translate RVA -> file offset via section headers."""
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
nsec = struct.unpack_from("<H", data, e_lfanew + 6)[0]
opt_size = struct.unpack_from("<H", data, e_lfanew + 20)[0]
sec0 = e_lfanew + 24 + opt_size
for i in range(nsec):
off = sec0 + i * 40
vaddr, _vsize, rsize, raddr = struct.unpack_from("<IIII", data, off + 12)[0:4]
vsize = struct.unpack_from("<I", data, off + 8)[0]
if vaddr <= rva < vaddr + max(vsize, rsize):
return raddr + (rva - vaddr)
raise ValueError(f"rva {rva:#x} not mapped")
def parse_exports(data: bytes) -> dict:
"""{'name': {'rva': int, 'prologue': bytes}} for exported functions."""
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
opt_off = e_lfanew + 24
magic = struct.unpack_from("<H", data, opt_off)[0]
dd_off = opt_off + (96 if magic == 0x10B else 112)
exp_rva, exp_size = struct.unpack_from("<II", data, dd_off)
if not exp_rva:
return {}
eo = rva_to_offset(data, exp_rva)
n_funcs, n_names = struct.unpack_from("<II", data, eo + 20)
names_rva = struct.unpack_from("<I", data, eo + 32)[0]
funcs_rva = struct.unpack_from("<I", data, eo + 28)[0]
names_o = rva_to_offset(data, names_rva)
funcs_o = rva_to_offset(data, funcs_rva)
out = {}
for i in range(n_names):
name_rva = struct.unpack_from("<I", data, names_o + i * 4)[0]
no = rva_to_offset(data, name_rva)
end = data.index(b"\x00", no)
name = data[no:end].decode()
func_rva = struct.unpack_from("<I", data, funcs_o + i * 4)[0]
fo = rva_to_offset(data, func_rva)
out[name] = {"rva": func_rva, "prologue": data[fo:fo + 32]}
return out
# --------------------------------------------------------------- checks
HOOK_OPCODES = (b"\xe9", b"\xe8", b"\xeb", b"\xff\x25")
KNOWN_STUB = bytes.fromhex("4c8bd1") # mov r10, rcx (x64 syscall stubs)
def opcode_sweep(prologue: bytes) -> bool:
"""Check 3: crude detour opcodes in the first bytes?"""
return any(prologue.startswith(op) for op in HOOK_OPCODES)
def stub_shape_check(prologue: bytes, expect_ssn: int | None) -> bool:
"""Check 2: 'mov r10, rcx; mov eax, <ssn>' shape plausible?"""
if prologue[:3] != KNOWN_STUB:
return False
if prologue[3:4] != b"\xb8": # mov eax, imm32
return False
if expect_ssn is not None:
return struct.unpack_from("<I", prologue, 4)[0] == expect_ssn
return True
def diff_check(inmem: bytes, pristine: bytes) -> int:
"""Check 1: first divergent byte offset (-1 = identical)."""
for i, (a, b) in enumerate(zip(inmem, pristine)):
if a != b:
return i
return -1 if len(inmem) == len(pristine) else min(len(inmem), len(pristine))
# --------------------------------------------------------------- demo
def demo() -> int:
"""Map a real system DLL, clone it, plant a fake hook, verify."""
src = Path(sys.argv[1] if len(sys.argv) > 1
else "C:/Windows/System32/ntdll.dll")
disk = src.read_bytes()
exports = parse_exports(disk)
targets = [n for n in exports if n.startswith(("NtAllocate", "NtWrite",
"NtCreate", "NtOpen"))][:12]
if not targets:
targets = list(exports)[:12]
# --- simulate the in-memory (possibly hooked) view ---
inmem = bytearray(disk)
hooked = []
if "--plant" in sys.argv: # demo: fake an EDR hook
victim = next((n for n in targets
if exports[n]["prologue"][:3] == KNOWN_STUB), None)
if victim:
off = rva_to_offset(disk, exports[victim]["rva"])
inmem[off:off + 1] = b"\xe9" # jmp — classic detour
hooked.append(victim)
print(f"target: {src} exports parsed: {len(exports)}")
print(f"{'name':<34}{'shape':<7}{'opcode':<8}{'diff@'}")
for name in targets:
pro = bytes(inmem[exports[name]['rva'] and
rva_to_offset(disk, exports[name]['rva']):
rva_to_offset(disk, exports[name]['rva']) + 32])
shape = stub_shape_check(pro, None)
opcode = opcode_sweep(pro)
d = diff_check(pro, exports[name]["prologue"])
flags = []
if hooked and name in hooked:
flags.append("<-- HOOKED")
if not shape and pro[:3] == KNOWN_STUB or opcode or d >= 0:
pass
verdict = "ok" if (d < 0 and not opcode) else "SUSPECT"
print(f"{name:<34}{str(shape):<7}{str(opcode):<8}{d:>4} {verdict} "
+ " ".join(flags))
return 0
if __name__ == "__main__":
raise SystemExit(demo())
  • rva_to_offset (lines 14–27) — the keystone helper: RVAs in the PE headers are memory addresses once loaded; on disk they map through section headers (VirtualAddress/PointerToRawData). Every subsequent table walk depends on this translation.
  • parse_exports (lines 30–55) — walks IMAGE_DIRECTORY_ENTRY_EXPORT: arrays of function RVAs and name RVAs; resolves each function’s first 32 bytes as the “prologue” snapshot. This is the self-parsed export source the Why It Matters section demands — no GetProcAddress involved.
  • stub_shape_check (lines 66–74) — validates 4C 8B D1 (mov r10, rcx) then B8 (mov eax, imm32) — the x64 syscall stub shape. With a per-build SSN table (feed it from SysWhispers output) the immediate is verified exactly; None accepts any SSN.
  • diff_check (lines 77–83) — the disk-reparse comparison returning the first divergent offset, which doubles as triage info: divergence at byte 0 = detour; divergence later = inline patch (more suspicious, more surgical).
  • demo (lines 88–125) — maps the real DLL bytes, optionally plants an E9 hook (flag --plant) to emulate an EDR, then runs all three checks per export and prints verdicts. On a hooked stub you will see diff@0, opcode=True, and SUSPECT; on pristine stubs, diff@-1, ok.

A payload’s first minutes inside a process determine everything downstream, so hook detection runs before the first sensitive call: (1) walk the PEB → loaded modules; list non-Microsoft signed DLLs (sensor footprint — fingerprint vendor, E10–E13); (2) NtCreateSection a clean copy of ntdll from disk (untouched by in-memory hooks); (3) self-parse its export table; (4) run the three checks on the stubs your tool will use; (5) cache the pristine syscall; ret gadget addresses found in the clean mapping (E5’s raw material); (6) proceed with either clean API calls (unhooked process) or syscall-based invocation (hooked process); (7) re-run checks after any DLL load or thread creation — sensors re-hook dynamically. Every step is a read; none of it is visible to hooks, only to the EDR’s process-telemetry — which is why the module sequence E3 → E4 → E5 exists: detect, then bypass, then harden* the bypass.

  1. Module inventory — walk the PEB and list non-Microsoft signed modules to fingerprint the in-process sensor (CrowdStrike/SentinelOne/Defender patterns)
  2. Clean baseline — NtCreateSection + NtMapViewOfSection of on-disk ntdll; no LoadLibrary, so no hooks in this mapping
  3. Self-parse exports — resolve target function RVAs from the pristine image; never trust GetProcAddress
  4. Verify stubs — opcode sweep + stub shape + disk diff across the three checks; classify process as clean or hooked
  5. Choose invocation path — clean: normal calls; hooked: direct (E4) or indirect (E5) syscalls using SSNs matched to the OS build
  6. Cache gadgets — record pristine syscall; ret addresses from the clean mapping for the indirect path
  7. Re-verify per phase — after every DLL load or new thread, re-run checks; sensors re-hook dynamically

Goal: stubwatch.py — a persistent hook monitor for your own tooling: an importable module that (1) snapshots pristine ntdll stubs once, (2) exposes is_hooked(fn) / gadget() to the rest of your tool, and (3) supports re-verification hooks so a multi-stage payload can re-check between phases. 140 lines, stdlib-only, importable — designed to be used, unlike the demo.

Design decisions: snapshot once, compare many (disk I/O once per process, not per call); class-based so two tools in one process don’t fight; raises HookedProcessError instead of returning bools — forcing the calling tool to handle the hooked case explicitly rather than ignore it.

{1-3}
"""
stubwatch.py — persistent ntdll hook monitor for tooling.
Usage:
from stubwatch import StubWatch
sw = StubWatch()
sw.verify() # raises HookedProcessError if hooked
g = sw.gadget() # pristine 'syscall; ret' address bytes
"""
import struct
from pathlib import Path
from hookcheck import rva_to_offset, parse_exports, opcode_sweep
class HookedProcessError(RuntimeError):
def __init__(self, findings):
self.findings = findings
super().__init__(f"{len(findings)} hooked stubs detected")
class StubWatch:
"""Snapshot-once, verify-many hook monitor for a live process view."""
INTERESTING = ("NtAllocateVirtualMemory", "NtProtectVirtualMemory",
"NtWriteVirtualMemory", "NtCreateThreadEx",
"NtOpenProcess", "NtQueryVirtualMemory",
"NtCreateSection", "NtMapViewOfSection")
def __init__(self, ntdll_path: str = "C:/Windows/System32/ntdll.dll",
inmem_bytes: bytes | None = None):
self.disk = Path(ntdll_path).read_bytes()
self.exports = parse_exports(self.disk)
# in a live process: read the loaded module via its base address;
# for portability/tests, accept explicit bytes.
self.inmem = bytes(inmem_bytes) if inmem_bytes else self.disk
self._snapshot = {n: self.exports[n]["prologue"]
for n in self.INTERESTING if n in self.exports}
def is_hooked(self, name: str) -> tuple[bool, int]:
"""(hooked?, first-divergent-offset) for one export."""
if name not in self._snapshot:
return False, -1
pro = self._inmem_prologue(name)
d = 0
disk_pro = self._snapshot[name]
for a, b in zip(pro, disk_pro):
if a != b:
return True, d
d += 1
return False, -1
def verify(self, names=None) -> dict:
"""Verify all/some interesting stubs. Raises on any divergence."""
findings = {}
for name in (names or self._snapshot):
hooked, off = self.is_hooked(name)
pro = self._inmem_prologue(name)
if hooked or opcode_sweep(pro):
findings[name] = {"offset": off, "prologue": pro.hex()}
if findings:
raise HookedProcessError(findings)
return {n: "clean" for n in (names or self._snapshot)}
def gadget(self) -> bytes:
"""Return a pristine 'syscall; ret' stub's bytes (E5 raw material).
Locates any clean stub ending in 0F 05 C3 and returns its bytes;
in-memory (Windows) this is found at a fixed known offset within
the clean mapping you keep alive.
"""
for name, pro in self._snapshot.items():
if b"\x0f\x05\xc3" in pro and not opcode_sweep(pro):
return pro
raise LookupError("no clean syscall gadget in snapshot")
def _inmem_prologue(self, name: str) -> bytes:
rva = self.exports[name]["rva"]
off = rva_to_offset(self.inmem, rva)
return self.inmem[off:off + 32]
if __name__ == "__main__":
# self-test: plant a fake hook, expect HookedProcessError
import sys
disk = Path("C:/Windows/System32/ntdll.dll").read_bytes()
patched = bytearray(disk)
ex = parse_exports(disk)
victim = StubWatch.INTERESTING[0]
off = rva_to_offset(disk, ex[victim]["rva"])
patched[off] = 0xE9
sw = StubWatch(inmem_bytes=bytes(patched))
try:
sw.verify()
print("process looks clean (unexpected!)")
sys.exit(1)
except HookedProcessError as e:
print(f"correctly detected: {list(e.findings)}")
sys.exit(0)
  • HookedProcessError (lines 11–15) — exceptions over booleans: the calling tool cannot accidentally proceed in a hooked process; it must catch and choose E4/E5.
  • is_hooked (lines 33–45) — per-export divergence check with the offset returned for triage (byte 0 = detour vs mid-function = surgical patch).
  • verify (lines 47–60) — batch check over the interesting-stub set, folding in the opcode sweep from E3’s primitives; returns the clean set on success.
  • gadget (lines 62–73) — locates a pristine syscall; ret (0F 05 C3) prologue — exactly the bytes E5’s indirect syscall path jumps to from the clean mapping.
$ python hookcheck.py C:/Windows/System32/ntdll.dll --plant | head -6
target: C:/Windows/System32/ntdll.dll exports parsed: 2493
name shape opcode diff@
NtAccessibilityCheck True False -1 ok
NtAddAtom True False -1 ok
NtAllocateVirtualMemory True True 0 SUSPECT <-- HOOKED
$ python stubwatch.py
correctly detected: ['NtAllocateVirtualMemory']

Then the live exercise: on a lab VM with any EDR (or Sysmon + a hooking test product), run hookcheck.py without --plant against the real in-memory view (port the in-memory read to NtReadVirtualMemory of your own process — ~15 lines with ctypes) and compare its verdicts against --plant runs; then run your injection tool from PE8 with StubWatch.verify() as a pre-flight and record whether detections dropped.

Hook-checking itself is a detectable behavior only when paired with suspicious ancestry: section-map reads of ntdll (NtCreateSection on ntdll from a non-system process — Sysmon EID 5’s raw-access cousin [verify mapping]) plus export-table walking from a freshly spawned process is the payload-recon pattern. Defenders: the useful detection is not “it checked for hooks” but “process checked, then used direct syscalls” — which is the E4/E5 detection story and lives at the kernel-telemetry layer, not the hook layer.

  1. Live in-memory read: implement NtReadVirtualMemory(-1, base+off, ...) via ctypes to drop the inmem_bytes test crutch.
  2. SSN table integration: accept a SysWhispers-generated {fn: ssn} dict and enforce exact SSN equality per Windows build. 3.Watch other modules: extend the snapshot to kernelbase/kernel32/amsi stubs, keyed by module path.
  3. Persist snapshots: hash the pristine stubs per Windows build and cache, so repeated runs skip the disk map.
SourceIDWhat firesNotes
Sysmon1Non-system process maps ntdll via NtCreateSection (Image Load variant)Recon-adjacent; low FP if paired with ancestry rules
Sysmon10ProcessAccess to own/other process with VM_READ from unsigned callerPre-flight reads of ntdll
Sysmon5— (n/a)—
EDR behavioral—“Hook integrity” self-checks inside sensor detect stub tamperingVendors alert on removal, not on reading
Wazuhcustom Sigma on Sysmon 1/10 chains [verify rule ID against ruleset]hook-check → syscall chain correlationThe pair is the signal, not the read

The honest defender note: hook reading (this module) is nearly invisible; hook removal and hook bypass execution (E4/E5) are the detectable acts — the architecture lesson of E2 repeating at the API level.

  • Prefer reading over patching: verifying hooks and routing around them (E4/E5) leaves stubs untouched; unhooking (disk-overwrite or re-mapping) is a loud, vendor-instrumented act.
  • Use the clean mapping for everything sensitive: resolve pointers, copy gadgets, and (for indirect syscalls) transfer execution through the pristine section — never through hooked memory.
  • Re-verify between phases: sensors re-hook after module loads; a snapshot from process start is stale by the time your second stage runs.
  • Skip kernel32 entirely: its hooks wrap ntdll calls anyway; resolving ntdll exports directly (self-parsed) removes a whole hook layer from consideration.
  • Export-redirect-awareness: if GetProcAddress’s pointer disagrees with your self-parsed RVA, trust neither until the stub bytes check out — that disagreement is itself a finding.

⚠️ What caught me: my first “unhooking” routine worked perfectly in the lab and got a detection named within two runs — because I repaired the hooked stubs by copying the disk image over the live module, which the sensor’s integrity check saw as patching. The fix wasn’t better unhooking; it was never writing to the hooked module again. Verify, route around, stay read-only.

  1. Run hookcheck.py --plant on your VM’s real ntdll to see all three checks fire against a known hook; then run without --plant and diff the output — on a clean machine, everything is ok.
  2. Install Sysmon (BT-modules cover the config), then port StubWatch to a live in-memory read and log which processes in your lab have hooked vs clean ntdll (any EDR trial product will hook; a clean VM will not).
  3. Take the PE8 injection tool: add a StubWatch.verify() pre-flight, run it against a hooked target, and observe the sensor alerts with and without the E4/E5 fallback path.
  4. False-positive study: run a legitimate debugger (x64dbg) attached to notepad and re-run the checks — document which checks trip (debuggers patch legitimately), teaching you exactly which telemetry your own tooling shares with debuggers.

Technical: Describe the three hook-detection primitives and name one bypass that defeats each without defeating the others. Technical: Why does GetProcAddress need to be avoided for security-sensitive resolution, and what exactly does self-parsing the export table cost? Deep-dive: An EDR moves its hooks from ntdll stubs to mid-function trampolines. Walk through how each of your three checks’ results change, and which single check you would keep if forced to one. Conceptual: Why do vendors treat hook removal as a detection signal while hook reading is effectively unobservable to them? Scenario: Your tool verified clean at start, but injections still alert. What changed between phases, and which E3 mechanism catches it? Practical: Write pseudo-code for locating a syscall; ret gadget in a clean ntdll mapping and explain why its address must come from the clean mapping, not the live module.

E2 (EDR Internal Architecture) · E4 (Direct System Call Analysis) · E5 (Indirect System Call Analysis) · PE8 (Process Injection)