MmAllocateIndependentPagesEx. MiGetPteAddress. SeValidateImageHeader. None of these are in the export table. If you need them, you either load a PDB and resolve the name, or you scan for a byte pattern that uniquely identifies the function’s prologue in the binary you have on disk.

PDB loading requires symsrv.dll, a live internet connection to the Microsoft symbol server, and appropriate caching. That’s fine for local tooling. It’s dead weight in a deployed payload. The pattern scanner approach works offline, survives minor compiler reordering between builds as long as you wildcard the operands, and produces a kernel VA from a user-mode process with no special privileges.

The DSE bypass post uses this technique twice: once to find CiValidateImageHeader in CI.dll, and once to extract PTE_BASE from the body of MiGetPteAddress. This post covers the scanner itself.

How SEC_IMAGE mapping works

The goal is to scan the same byte layout the kernel loaded, not the raw file layout. PE files on disk pack sections by PointerToRawData. Loaded PE images place sections at VirtualAddress. The offsets are different. If you scan the raw file, the offset you find doesn’t translate cleanly to a kernel VA.

CreateFileMapping with SEC_IMAGE asks the loader to apply section mapping as if the file were being loaded. Each section lands at its VirtualAddress offset from the mapping base. Any offset measured in this view can be subtracted from the mapping base and added directly to the module’s kernel base address to get the kernel VA.

c
static PBYTE map_pe(const wchar_t *path) {
    HANDLE fh = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ,
                            NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    if (fh == INVALID_HANDLE_VALUE) return NULL;

    HANDLE fm = CreateFileMappingW(fh, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
    CloseHandle(fh);
    if (!fm) return NULL;

    PBYTE view = (PBYTE)MapViewOfFile(fm, FILE_MAP_READ, 0, 0, 0);
    CloseHandle(fm);
    return view;
}

SEC_IMAGE causes the mapping to fail if the file is not a valid PE. The returned pointer is the mapping base. Section data starts at base + section->VirtualAddress, matching the in-memory layout exactly.

The scanner

The pattern format used throughout this post: a PBYTE array of the bytes to match, paired with a const char * mask where 'x' means match and '?' means wildcard. The mask length controls how many bytes are compared.

c
static BOOL data_compare(const BYTE *data, const BYTE *pattern, const char *mask) {
    for (; *mask; ++mask, ++data, ++pattern) {
        if (*mask == 'x' && *data != *pattern)
            return FALSE;
    }
    return TRUE;
}

/* Scan the .text section of a SEC_IMAGE mapping.
 * Returns the VA in the mapping where the pattern was found, or 0. */
static ULONG_PTR pattern_scan(PBYTE base, const BYTE *pattern, const char *mask) {
    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base;
    if (dos->e_magic != IMAGE_DOS_SIGNATURE) return 0;

    PIMAGE_NT_HEADERS nt =
        (PIMAGE_NT_HEADERS)(base + dos->e_lfanew);
    PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);

    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
        /* Only scan executable sections */
        if (!(sec->Characteristics & IMAGE_SCN_MEM_EXECUTE)) continue;

        PBYTE start = base + sec->VirtualAddress;
        /* Use VirtualSize when available; SEC_IMAGE maps max(SizeOfRawData,
         * VirtualSize) bytes. SizeOfRawData alone would miss the tail of any
         * section where VirtualSize > SizeOfRawData. */
        DWORD  size  = sec->Misc.VirtualSize ? sec->Misc.VirtualSize : sec->SizeOfRawData;

        for (DWORD j = 0; j < size; j++) {
            if (data_compare(start + j, pattern, mask))
                return (ULONG_PTR)(start + j);
        }
    }
    return 0;
}

A common variation of this scanner (found in several public DSE-related tools) scans every section regardless of characteristics. For kernel binaries you almost always want .text only. Scanning .data for a code pattern wastes cycles and can produce false positives if the same byte sequence appears in read-only data.

Translating a match offset to a kernel VA

Once pattern_scan returns a match, you have a VA inside the user-mode mapping. The translation is:

text
kernel_va = (match_va - mapping_base) + kernel_module_base

Getting the kernel module base without kernel access: NtQuerySystemInformation with class 11 (SystemModuleInformation) returns the full loaded module list. No SeDebugPrivilege required. The ImageBase field in each RTL_PROCESS_MODULE_INFORMATION entry is the kernel virtual address.

c
typedef NTSTATUS (NTAPI *PFN_NtQSI)(ULONG, PVOID, ULONG, PULONG);

static ULONG_PTR get_kernel_module_base(const char *module_name) {
    PFN_NtQSI NtQSI = (PFN_NtQSI)GetProcAddress(
        GetModuleHandleW(L"ntdll.dll"), "NtQuerySystemInformation");
    if (!NtQSI) return 0;

    ULONG    size = 0;
    PVOID    buf  = NULL;
    NTSTATUS st;

    /* First call with NULL obtains the required buffer size */
    NtQSI(11 /* SystemModuleInformation */, NULL, 0, &size);
    if (!size) size = 0x10000;

    for (;;) {
        if (buf) VirtualFree(buf, 0, MEM_RELEASE);
        buf = VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
        if (!buf) return 0;
        st = NtQSI(11, buf, size, &size);
        if (st != (NTSTATUS)0xC0000004L) break;
        size += 0x1000;
    }

    if (st != 0) { VirtualFree(buf, 0, MEM_RELEASE); return 0; }

    PRTL_PROCESS_MODULES mods = (PRTL_PROCESS_MODULES)buf;
    ULONG_PTR result = 0;

    for (ULONG i = 0; i < mods->NumberOfModules; i++) {
        const char *fname = (const char *)mods->Modules[i].FullPathName
                          + mods->Modules[i].OffsetToFileName;
        if (!_stricmp(fname, module_name)) {
            result = (ULONG_PTR)mods->Modules[i].ImageBase;
            break;
        }
    }

    VirtualFree(buf, 0, MEM_RELEASE);
    return result;
}

Decoding RIP-relative operands

Finding the prologue of a function is half the job. The other half is extracting values encoded in the instructions at that location.

The canonical example: MiGetPteAddress in ntoskrnl.exe. The function shifts rcx right by 9, then loads the PTE base constant into rax:

nasm
48 C1 E9 09        ; shr rcx, 9
48 B8 xx xx xx xx  ; mov rax, <PTE_BASE>
     xx xx xx xx

The xx bytes are the 8-byte immediate. You can scan for the fixed prefix 48 C1 E9 09 48 B8 with mask xxxxxx and read the 8 bytes after byte 5 of the match. That’s PTE_BASE for this boot, no PDB.

For MOV RAX, [RIP+disp32] (opcode 48 8B 05 dd dd dd dd), the 4-byte signed displacement is at offset 3. The instruction is 7 bytes. RIP at execution time points at the next instruction, so the target address is:

text
target_va = instruction_va + 7 + *(int32_t *)(instruction_va + 3)

This is what the ResolveMovCs and ResolveMovReg helpers in common public reference implementations compute: they dereference the displacement, add the instruction length, and subtract the module base to give you a module-relative offset.

c
/* Decode a RIP-relative MOV [48 8B 05 dd dd dd dd] at match_va.
 * Returns the kernel VA of the referenced global, or 0 on error. */
static ULONG_PTR decode_rip_relative(ULONG_PTR match_va, ULONG_PTR mapping_base,
                                     ULONG_PTR kernel_base) {
    /* displacement is a signed 32-bit value at byte offset 3 */
    INT32 disp = *(INT32 *)(match_va + 3);
    /* next instruction is at match_va + 7 (3-byte prefix + 4-byte disp) */
    ULONG_PTR next_insn_mapping = match_va + 7;
    /* referenced address in mapping-space */
    ULONG_PTR ref_mapping = next_insn_mapping + (INT64)disp;
    /* translate to kernel VA */
    return (ref_mapping - mapping_base) + kernel_base;
}

A common bug in displacement decoders

Several public implementations of this pattern contain a compile error in their displacement-reading helpers. The buggy form looks like this:

c
/* buggy */
return *(int)(Instruction + 3);

Instruction is UINT64. Instruction + 3 is also UINT64. (int)(Instruction + 3) casts it to int, which truncates the address. Dereferencing the result of an integer cast is not valid C. MSVC rejects this with:

text
error C2100: illegal indirection

The fix is to cast to a pointer before dereferencing:

c
/* fixed */
return *(int *)(Instruction + 3);

A related variant (ResolveTraceMovRegByte in some repos) is correct because it casts to (BYTE *) at the equivalent line. The deeper problem with “trace” variants of this decoder is that they follow a relative jump from the match site before reading the operand. That means the scan pattern must land in a specific caller rather than in the function itself. One build’s call-site layout is another build’s crash. These are worth avoiding.

Complete scanner

The individual components above (map_pe, data_compare, pattern_scan, get_kernel_module_base, decode_rip_relative) wire together into a single tool. It maps ntoskrnl.exe from disk as a SEC_IMAGE section, scans for the MiGetPteAddress prologue to extract PTE_BASE, then scans CI.dll for CiValidateImageHeader.

Full compilable source: scanner.c

Diagram: the full pipeline

ntoskrnl.exe on diskNtQuerySystemInfoclass 11SEC_IMAGE mappingkernel module baseRTL_PROCESS_MODULE_INFOpattern_scan()xx?? mask, .text onlymatch_va (mapping)kernel_va =(match - base) + kbasekernel virtual address

Practical targets

MiGetPteAddress has used a stable 6-byte prefix from Win10 1903 through Win11 24H2 at time of writing, because the instruction encoding for shr rcx, 9 followed by mov rax, imm64 is structurally determined by the algorithm. That said, no pattern is guaranteed to survive a future build, and this one is no exception. The PTE_BASE immediate changes every boot (KASLR relocates the PTE self-map), so wildcarding it is correct. You’re looking for the structural pattern, not the operand.

SeValidateImageHeader in ntoskrnl has a longer stable prologue but you need it less often since CI.dll is the better patch target. See the DSE bypass post for both use cases.

MmAllocateIndependentPagesEx is fully unexported. The independent pages post covers its prologue pattern.

PsLoadedModuleList is exported by name but worth mentioning: you can recover it from the _DRIVER_OBJECT.DriverSection chain without symbol lookup at all. Pattern scanning is for things that have no exported handle you can walk to.

What breaks this

Signature-based scanning assumes the bytes you matched on are stable. They’re usually not stable across major Windows version boundaries. A pattern that works on Win11 22H2 through 24H2 may fail on a future build. The usual mitigation is to use multiple candidate patterns and accept the first match, or to write your patterns from the most structurally-determined part of the function (the frame setup instructions at the prologue are more stable than instructions in the function body).

HVCI does not affect this technique at all. You’re reading a file from disk and querying publicly available system information. The pattern scanner is entirely user-mode.

If CI.dll is not loaded (non-standard configurations, PE), get_kernel_module_base("CI.dll") returns zero and the tool skips that scan. Handle the zero return.

The SEC_IMAGE mapping can fail if the Windows loader rejects the PE, for example on a corrupt on-disk image or if the file is locked exclusively. CreateFileMappingW returns NULL in that case. Check it.