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.
Definition
Section titled “Definition”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.
Why It Matters
Section titled “Why It Matters”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.
Detailed Explanation
Section titled “Detailed Explanation”The three detection primitives
Section titled “The three detection primitives”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 stubThis 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.
Order of operations for a clean process
Section titled “Order of operations for a clean process”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.
Where hooks actually live (full map)
Section titled “Where hooks actually live (full map)”| Module / region | Typical hooks | Bypass family |
|---|---|---|
ntdll.dll syscall stubs | NtAllocate/Write/ProtectVirtualMemory, NtCreateThreadEx, NtOpenProcess | E4 direct, E5 indirect syscalls |
kernel32/kernelbase exports | CreateProcessW, WriteFile, WSAStartup wrappers | Resolve ntdll directly; skip kernel32 |
amsi.dll!AmsiScanBuffer | Script/content gate | E8 (patch/bypass — separate topic) |
.NET clr.dll/coreclr JIT + ETW | Assembly-load telemetry | M10 memory-only .NET, E7 |
Winsock layer (ws2_32) | Connect/send wrappers for C2 visibility | Raw socket via ntdll; protocol mimicry |
| Unknown third-party DLLs injected | Falcon’s per-process sensor, AV hooks | enumerate 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).
Common Techniques
Section titled “Common Techniques”| Detection technique | Compares | Catches | Misses |
|---|---|---|---|
| Disk-image reparse | in-mem stub vs disk stub | byte-detours | export-redirect, post-check installs |
| SSN prologue check | mov eax, X vs known SSN table | lazy/prologue hooks | mid-function, export-redirect |
| Opcode sweep | E9/E8/EB/FF25 presence | crude detours | hooks beyond prologue window |
| Export-table cross-check | GetProcAddress vs self-parsed | export redirection | stealth stubs in first bytes |
| Module inventory | non-MS signed DLLs loaded | sensor presence (fingerprinting) | kernel-only sensors |
| Control-flow timing | syscall latency anomalies | trampoline overhead [verify reliability] | jitter noise |
Full Working Code
Section titled “Full Working Code”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 pristineon-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 ctypesimport structimport sysfrom pathlib import Path
# --------------------------------------------------------------- PE parsingdef 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
# --------------------------------------------------------------- checksHOOK_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))
# --------------------------------------------------------------- demodef 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())Code Walkthrough
Section titled “Code Walkthrough”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) — walksIMAGE_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 — noGetProcAddressinvolved.stub_shape_check(lines 66–74) — validates4C 8B D1(mov r10, rcx) thenB8(mov eax, imm32) — the x64 syscall stub shape. With a per-build SSN table (feed it from SysWhispers output) the immediate is verified exactly;Noneaccepts 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 anE9hook (flag--plant) to emulate an EDR, then runs all three checks per export and prints verdicts. On a hooked stub you will seediff@0,opcode=True, andSUSPECT; on pristine stubs,diff@-1,ok.
How It Reaches The Target
Section titled “How It Reaches The Target”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.
Exploitation Chain
Section titled “Exploitation Chain”- Module inventory — walk the PEB and list non-Microsoft signed modules to fingerprint the in-process sensor (CrowdStrike/SentinelOne/Defender patterns)
- Clean baseline — NtCreateSection + NtMapViewOfSection of on-disk ntdll; no LoadLibrary, so no hooks in this mapping
- Self-parse exports — resolve target function RVAs from the pristine image; never trust GetProcAddress
- Verify stubs — opcode sweep + stub shape + disk diff across the three checks; classify process as clean or hooked
- Choose invocation path — clean: normal calls; hooked: direct (E4) or indirect (E5) syscalls using SSNs matched to the OS build
- Cache gadgets — record pristine
syscall; retaddresses from the clean mapping for the indirect path - Re-verify per phase — after every DLL load or new thread, re-run checks; sensors re-hook dynamically
Building Your Own Tool
Section titled “Building Your Own Tool”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 structfrom pathlib import Pathfrom 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)Walkthrough
Section titled “Walkthrough”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 pristinesyscall; ret(0F 05 C3) prologue — exactly the bytes E5’s indirect syscall path jumps to from the clean mapping.
Compilation and test procedure
Section titled “Compilation and test procedure”$ python hookcheck.py C:/Windows/System32/ntdll.dll --plant | head -6target: C:/Windows/System32/ntdll.dll exports parsed: 2493name shape opcode diff@NtAccessibilityCheck True False -1 okNtAddAtom True False -1 okNtAllocateVirtualMemory True True 0 SUSPECT <-- HOOKED$ python stubwatch.pycorrectly 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.
Detection considerations
Section titled “Detection considerations”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.
Improvements to add
Section titled “Improvements to add”- Live in-memory read: implement
NtReadVirtualMemory(-1, base+off, ...)via ctypes to drop theinmem_bytestest crutch. - 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. - Persist snapshots: hash the pristine stubs per Windows build and cache, so repeated runs skip the disk map.
Detection — Defender View
Section titled “Detection — Defender View”| Source | ID | What fires | Notes |
|---|---|---|---|
| Sysmon | 1 | Non-system process maps ntdll via NtCreateSection (Image Load variant) | Recon-adjacent; low FP if paired with ancestry rules |
| Sysmon | 10 | ProcessAccess to own/other process with VM_READ from unsigned caller | Pre-flight reads of ntdll |
| Sysmon | 5 | — (n/a) | — |
| EDR behavioral | — | “Hook integrity” self-checks inside sensor detect stub tampering | Vendors alert on removal, not on reading |
| Wazuh | custom Sigma on Sysmon 1/10 chains [verify rule ID against ruleset] | hook-check → syscall chain correlation | The 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.
Evasion Techniques
Section titled “Evasion Techniques”- 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.
Lab Practice
Section titled “Lab Practice”- Run
hookcheck.py --planton your VM’s real ntdll to see all three checks fire against a known hook; then run without--plantand diff the output — on a clean machine, everything isok. - Install Sysmon (BT-modules cover the config), then port
StubWatchto 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). - 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. - 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.
Interview Questions
Section titled “Interview Questions”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.
Related Modules
Section titled “Related Modules”E2 (EDR Internal Architecture) · E4 (Direct System Call Analysis) · E5 (Indirect System Call Analysis) · PE8 (Process Injection)