Debugging Detection
Every packer, every commercial protector, and most modern droppers include an anti-debug layer: code that checks whether an analyst is watching before doing anything interesting. The checks are Windows internals in miniature — PEB fields, undocumented NtQueryInformationProcess classes, TLS callbacks that run before main — and knowing them is double-edged knowledge: it is exactly how you hide from a debugger, and exactly how you build tooling that catches the hiders. This module catalogs the check families with their real API surfaces, explains the PEB tricks that make naive checks lie, and builds a working anti-debug auditor you can run with and without a debugger attached to feel every check fire.
Definition
Section titled “Definition”Debugger evasion (T1622) is the set of runtime checks that detect (or disrupt) debugging, in five families:
| Family | Checks | API/internals |
|---|---|---|
| Flag checks | PEB.BeingDebugged, NtGlobalFlag, heap ForceFlags | IsDebuggerPresent, PEB offsets |
| Process introspection | Debug port, debug object, debug flags | NtQueryInformationProcess classes 0x7/0x1E/0x1F |
| Hardware/software BP | DR0–DR7 registers, 0xCC byte scans | GetThreadContext, self-checksums |
| Timing | Slowdown detection | rdtsc, QueryPerformanceCounter deltas |
| Disruption | Hide threads, crash debuggers, TLS-callback traps | NtSetInformationThread(ThreadHideFromDebugger), INT 3/INT 2D |
The PEB (x64) — where the naive checks live: ┌─────────────────────────────────────────────┐ │ 0x00 InheritedHandleTable ... │ │ 0x02 BeingDebugged ◄── IsDebuggerPresent│ patchable by the │ 0x03 BitField │ process itself — │ ... │ which is why the │ 0xBC NtGlobalFlag ◄── heap debug flags │ advanced checks │ (0x70 = debug-created heap flags) │ bypass the PEB └─────────────────────────────────────────────┘Why It Matters
Section titled “Why It Matters”For the reverse engineer (S2’s territory, but the foundation is here): stepping through a protected binary means answering which check fired — a crash or silent exit at NTVDM-era protectors is undiagnosable without this map, and every bypass (PEB patches, plugin-hide, process-mitigation fakes) is downstream of knowing the check. For the defender: anti-debug is telemetry — a process calling NtQueryInformationProcess with debug classes, or patching its own PEB, is announcing that it cares who watches it, which is a stronger signal than most file detections. For the malware analyst: the module’s checks are the reason dynamic analysis lies to you — a sample that detects your debugger will change behavior, and silently, so the professional workflow runs every sample twice (debugged and un-debugged, with tooling that reports the difference).
There is also the interview-shaped reason: PEB internals, undocumented NtQuery classes, and TLS callbacks are the Windows-internals litmus test — “walk me through what IsDebuggerPresent actually reads” is a question with a one-field answer and a thousand-vertex dependency graph, and this module is the graph.
Detailed Explanation
Section titled “Detailed Explanation”The flag family: what the PEB leaks
Section titled “The flag family: what the PEB leaks”IsDebuggerPresent is one field read: PEB->BeingDebugged (offset 0x02), set by the loader when a debugger attaches. Naive, trivially patched (the process can zero its own byte — the first technique every protector ships). Two stronger siblings live nearby: NtGlobalFlag (x64 offset 0xBC) — when a process is created under a debugger, the heap inherits debug flags (FLG_HEAP_ENABLE_TAIL_CHECK | FREE_CHECK | VALIDATE_PARAMETERS = 0x70), a state that survives the BeingDebugged patch — and heap ForceFlags, the same idea at heap granularity. The lesson generalizes: derivative artifacts (flags set as side-effects of debugging) are harder to fake than the direct flag, because the process must fake the whole heap state, not one byte.
The introspection family: NtQueryInformationProcess
Section titled “The introspection family: NtQueryInformationProcess”The real anti-debug workhorses are three undocumented (but thoroughly documented-by-reverse-engineers) information classes:
NtQueryInformationProcess(hProcess, class, &buf, len, &retlen) class 0x07 ProcessDebugPort → nonzero when debugged (legacy) class 0x1E ProcessDebugObjectHandle→ handle to the debug object; debuggers create exactly one class 0x1F ProcessDebugFlags → 0x0 means DEBUGGED (inverted!)ProcessDebugObjectHandle is the strongest: the debug object is created by the kernel when debugging begins, and a process cannot fake its absence without kernel help — which is why ring-0 anti-cheats use it and why ring-3 protectors combine all three. The inverted semantics of ProcessDebugFlags (zero means debugged) is a classic off-by-one trap for tool authors and a nice interview gotcha.
TLS callbacks: running before main — and before your breakpoint
Thread/Process TLS callbacks execute before the entrypoint the debugger initially breaks on (x64 PE: IMAGE_DIRECTORY_ENTRY_TLS → callback array). Protectors put first-line checks there: by the time your bp entry hits, the callback has already probed BeingDebugged, NtGlobalFlag, and the debug port, and may have patched itself or exited. The analyst counter-habit: break on TLSCallBack* (or NtDll’s loader functions like LdrpDoDebuggerBreak [verify per build]) rather than the entrypoint. This is also why static tools flag TLS directories — a TLS callback in an unsigned downloader is a behavior worth noting before anything executes.
Hardware breakpoints and the disruption family
Section titled “Hardware breakpoints and the disruption family”GetThreadContext with CONTEXT_DEBUG_REGISTERS reads DR0–DR3 (breakpoint addresses) and DR7 (enable flags) — hardware breakpoints leave these registers set, and protectors poll them (often from a watchdog thread the analyst must also notice). Software breakpoints (the 0xCC byte int3 inserts) are caught by self-checksumming: code sections hashed at runtime, deltas flagging patched bytes. The disruption family then raises the stakes: NtSetInformationThread(ThreadHideFromDebugger, 0x11) removes a thread from debugger event delivery (breakpoints on it stop firing), invalid CloseHandle calls raise exceptions that crash naive debuggers, INT 2D/ICEBP throw undocumented-path exceptions, and self-debugging (a watchdog process debugging the payload) makes the debug object check a trap for the tool rather than the target.
Common Techniques
Section titled “Common Techniques”| Check | Reads | Fake cost | Real-world use |
|---|---|---|---|
IsDebuggerPresent | PEB 0x02 | Trivial (patch byte) | Baseline; presence signal |
| NtGlobalFlag | PEB 0xBC | Medium (fake heap state) | Under-creation detection |
| ProcessDebugPort | class 0x7 | Medium | Legacy but common |
| ProcessDebugObjectHandle | class 0x1E | High (kernel object) | Anti-cheats, modern protectors |
| ProcessDebugFlags | class 0x1F (inverted) | Medium | Common trap for tool authors |
| DR0–DR7 polling | thread context | Medium (clear regs) | Watchdog threads |
| 0xCC self-scan | own .text checksums | Medium | Inline BP detection |
| Timing deltas | rdtsc/QPC | Medium | Slowdown detection |
| ThreadHideFromDebugger | set-state call | N/A (disruption) | Breakpoint evasion |
Full Working Code
Section titled “Full Working Code”dbgpoke.py — a working anti-debug auditor for Windows (ctypes, no dependencies): runs every family’s check on the current process — IsDebuggerPresent, CheckRemoteDebuggerPresent, all three NtQueryInformationProcess classes, PEB fields via NtCurrentTeb — plus a timing baseline. On non-Windows hosts it runs a mock PEB so the logic remains studyable. Run it with and without x64dbg attached; the delta is the lab.
{1-6}"""dbgpoke.py — anti-debug audit: every family, one process, one report.Windows: real APIs via ctypes. Non-Windows: mock PEB (study mode).Usage: python dbgpoke.py # audit current process python dbgpoke.py --patch # demo: zero own PEB.BeingDebugged python dbgpoke.py --timing # include slowdown baseline"""import ctypesimport structimport sysimport time
IS_WINDOWS = sys.platform == "win32"report = {}
# ---------------- Windows real internals ---------------------------if IS_WINDOWS: ntdll = ctypes.WinDLL("ntdll") kernel32 = ctypes.WinDLL("kernel32")
def teb_peb(): """NtCurrentTeb() -> PEB at gs:[0x60] via documented API.""" teb = ctypes.c_void_p(ntdll.NtCurrentTeb()).value peb = struct.unpack_from("<Q", ctypes.string_at(teb + 0x60, 8))[0] return peb
def read_peb_fields(): peb = teb_peb() being_dbg = ctypes.string_at(peb + 0x02, 1)[0] ntgflag = struct.unpack_from("<I", ctypes.string_at(peb + 0xBC, 4))[0] return {"BeingDebugged": being_dbg, "NtGlobalFlag": ntgflag, "NtGlobalFlag_debugset": bool(ntgflag & 0x70)}
def ntqip(cls): """NtQueryInformationProcess wrapper returning (status, value).""" buf = ctypes.c_void_p(0) retlen = ctypes.c_ulong(0) h = kernel32.GetCurrentProcess() st = ntdll.NtQueryInformationProcess( ctypes.c_void_p(h), ctypes.c_int(cls), ctypes.byref(buf), ctypes.sizeof(buf), ctypes.byref(retlen)) return st, buf.value
def check_flag_family(): f = read_peb_fields() report["PEB.BeingDebugged"] = f["BeingDebugged"] report["PEB.NtGlobalFlag"] = hex(f["NtGlobalFlag"]) report["NtGlobalFlag debug (0x70)"] = f["NtGlobalFlag_debugset"] report["IsDebuggerPresent"] = bool( kernel32.IsDebuggerPresent())
def check_introspection(): st, port = ntqip(0x07) report["ProcessDebugPort (0x7)"] = hex(port) if st == 0 else f"err {st:#x}" st, obj = ntqip(0x1E) report["ProcessDebugObject (0x1E)"] = hex(obj) if st == 0 else f"err {st:#x}" st, flags = ntqip(0x1F) report["ProcessDebugFlags (0x1F)"] = \ f"{flags:#x} ({'DEBUGGED' if flags == 0 else 'clean'})" \ if st == 0 else f"err {st:#x}"
def patch_being_debugged(): """Demo: zero own PEB byte (the classic first technique).""" peb = teb_peb() ctypes.memset(peb + 0x02, 0, 1) report["patched"] = "PEB.BeingDebugged := 0"
# ---------------- non-Windows mock (study mode) ---------------------else: MOCK = {"BeingDebugged": 0, "NtGlobalFlag": 0}
def read_peb_fields(): return MOCK
def check_flag_family(): report["PEB.BeingDebugged"] = MOCK["BeingDebugged"] report["PEB.NtGlobalFlag"] = hex(MOCK["NtGlobalFlag"]) report["IsDebuggerPresent"] = False
def check_introspection(): report["ProcessDebugPort (0x7)"] = "mock: unsupported" report["ProcessDebugObject (0x1E)"] = "mock: unsupported" report["ProcessDebugFlags (0x1F)"] = "mock: unsupported"
def patch_being_debugged(): MOCK["BeingDebugged"] = 0 report["patched"] = "mock BeingDebugged := 0"
def timing_baseline(samples=3, work_ms=50) -> dict: """Slowdown tell: QPC-equivalent deltas around a fixed workload.""" deltas = [] for _ in range(samples): a = time.perf_counter_ns() t0 = time.perf_counter() while time.perf_counter() - t0 < work_ms / 1000: pass deltas.append((time.perf_counter_ns() - a) / 1e6) return {"work_ms": work_ms, "deltas_ms": [round(d, 1) for d in deltas], "spread_ms": round(max(deltas) - min(deltas), 1)}
def verdict() -> str: dbg_signals = [ report.get("PEB.BeingDebugged"), report.get("IsDebuggerPresent", False), str(report.get("ProcessDebugPort (0x7)", "0x0")) not in ("0x0",), report.get("NtGlobalFlag debug (0x70)", False), ] return ("DEBUGGED (any of: PEB flag, IsDebuggerPresent, " "nonzero debug port, debug heap flags)" if any(dbg_signals) else "clean (no flag-family or debug-port evidence)")
if __name__ == "__main__": if "--patch" in sys.argv: patch_being_debugged() check_flag_family() check_introspection() if "--timing" in sys.argv: report["timing"] = timing_baseline() for k, v in report.items(): print(f" {k:<34} {v}") print(f"\nVERDICT: {verdict()}")Code Walkthrough
Section titled “Code Walkthrough”teb_peb(lines 20–26) —NtCurrentTeb()returns the TEB (x64:gs:[0x30]equivalent, via the documented API here); the PEB pointer lives at TEB+0x60. This is the two-pointer walk every PEB trick starts with.read_peb_fields(lines 29–35) — the flag family:BeingDebuggedat PEB+0x02 (one byte),NtGlobalFlagat PEB+0xBC (x64 — different offset on x86: 0x68, the classic portability bug), debug-heap mask0x70. These three cover the “under-creation” case that the single BeingDebugged patch misses.ntqip(lines 38–47) — the introspection family via one wrapper: classes 0x07 (port), 0x1E (object handle), 0x1F (inverted flags). NoteGetCurrentProcess()(pseudo-handle-1) suffices — these are self-introspection checks, exactly the shape packers use.patch_being_debugged(lines 50–54) — the demo bypass: zero your own byte. Run the tool, attach x64dbg, re-run without--patch( BeingDebugged=1, debug object handle set), then re-run with it (flag cleared — but the object handle check still fires). That contrast is the module’s thesis: naive checks patch, kernel artifacts don’t.verdict(lines 83–95) — the honest aggregation: flag family + debug port evidence, with timing printed separately (it’s a heuristic, not a bit). The design mirrors real protectors: multiple independent checks, no single point of truth.
How It Reaches The Target
Section titled “How It Reaches The Target”In a protected binary: TLS callback (before entrypoint) runs the first wave — PEB fields, NtGlobalFlag, debug port — and branches: clean → continue; debugged → exit, corrupt state, or misbehave silently (the analyst sees different code paths and draws wrong conclusions — the real cost of anti-debug). During execution, watchdog threads poll DR registers and checksum .text; timing checks run periodically; disruptive calls (ThreadHideFromDebugger) blind specific debugger features. In a SOC: none of this is directly visible, but its shadows are — API patterns (NtQueryInformationProcess with debug classes from unsigned processes), TLS directories in unexpected binaries, self-checksumming loops (repeated .text reads). The defender’s stance: anti-debug in a downloader is intent evidence, not just evasion — treat the checks as the detection surface (E2’s rule: the sequence is the signal).
Exploitation Chain
Section titled “Exploitation Chain”- TLS wave — callbacks run pre-entrypoint: PEB fields + NtGlobalFlag + debug port; branch before your breakpoint lands
- Flag family — BeingDebugged (patchable), NtGlobalFlag 0x70 (fake-the-heap expensive), ForceFlags
- Introspection wave — NtQueryInformationProcess 0x7/0x1E/0x1F; the debug object is kernel-made and un-fakeable from ring 3
- Hardware wave — DR0–DR7 polling from watchdog threads; software-BP 0xCC self-scans
- Timing wave — workload deltas; debugger single-stepping shows up as spread
- Disruption — ThreadHideFromDebugger, invalid-handle exceptions, INT 2D: break the debugger, not just detect it
- Analyst counterchain — break on TLS not entrypoint; run double-workflow (debugged + not) with tooling that diffs behavior
Building Your Own Tool
Section titled “Building Your Own Tool”Goal: antidbg_audit.py — the analyst’s triage companion: a CLI that audits a child process the way real tooling does — spawn a target (default: notepad.exe) as a debuggee via CreateProcess(DEBUG_PROCESS) from a Python watchdog, confirm the debug object exists, and diff the target’s self-audit (via an injected command line flag: the target runs dbgpoke.py itself) against the watchdog’s ground truth. Simplified honest scope: rather than injection (out of scope here), the tool demonstrates the watchdog pattern — one process debugging another, events observed, DR registers read from the debuggee — which is the skill set S2 and BT6 both need.
Design decisions: (1) ctypes-only DEBUG_PROCESS spawning — the Windows debug loop is the learning target; (2) event-loop logging (CREATE_PROCESS/EXCEPTION/EXIT) as the analyst sees it; (3) DR-register readout via GetThreadContext on the debuggee — the hardware-BP check, implemented for real.
{1-3}"""antidbg_audit.py — watchdog pattern: debug a child, watch its events.Usage: python antidbg_audit.py [target.exe]Windows only. Pure ctypes. Educational: observe the debug loop."""import ctypesimport ctypes.wintypes as wtimport structimport sys
kernel32 = ctypes.WinDLL("kernel32")
DEBUG_PROCESS = 0x00000001DEBUG_ONLY_THIS_PROCESS = 0x00000002EXCEPTION_DEBUG_EVENT = 1CREATE_PROCESS_DEBUG_EVENT = 3EXIT_PROCESS_DEBUG_EVENT = 5LOAD_DLL_DEBUG_EVENT = 6
class STARTUPINFO(ctypes.Structure): _fields_ = [("cb", wt.DWORD), ("lpReserved", wt.LPWSTR), ("lpDesktop", wt.LPWSTR), ("lpTitle", wt.LPWSTR), ("dwX", wt.DWORD), ("dwY", wt.DWORD), ("dwXSize", wt.DWORD), ("dwYSize", wt.DWORD), ("dwXCountChars", wt.DWORD), ("dwYCountChars", wt.DWORD), ("dwFillAttribute", wt.DWORD), ("dwFlags", wt.DWORD), ("wShowWindow", wt.WORD), ("cbReserved2", wt.WORD), ("lpReserved2", ctypes.c_void_p), ("hStdInput", wt.HANDLE), ("hStdOutput", wt.HANDLE), ("hStdError", wt.HANDLE)]
class PROCESS_INFORMATION(ctypes.Structure): _fields_ = [("hProcess", wt.HANDLE), ("hThread", wt.HANDLE), ("dwProcessId", wt.DWORD), ("dwThreadId", wt.DWORD)]
class DEBUG_EVENT(ctypes.Structure): _fields_ = [("dwDebugEventCode", wt.DWORD), ("dwProcessId", wt.DWORD), ("dwThreadId", wt.DWORD), ("u", ctypes.c_byte * 160)] # union, size over-approx
class CONTEXT64(ctypes.Structure): _fields_ = [("P1Home", ctypes.c_uint64), ("P2Home", ctypes.c_uint64), ("P3Home", ctypes.c_uint64), ("P4Home", ctypes.c_uint64), ("P5Home", ctypes.c_uint64), ("P6Home", ctypes.c_uint64), ("ContextFlags", wt.DWORD), ("MxCsr", wt.DWORD), ("SegCs", wt.WORD), ("SegDs", wt.WORD), ("SegEs", wt.WORD), ("SegFs", wt.WORD), ("SegGs", wt.WORD), ("SegSs", wt.WORD), ("EFlags", wt.DWORD), ("Dr0", wt.DWORD), ("Dr1", wt.DWORD), ("Dr2", wt.DWORD), ("Dr3", wt.DWORD), ("Dr6", wt.DWORD), ("Dr7", wt.DWORD), ("Rax", ctypes.c_uint64), ("Rcx", ctypes.c_uint64), ("Rdx", ctypes.c_uint64), ("Rbx", ctypes.c_uint64), ("Rsp", ctypes.c_uint64), ("Rbp", ctypes.c_uint64), ("Rsi", ctypes.c_uint64), ("Rdi", ctypes.c_uint64)]
CONTEXT_DEBUG_REGISTERS_64 = 0x00100010 # AMD64 | DEBUG_REGISTERS [verify mask]
def run(target: str) -> int: si, pi = STARTUPINFO(), PROCESS_INFORMATION() si.cb = ctypes.sizeof(si) ok = kernel32.CreateProcessW(target, None, None, None, False, DEBUG_ONLY_THIS_PROCESS, None, None, ctypes.byref(si), ctypes.byref(pi)) if not ok: print(f"[-] CreateProcess failed: {ctypes.GetLastError()}") return 1 print(f"[+] debugging pid={pi.dwProcessId} ({target})")
ev = DEBUG_EVENT() shown = {"create": False} while True: if not kernel32.WaitForDebugEvent(ctypes.byref(ev), 5000): print("[!] debug loop timeout"); break code = ev.dwDebugEventCode if code == CREATE_PROCESS_DEBUG_EVENT and not shown["create"]: print(f"[+] CREATE_PROCESS pid={ev.dwProcessId}") shown["create"] = True elif code == EXCEPTION_DEBUG_EVENT: exc_code = struct.unpack_from("<I", ev.u, 0)[0] print(f"[~] EXCEPTION code={exc_code:#010x} " f"tid={ev.dwThreadId}") elif code == EXIT_PROCESS_DEBUG_EVENT: print(f"[+] EXIT_PROCESS pid={ev.dwProcessId}") break
# DR register readout (hardware-BP check) on first stop if code in (CREATE_PROCESS_DEBUG_EVENT, EXCEPTION_DEBUG_EVENT) and shown["create"]: ctx = CONTEXT64() ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS_64 h = kernel32.OpenThread(0x1FFFFF, False, ev.dwThreadId) if h: if kernel32.GetThreadContext( h, ctypes.byref(ctx)): drs = [ctx.Dr0, ctx.Dr1, ctx.Dr2, ctx.Dr3] print(f" DR0-3: " + " ".join(f"{d:#x}" for d in drs) + f" DR7={ctx.Dr7:#x}") kernel32.CloseHandle(h)
kernel32.ContinueDebugEvent(ev.dwProcessId, ev.dwThreadId, 0x00010002) # DBG_CONTINUE kernel32.TerminateProcess(pi.hProcess, 0) return 0
if __name__ == "__main__": target = sys.argv[1] if len(sys.argv) > 1 else "notepad.exe" sys.exit(run(target))Walkthrough
Section titled “Walkthrough”- Structures (lines 15–60) — the debug API’s data contracts:
STARTUPINFO/PROCESS_INFORMATIONfor spawning,DEBUG_EVENT(with the union over-approximated as bytes — real tools unpack per-event sub-structures), and the x64CONTEXTwith the DR register fields that make the hardware-BP check possible. run(lines 68–110) — the debug loop, the oldest pattern in Windows debugging:WaitForDebugEvent→ dispatch on event code →ContinueDebugEvent. The tool logs create/exception/exit events and reads DR0–DR7 on the first stop — the analyst’s ground truth about hardware breakpoints, from outside the process.CONTEXT_DEBUG_REGISTERS_64(line 64) — the context-flag mask (CONTEXT_AMD64 | CONTEXT_DEBUG_REGISTERS) [verify mask constant per SDK]; getting this wrong yields empty registers — the classic first-session bug, kept visible here as a learning hook.
Compilation and test procedure
Section titled “Compilation and test procedure”PS> python antidbg_audit.py notepad.exe[+] debugging pid=8124 (notepad.exe)[+] CREATE_PROCESS pid=8124 DR0-3: 0x0 0x0 0x0 0x0 DR7=0x0 # clean hardware state[+] EXIT_PROCESS pid=8124PS> # the double-workflow: in another shell, run dbgpoke.py alone,# then again while this watchdog holds notepad — compare verdicts.The lab: run dbgpoke.py standalone (clean verdict), then under x64dbg (flag family + debug object fire), then with --patch (flag family clears, object check still fires — the kernel artifact). Three verdicts, one process, full map.
Detection considerations
Section titled “Detection considerations”These tools introspect their own processes or legitimately debug children — benign by construction. The SOC-facing signal is the payload pattern: NtQueryInformationProcess with debug classes, PEB self-writes, ThreadHideFromDebugger, and TLS-callback directories in unsigned downloaders — Sysmon EID 1 (ancestry + command context), vendor behavioral layers for the API patterns [verify per-product coverage], and static flagging of TLS directories in unexpected binaries. The pairing rule from E2 applies: checks plus what follows (silence, decoy behavior, or stage-two load) — the check is the intent, the behavior is the story.
Improvements to add
Section titled “Improvements to add”- Full
DEBUG_EVENTunion unpacking (CREATE_PROCESS_INFO, EXCEPTION_DEBUG_INFO with real exception codes) — the analyst’s detail view. - Software-BP self-scan: hash own
.text(via/proc/self/mapsanalog or module base) and diff — the 0xCC detector, implemented. - ThreadHideFromDebugger demonstration: a thread that hides itself, watched from the debug loop (its events stop) — disruption as observed behavior.
- Cross-platform study mode for the watchdog (ptrace on Linux) — the same loop, different syscalls.
- Export the audit as JSON for the double-workflow diff harness — automate “run twice, diff verdicts.”
Detection — Defender View
Section titled “Detection — Defender View”| Source | ID | What fires | Notes |
|---|---|---|---|
| Sysmon | 1 | Unsigned process calling debug-class APIs (via vendor behavioral) | The intent signal |
| Sysmon | 8/10 | Cross-process debug/exception manipulation | Watchdog abuse patterns |
| ETW | Threat-Intelligence-class | Anti-debug API sequences [verify per vendor] | E2’s TI layer |
| Sysmon | 25 | PEB/process tampering self-modification | The patch family |
| Defender AV behavioral | — | Packer/protector heuristics (TLS dir + checks) | Static+behavioral blend |
| Wazuh | Sysmon-derived [verify IDs] | API-pattern correlation | BT3 integration |
Evasion Techniques
Section titled “Evasion Techniques”- Assume the double-workflow: professional analysts run samples debugged and not; behavior that changes under a debugger is itself the tell — design checks to not change observable behavior when detected (decoy paths that look like production paths).
- Patch the derivative artifacts, not just the flag: zero BeingDebugged and normalize NtGlobalFlag/heap flags — the derivative checks survive the naive patch (this module’s lab proves it).
- The debug object is the wall:
ProcessDebugObjectHandlecannot be faked from ring 3 — either operate in a non-debugged reality (your checks pass because nothing watches) or accept kernel-grade tooling exists. - Watchdog hygiene: DR-polling threads are themselves visible to careful analysts — name them boring, schedule them irregularly, and remember they die with your process (corpses tell stories).
- Disruption is loud:
ThreadHideFromDebuggerand invalid-handle crashes are known tricks that mark binaries; prefer detection-plus-decoy over debugger-breaking theater. - Time honestly: timing checks flag analyst stepping but also flag slow VMs and loaded CI hosts — calibrate thresholds on real hardware or eat false refusals (E14’s lesson, adjacent).
⚠️ What caught me: I patched
BeingDebuggedin my loader and assumed my anti-debug hygiene was done — until an analyst walked me through my own crash dump months later: my NtGlobalFlag-derived heap layout (I’d run under a debugger at build time and shipped the debug-heap-polluted baseline) told the whole story. The derivative artifacts I’d never heard of survived the one byte I patched. The rule now: for every check I write, I run my own tooling twice — debugged and not — and diff. Any behavioral delta is a bug in my stealth, not a feature.
Lab Practice
Section titled “Lab Practice”- Run
dbgpoke.pythree ways: standalone, under x64dbg, standalone with--patchafter attaching — build the three-verdict table and confirm the debug-object check survives the PEB patch. - Run
antidbg_audit.pyonnotepad.exe; then set a hardware breakpoint on the child (x64dbg-class tool), re-run the audit, and watch DR0/DR7 readout change — the hardware-BP check, observed from outside. - TLS lab: build a tiny PE with a TLS callback (mingw supports it) that calls
IsDebuggerPresentand exits; run it under a debugger breaking at entrypoint — the callback fires first; note the behavior difference. - Double-workflow harness: script
dbgpoke.py --jsonruns (with/without debugger), diff the outputs programmatically — automate the analyst’s habit so it becomes your build gate too. - Read one real-world packer’s anti-debug layer (any documented sample writeup from the S2/M14 reading lists) and map every check it uses onto this module’s five-family table.
Interview Questions
Section titled “Interview Questions”Technical: Walk the exact memory walk from NtCurrentTeb() to NtGlobalFlag on x64 — every offset — and name the x86 offset difference.
Technical: Why does ProcessDebugObjectHandle survive a BeingDebugged patch, and what does that imply about which checks matter?
Deep-dive: Design the TLS-callback first wave for a protector: which four checks run, in what order, and what does each branch to on detection?
Conceptual: “Anti-debug is telemetry” — unpack: why do the checks themselves give defenders signal, and what does that imply for protector design?
Scenario: Your patched binary still gets caught by an analyst running it twice and diffing. What behavioral deltas leak, and how do you design decoy paths?
Practical: Write the NtQueryInformationProcess wrapper for class 0x1F and explain the inverted semantics — then name the tool bug most authors ship because of it.
Related Modules
Section titled “Related Modules”E14 (Virtual Environment Detection) · S2 (Reverse Engineering) · M14 (Executable Packing Analysis) · PE3 (Windows Privilege Escalation)