ntdll unhooking
Restores the loaded ntdll.dll code section from a clean on-disk copy,
removing an EDR’s userland hooks from the stub’s own process.
Metadata
Section titled “Metadata”| ATT&CK | T1562.001 (Impair Defenses: Disable or Modify Tools) |
| Stability | Stable |
| Category | Preparation |
| YAML key | unhook_ntdll |
| Introduced in | unreleased (pre-wired skeleton batch) |
What it does
Section titled “What it does”Restores the code section of the loaded ntdll.dll from a clean copy read from
disk, removing the userland hooks an EDR placed in the stub’s own process. The
payload — and the loader’s own nt_* helpers under syscalls.mode: none —
then call the real export prologues again. It does not run the payload; it only
modifies process state, which is why it is a Preparation technique.
The restore covers the whole code section, not just the Nt* syscall stubs, so
hooks on any other ntdll export (Ldr*, Rtl*, the Etw* family) go with it.
YAML parameters
Section titled “YAML parameters”| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| — | — | — | — | No parameters: the target is always the loaded ntdll.dll, and the clean copy comes from the same path the syscall layer reads (C:\Windows\System32\ntdll.dll) |
The technique is deliberately parameterless. Letting a plan point the restore at
a different file would let the restored .text disagree with the SSN table
init_syscalls() resolved from the disk copy (see the interaction notes below),
and the loaded module is always the right target.
YAML example
Section titled “YAML example”runtime: - technique: unhook_ntdll - technique: patch_etw - technique: reflective_loading params: payload: implantunhook_ntdll runs first: patch_etw writes into ntdll’s .text, and the
restore would silently undo an earlier patch (see “Implementation notes”).
How it works
Section titled “How it works”flowchart TD
A[GetModuleHandleA - loaded ntdll base] --> B[Read clean copy from disk]
B --> C[Walk PE headers to the .text section]
C --> D{Bounds guards pass?}
D -- no --> E[Obfuscated error, abort]
D -- yes --> F[Protect region RWX]
F --> G[Copy clean bytes]
G --> H[Apply base relocations]
H --> I[Restore original protection]
I --> J[Return Ok true, continue]
1. Fetch the clean copy
Section titled “1. Fetch the clean copy”GetModuleHandleAfetches the loadedntdll.dllbase. The module is already loaded (the stub links against it), so this loads nothing.std::fs::readreads a clean copy of the same file from disk. The path is obfuscated withStringsConfig, so it never appears in.rdata.
2. Locate the .text section
Section titled “2. Locate the .text section”- The fragment walks the file’s PE headers to the
.textsection: it readsNumberOfSectionsandSizeOfOptionalHeaderfrom the file header, finds the section named.text(compared as an 8-byte constant, not a string literal) and keepsVirtualAddress,Misc.VirtualSize,SizeOfRawDataandPointerToRawData. - Guards, each with its own obfuscated diagnostic: the file must start with
MZand carry aPE\0\0signature, the section must be non-empty, and its raw range must lie inside the file (PointerToRawData + SizeOfRawData <= file.len(), overflow-checked). Nothing is ever truncated or skipped silently. - The live target is
module_base + VirtualAddress; the copy length ismin(Misc.VirtualSize, SizeOfRawData), so the write stays inside the mapped section and the read stays inside the file.
3. Rewrite the section
Section titled “3. Rewrite the section”nt_protect_vmmakes exactly those bytesPAGE_EXECUTE_READWRITE, the clean bytes are copied over, andnt_protect_vmrestores the original protection (PAGE_EXECUTE_READ). The write window has to stay executable: the protection call itself returns through ntdll’s ownNtProtectVirtualMemorystub, which is inside the region being rewritten, so aPAGE_READWRITEwindow would fault on the syscall’s return path (measured; see “Implementation notes”).- Before the protection is restored, the fragment applies the base relocations
the loader would have applied to that section. A PE32 image stores absolute
addresses in its code, so the file’s bytes still hold the preferred-base
values; each fixup inside the restored range is shifted by
live_base - preferred_base. PE32+ ntdll is RIP-relative and has no fixups in.text, so the walk is a no-op there (see “Implementation notes”).
4. Hand back control
Section titled “4. Hand back control”- The section is back to its original protection before the next step runs,
and the step returns
Ok(true), so the pipeline continues.
Only the section is touched. The PE headers keep the offsets the system loader already resolved, and the rest of the image (data, relocations, the export directory) is left alone, so every export still points where it did.
What it evades
Section titled “What it evades”- Userland API hooks an EDR installed in the stub’s own process. This is the
same problem
syscalls.mode: indirectworks around for the loader’s own kernel calls;unhook_ntdllrepairs the exports the payload (and the loader’s Win32 entry points such asLoadLibraryA/GetProcAddress) call. - Hooks beyond the syscall stubs: the whole
.textis restored, so an EDR that hooksLdrLoadDll, anRtl*helper or anEtw*export loses those hooks too.
What detects it
Section titled “What detects it”- Kernel telemetry on the protection change. A page that was mapped from an
image section becoming writable is a classic image-tampering signal. The
kernel sees the
NtProtectVirtualMemorycall (ETW-TI,Microsoft-Windows-Threat-Intelligence, reports it) and the write that follows; going through the syscall layer hides the userland stub, not the kernel event. The window isPAGE_EXECUTE_READWRITE(the section contains the code returning from the protection call, so it cannot lose execute permission), which is exactly the RWX-on-an-image-page pattern heuristics look for. - Memory scanning. An EDR that periodically re-reads ntdll’s
.textfrom disk, or compares it to a known-good copy, sees the hooks disappear — and some vendors simply re-install them after an unhook. - Hook-integrity self-checks. A product that watches its own trampolines can notice they were overwritten and respond.
- The file read itself. Opening
ntdll.dllfor reading right before execution is suspicious on its own. - Kernel callbacks (
PsSetCreateProcessNotifyRoutineEx, etc.) are unaffected: this is a userland restore. It buys the payload a clean userland view, not kernel invisibility.
Measurements
Section titled “Measurements”- Baseline and the two measured constraints (RWX window, x86 fixups) are in
docs/measurements.md. Verified at run time withcode_examples/unhook_ntdll/smoke.yaml:unhook_ntdllfollowed byfibersrunning themov eax, 42; retdemo shellcode exits42on x64 withsyscalls: noneandsyscalls: indirect, and on x86 (smoke-x86.yaml) — the restore ran first and did not break the following step. The debug trace confirms the size restored (restored 1482908 bytes at 0x..., i.e. the file’s.textVirtualSize). - The two scratch probes behind the design are in
code_examples/unhook_ntdll/harness/:probe_protect(thePAGE_READWRITEfault vs. the workingPAGE_EXECUTE_READWRITE) andprobe_relocs(the x86 fixups: 19 004 differing bytes before, 0 after).
Implementation notes
Section titled “Implementation notes”Order against the other preparations
Section titled “Order against the other preparations”patch_etwafter, not before.patch_etwoverwritesEtwEventWrite/EtwEventWriteFullinside ntdll’s.text. Because the restore copies the whole clean section over the live one, apatch_etwthat ran earlier is silently undone. Putunhook_ntdllfirst, thenpatch_etw.patch_amsiis unaffected. It patchesAmsiScanBufferinamsi.dll, a different module, so the ntdll restore neither removes it nor depends on it. The general rule stands: any preparation that writes into ntdll’s.textmust run after this step, or its patch will be overwritten.bouncerand the other preparations do not read ntdll’s hooked stubs. They use their own imports, so unhooking buys the payload a clean userland view — it does not change what the gate checks, and it is not kernel invisibility.syscalls.mode: indirectis safe in either order, with one caveat. The stub resolves the SSN table and thesyscall; retgadget inside live ntdll’s.textininit_syscalls(), before any step runs, so the restore cannot invalidate something resolved later. The caveat: if the clean file on disk is a different build than the loaded image (e.g. Windows Update replacedntdll.dllon disk between process start and step execution), the restored.textis not the code whose gadget address was found, and the gadget/SSNs could disagree with the bytes now at that address. The technique reads the same path the syscall layer reads precisely so both see the same build.- Only the loader’s own process benefits. A payload run in another process
(
process_hollowing,early_bird,pe_injection) executes against that process’s ntdll, which is untouched.
x86 needs the section’s base relocations
Section titled “x86 needs the section’s base relocations”A PE32 image stores absolute addresses in its code, and the loader rewrites each
of them when it maps the image at a base other than the preferred one. Measured
on this host: the live 32-bit ntdll (WOW64) differed from the file in 19 004
bytes of its 1.25 MB .text, all of them HIGHLOW fields (IMAGE_REL_BASED_HIGHLOW,
9 502 entries inside the section plus 130 padding entries). Copying the raw file
bytes therefore installs a section whose absolute addresses point at
0x4b280000 (the file’s preferred base) instead of the live base — every call
through such an address crashes. The fragment walks the file’s base-relocation
directory (data directory 5), keeps the entries that fall inside the restored
range and shifts each field by live_base - preferred_base (wrapping_add, so
the arithmetic is defined for both header formats). The same probe shows the
result is byte-identical to the live section: probe_relocs reports 19 004
differing bytes before the fixups and 0 after.
PE32+ ntdll is RIP-relative and has no fixups inside .text (measured: 0
differing bytes on x64), so the walk is a no-op there. A relocation kind the
fragment does not know (anything but ABSOLUTE, HIGHLOW and DIR64) inside the
restored range is a loud error, never a silent skip.
Why the PE walk is a self-contained copy
Section titled “Why the PE walk is a self-contained copy”The syscall layer’s disk-reading resolvers (Tartarus Gate, API hashing) already
contain an RVA→offset walk, but it lives in $FILE_PE_HELPERS$ inside the
resolver body: it is rendered only for those resolvers and is private to the
syscall block, so it does not exist under syscalls.mode: none or
hells_gate. Promoting it to a shared block would touch src/engine/
(outside this technique’s scope). The fragment therefore carries its own
bounds-checked read_u16/read_u32/read_u64 plus the section walk — small,
documented, and available in every configuration.
Other notes
Section titled “Other notes”- WOW64. For an x86 stub,
C:\Windows\System32\ntdll.dllis redirected by the WOW64 filesystem redirector toSysWOW64\ntdll.dll, which is exactly the 32-bit image the process mapped, so the same obfuscated path works on both architectures — and the relocation fixups above make the file’s bytes valid at the base the loader actually chose. - The write window is RWX, and it is brief. The section being rewritten
contains the
NtProtectVirtualMemorystub (modenone) or thesyscall; retgadget (modeindirect) that the protection call returns through, so the window cannot bePAGE_READWRITE: dropping execute permission faults on the syscall’s return path. The standalone probecode_examples/unhook_ntdll/harness/probe_protect.rsmeasured exactly that —rwdies insideVirtualProtectEx,rwxcompletes the copy and the restore. The originalPAGE_EXECUTE_READprotection is restored before the step returns, so the section is never left writable; a failed restore is a loud error, not a silent skip. - The
.textname is a constant, not a string. It is compared asu64::from_le_bytes([b'.', b't', b'e', b'x', b't', 0, 0, 0]), so it lands in the code as an immediate, never in.rdata. - Every runtime string is obfuscated. The module name, the path and all
eight diagnostics go through
StringsConfig::obfuscateinrender_fragment(MESSAGES), so no diagnostic text and no technique keyword reaches.rdata. - The protection change is itself a target. With
mode: nonethe change goes throughVirtualProtectEx(kernel32 → ntdll), so a hook on it can block or revert the restore — MDSec notes exactly this.mode: indirectissues the change as a directsyscallfrom the loader and skips the userland stub; the kernel still sees the event. GetModuleHandleAandstd::fs::readstay Win32/host entry points, likeLoadLibraryAelsewhere; only the protection change goes through the syscall layer (nt_protect_vm), somode: indirectremoves theVirtualProtectimport.- No relocations are applied. ntdll’s
.texthas none, and the headers are not restored, so this is correct for this target.
References
Section titled “References”- ired.team — Full DLL Unhooking with C++: https://www.ired.team/offensive-security/defense-evasion/how-to-unhook-a-dll-using-c++
- MDSec — Bypassing User-Mode Hooks and Direct Invocation of System Calls for Red Teams: https://www.mdsec.co.uk/2020/12/bypassing-user-mode-hooks-and-direct-invocation-of-system-calls-for-red-teams/
- SysWhispers (direct system calls, and the related-reading list): https://github.com/jthuraisamy/SysWhispers
- MITRE ATT&CK T1562.001 — Impair Defenses: Disable or Modify Tools.