No More Hardcoded Kernel Offsets: Turning Microsoft PDB Symbols into a Runtime BYOVD Superpower

No More Hardcoded Kernel Offsets: Turning Microsoft PDB Symbols into a Runtime BYOVD Superpower

Original text by S12 – 0x12Dark Development

The article explains a method for resolving Windows kernel offsets dynamically by using Microsoft PDB symbols instead of hardcoding structure offsets for each Windows build. The workflow starts by locating the loaded kernel image with NtQuerySystemInformation(SystemModuleInformation), then parsing ntoskrnl.exe on disk to extract its CodeView RSDS debug record, including the PDB GUID and Age. Those values are used to download the exact matching ntoskrnl.pdb from Microsoft’s symbol server. The code then uses DbgHelp.dll APIs such as SymInitializeSymLoadModuleExSymEnumTypesByName, and SymGetTypeInfo to resolve fields like _EPROCESS.TokenActiveProcessLinksUniqueProcessId, and Protection. The technique helps BYOVD and kernel research tooling adapt across Windows versions without manual WinDBG work, but it depends on internet access unless symbols are cached. The post also provides detection ideas, including a YARA rule for this behavior. 

Today, I’ll show you an interesting approach to resolving kernel offsets dynamically. In previous BYOVD techniques, we relied on WinDBG to manually extract the correct offsets for each Windows build. In this case we will move away from static analysis tools and instead leverage PDB (Program Database) symbols to resolve kernel structures and offsets at runtime.

This approach allows us to automatically adapt to different Windows builds without requiring manual inspection or repeated debugging sessions. By querying symbol information directly from Microsoft’s symbol server, we can extract the exact offsets we need for kernel structures in a more scalable and reliable way

This not only improves operational efficiency but also reduces the dependency on tooling like WinDBG during development or deployment, making the overall workflow more flexible when dealing with multiple Windows versions

The unique disadvantage of this technique, relies on the necessity to have internet access, in some environments can be restricted or heavily monitored, to fix this, we will use another technique on a future post. For the moment we use this PDB approach.

To achieve dynamic kernel offset resolution using PDB symbols, we need to follow these logical steps:

  1. Find the loaded kernel module: First, we need to know where ntoskrnl.exe is loaded in memory and where its file lives on disk. We use NtQuerySystemInformation with SystemModuleInformation to get the full path of the loaded kernel image (e.g. \SystemRoot\System32\ntoskrnl.exe)
  2. Read the PE to get the PDB identifier: Once we have the kernel binary on disk, we parse its PE headers to extract the Debug Directory, which contains the PDB GUID and Age. These two values uniquely identify which exact PDB file corresponds to this specific build. No two builds share the same GUID+Age pair
  3. Download the matching PDB from Microsoft: With the GUID and Age, we construct a URL to Microsoft’s public symbol server (https://msdl.microsoft.com/download/symbols/ntoskrnl.pdb/<GUID><Age>/ntoskrnl.pdb) and download the correct PDB file. This is the step that requires internet access
  4. Parse the PDB to extract offsets: We parse the downloaded PDB file, using DbgHelp.dll (SymInitialize + SymGetTypeInfo / SymFromName) to look up the exact byte offset of the structure field we need
  5. Use the offset at runtime: Finally, with the resolved offset in hand, we can calculate the absolute kernel address of the target field by adding the offset to the base address of the loaded kernel. No hardcoded values, no manual WinDBG sessions…
NtQuerySystemInformation
  > kernel path + base address
  |
Parse PE Debug Directory
  > PDB GUID + Age
  |
HTTP request > msdl.microsoft.com
  > download ntoskrnl.pdb
  |
DbgHelp / PDB parser
  > field offset (e.g. EPROCESS.Token = 0x4b8)
  |
base address + offset
  > absolute kernel address 

Implementation

Now, let’s look at how to translate that logic into C++ code. I have broken down the most important parts

Finding the kernel base and path

The first thing we need is to locate ntoskrnl.exe on disk and get its load address. We use NtQuerySystemInformation with SystemModuleInformation(class 0xB) which returns a list of all loaded kernel modules. The first entry is always ntoskrnl.exe

#include <Windows.h>
#include <winternl.h>

#define SystemModuleInformation 0xB

typedef struct _RTL_PROCESS_MODULE_INFORMATION {
    HANDLE Section;
    PVOID  MappedBase;
    PVOID  ImageBase;       // kernel base address
    ULONG  ImageSize;
    ULONG  Flags;
    USHORT LoadOrderIndex;
    USHORT InitOrderIndex;
    USHORT LoadCount;
    USHORT OffsetToFileName;
    CHAR   FullPathName[256];
} RTL_PROCESS_MODULE_INFORMATION;

typedef struct _RTL_PROCESS_MODULES {
    ULONG NumberOfModules;
    RTL_PROCESS_MODULE_INFORMATION Modules[1];
} RTL_PROCESS_MODULES;

PVOID GetKernelBase(char* outPath) {
    ULONG size = 0;
    NtQuerySystemInformation(
        (SYSTEM_INFORMATION_CLASS)SystemModuleInformation,
        nullptr, 0, &size
    );

    auto* modules = (RTL_PROCESS_MODULES*)malloc(size);
    NtQuerySystemInformation(
        (SYSTEM_INFORMATION_CLASS)SystemModuleInformation,
        modules, size, &size
    );

    // First module is always ntoskrnl
    PVOID base = modules->Modules[0].ImageBase;
    strcpy(outPath, (char*)modules->Modules[0].FullPathName);

    free(modules);
    return base;
}

ImageBase gives us the runtime address. FullPathName gives us the path on disk like \SystemRoot\System32\ntoskrnl.exe

Extracting the PDB GUID and Age from the PE

Every PE built with debug info contains a Debug Directory with a RSDSsignature. Inside it lives the PDB GUID and Age, the unique fingerprint of this exact build’s symbols

#include <dbghelp.h>

struct PdbInfo {
    DWORD  Signature;   // 'RSDS'
    GUID   Guid;
    DWORD  Age;
    char   PdbFileName[1];
};

bool GetPdbInfo(const char* imagePath, GUID& outGuid, DWORD& outAge) {
    HANDLE hFile = CreateFileA(imagePath, GENERIC_READ,
        FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);

    HANDLE hMap = CreateFileMappingA(hFile, nullptr, PAGE_READONLY, 0, 0, nullptr);
    PVOID  base = MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);

    auto* dos  = (IMAGE_DOS_HEADER*)base;
    auto* nt   = (IMAGE_NT_HEADERS*)((BYTE*)base + dos->e_lfanew);
    auto  dbgDir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG];

    auto* entry = (IMAGE_DEBUG_DIRECTORY*)((BYTE*)base + dbgDir.VirtualAddress);

    if (entry->Type == IMAGE_DEBUG_TYPE_CODEVIEW) {
        auto* pdb = (PdbInfo*)((BYTE*)base + entry->PointerToRawData);
        if (pdb->Signature == 'SDSR') {  // 'RSDS' little-endian
            outGuid = pdb->Guid;
            outAge  = pdb->Age;
            // cleanup ...
            return true;
        }
    }
    return false;
}

The GUID here is not optional, Microsoft’s symbol server will reject the request if it doesn’t match exactly. This is what ties our request to this specific Windows build

Building the symbol server URL and downloading the PDB

With GUID and Age in hand, we construct the canonical symbol server URL and download the PDB. The format Microsoft uses is:

https://msdl.microsoft.com/download/symbols
    /ntoskrnl.pdb
    /<GUID_NO_DASHES><Age>
    /ntoskrnl.pdb

And the code:

#include <wininet.h>
#pragma comment(lib, "wininet.lib")

bool DownloadPdb(const GUID& guid, DWORD age, const char* outPath) {
    // Format GUID as uppercase no-dashes + age
    char guidStr[64];
    snprintf(guidStr, sizeof(guidStr),
        "%08X%04X%04X%02X%02X%02X%02X%02X%02X%02X%02X%X",
        guid.Data1, guid.Data2, guid.Data3,
        guid.Data4[0], guid.Data4[1], guid.Data4[2], guid.Data4[3],
        guid.Data4[4], guid.Data4[5], guid.Data4[6], guid.Data4[7],
        age
    );

    char url[512];
    snprintf(url, sizeof(url),
        "https://msdl.microsoft.com/download/symbols/ntoskrnl.pdb/%s/ntoskrnl.pdb",
        guidStr
    );

    HINTERNET hInet = InternetOpenA("SymbolDownloader", 
        INTERNET_OPEN_TYPE_DIRECT, nullptr, nullptr, 0);
    HINTERNET hUrl  = InternetOpenUrlA(hInet, url, 
        nullptr, 0, INTERNET_FLAG_SECURE, 0);

    HANDLE hOut = CreateFileA(outPath, GENERIC_WRITE,
        0, nullptr, CREATE_ALWAYS, 0, nullptr);

    char buf[4096]; DWORD read;
    while (InternetReadFile(hUrl, buf, sizeof(buf), &read) && read)
        WriteFile(hOut, buf, read, &read, nullptr);

    // cleanup handles ...
    return true;
}

Parsing the PDB and resolving the offset

Now we load the downloaded PDB using DbgHelp.dll and query the exact byte offset of the field we need. Here we target _EPROCESS.Token as an example

#include <dbghelp.h>
#pragma comment(lib, "dbghelp.lib")

DWORD ResolveFieldOffset(const char* pdbPath, const char* typeName, const char* fieldName){
    SymSetOptions(SYMOPT_UNDNAME | SYMOPT_LOAD_LINES);
    HANDLE hProc = GetCurrentProcess();

    // Load PDB from local path, fake base address
    DWORD64 base = SymLoadModuleEx(hProc, nullptr,
        pdbPath, nullptr, 0x10000000, 0, nullptr, 0);

    SYMBOL_INFO_PACKAGE sym = {};
    sym.si.SizeOfStruct = sizeof(SYMBOL_INFO);
    sym.si.MaxNameLen   = MAX_SYM_NAME;

    // Get type index for the struct
    SymGetTypeFromName(hProc, base, typeName, &sym.si);
    DWORD typeId = sym.si.TypeIndex;

    // Enumerate children (fields) to find our field
    TI_FINDCHILDREN_PARAMS* fc;
    DWORD childCount = 0;
    SymGetTypeInfo(hProc, base, typeId,
        TI_GET_CHILDRENCOUNT, &childCount);

    fc = (TI_FINDCHILDREN_PARAMS*)malloc(
        sizeof(TI_FINDCHILDREN_PARAMS) + childCount * sizeof(ULONG));
    fc->Count = childCount;
    fc->Start = 0;
    SymGetTypeInfo(hProc, base, typeId, TI_FINDCHILDREN, fc);

    for (DWORD i = 0; i < childCount; i++) {
        WCHAR* name = nullptr;
        SymGetTypeInfo(hProc, base, fc->ChildId[i], TI_GET_SYMNAME, &name);

        if (name && wcscmp(name, L"Token") == 0) {
            DWORD offset = 0;
            SymGetTypeInfo(hProc, base, fc->ChildId[i], TI_GET_OFFSET, &offset);
            LocalFree(name);
            free(fc);
            return offset;  // e.g. 0x4b8 on Windows 11 22H2
        }
        if (name) LocalFree(name);
    }

    free(fc);
    return 0;
}

Code

main.cpp

#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <Windows.h>
#include <winhttp.h>
#include <dbghelp.h>
#include <stdio.h>
#include <string>
#include <vector>
#include <algorithm>

#pragma comment(lib, "winhttp.lib")
#pragma comment(lib, "dbghelp.lib")

// Data Structures
struct PdbCodeViewInfo {
    GUID  Guid;
    DWORD Age;
    char  PdbFileName[MAX_PATH];
};

struct KernelOffsets {
    // EPROCESS struct field offsets (bytes from struct base)
    DWORD   ActiveProcessLinks;
    DWORD   UniqueProcessId;
    DWORD   Protection;         // PS_PROTECTION byte
    // Global symbol RVAs (offset from ntoskrnl image base)
    DWORD64 PsLoadedModuleList;
    DWORD64 PsInitialSystemProcess;
};

// PE Parsing 

#pragma pack(push, 1)
struct CV_INFO_PDB70 {
    DWORD CvSignature;   // 0x53445352 = 'RSDS'
    GUID  Signature;
    DWORD Age;
    char  PdbFileName[1];
};
#pragma pack(pop)

static DWORD RvaToFileOffset(PIMAGE_NT_HEADERS nt, DWORD rva) {
    PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
        if (rva >= sec->VirtualAddress &&
            rva < sec->VirtualAddress + sec->Misc.VirtualSize)
            return rva - sec->VirtualAddress + sec->PointerToRawData;
    }
    return 0;
}

static bool GetPdbInfoFromPE(const char* exePath, PdbCodeViewInfo& out) {
    HANDLE hFile = CreateFileA(exePath, GENERIC_READ, FILE_SHARE_READ,
        nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
    if (hFile == INVALID_HANDLE_VALUE) {
        printf("[-] Cannot open '%s' (err %lu)\n", exePath, GetLastError());
        return false;
    }

    LARGE_INTEGER sz{};
    GetFileSizeEx(hFile, &sz);
    std::vector<BYTE> buf(static_cast<size_t>(sz.QuadPart));
    DWORD rd = 0;
    bool ok = ReadFile(hFile, buf.data(), static_cast<DWORD>(buf.size()), &rd, nullptr)
        && rd == buf.size();
    CloseHandle(hFile);
    if (!ok) return false;

    auto* dos = reinterpret_cast<PIMAGE_DOS_HEADER>(buf.data());
    if (dos->e_magic != IMAGE_DOS_SIGNATURE) return false;
    auto* nt = reinterpret_cast<PIMAGE_NT_HEADERS>(buf.data() + dos->e_lfanew);
    if (nt->Signature != IMAGE_NT_SIGNATURE) return false;

    auto& dd = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG];
    if (!dd.VirtualAddress || !dd.Size) return false;

    DWORD ddOff = RvaToFileOffset(nt, dd.VirtualAddress);
    if (!ddOff || ddOff + dd.Size > buf.size()) return false;

    int entryCount = dd.Size / sizeof(IMAGE_DEBUG_DIRECTORY);
    auto* entries = reinterpret_cast<PIMAGE_DEBUG_DIRECTORY>(buf.data() + ddOff);

    for (int i = 0; i < entryCount; i++) {
        if (entries[i].Type != IMAGE_DEBUG_TYPE_CODEVIEW) continue;

        DWORD raw = entries[i].PointerToRawData;
        if (!raw) raw = RvaToFileOffset(nt, entries[i].AddressOfRawData);
        if (!raw || raw >= buf.size()) continue;

        auto* cv = reinterpret_cast<CV_INFO_PDB70*>(buf.data() + raw);
        if (cv->CvSignature != 0x53445352) continue;  // 'RSDS'

        out.Guid = cv->Signature;
        out.Age = cv->Age;
        strncpy_s(out.PdbFileName, cv->PdbFileName, _TRUNCATE);
        return true;
    }

    printf("[-] No CodeView RSDS entry found in PE\n");
    return false;
}

// PDB Cache Validation 

#pragma pack(push, 1)
struct MsfSuperBlock {
    char  FileMagic[0x20];
    DWORD BlockSize;
    DWORD FreeBlockMapBlock;
    DWORD NumBlocks;
    DWORD NumDirectoryBytes;
    DWORD Unknown;
    DWORD BlockMapAddr;
};
struct PdbInfoStreamHeader {
    DWORD Version;
    DWORD Signature;
    DWORD Age;
    GUID  UniqueId;
};
#pragma pack(pop)

static bool ExtractGuidFromPdb(const char* pdbPath, GUID& outGuid) {
    HANDLE hFile = CreateFileA(pdbPath, GENERIC_READ, FILE_SHARE_READ,
        nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
    if (hFile == INVALID_HANDLE_VALUE) return false;

    LARGE_INTEGER sz{};
    GetFileSizeEx(hFile, &sz);
    std::vector<BYTE> buf(static_cast<size_t>(sz.QuadPart));
    DWORD rd = 0;
    ReadFile(hFile, buf.data(), static_cast<DWORD>(buf.size()), &rd, nullptr);
    CloseHandle(hFile);

    if (buf.size() < sizeof(MsfSuperBlock)) return false;

    // MSF 7.00 magic (null-terminated string is 32 bytes including padding)
    static const char kMsfMagic[] = "Microsoft C/C++ MSF 7.00\r\n\x1A""DS";
    auto* sb = reinterpret_cast<MsfSuperBlock*>(buf.data());
    if (memcmp(sb->FileMagic, kMsfMagic, sizeof(kMsfMagic) - 1) != 0) return false;

    DWORD bs = sb->BlockSize;
    DWORD nd = sb->NumDirectoryBytes;
    if (!bs || !nd) return false;

    DWORD nDirBlocks = (nd + bs - 1) / bs;
    DWORD bmOffset = sb->BlockMapAddr * bs;
    if (bmOffset >= buf.size()) return false;

    // Reconstruct stream directory into contiguous buffer
    auto* blockIdx = reinterpret_cast<DWORD*>(buf.data() + bmOffset);
    std::vector<BYTE> dir(nd, 0);
    DWORD written = 0;
    for (DWORD i = 0; i < nDirBlocks; i++) {
        DWORD blkOff = blockIdx[i] * bs;
        if (blkOff >= buf.size()) break;
        DWORD chunk = std::min(bs, nd - written);
        memcpy(dir.data() + written, buf.data() + blkOff, chunk);
        written += chunk;
    }

    // Directory layout: [NumStreams(4)] [StreamSizes(4*N)] [StreamBlockIndices...]
    DWORD numStreams = *reinterpret_cast<DWORD*>(dir.data());
    if (numStreams < 2) return false;

    auto* streamSizes = reinterpret_cast<DWORD*>(dir.data() + 4);
    auto* flatBlocks = reinterpret_cast<DWORD*>(dir.data() + 4 + numStreams * 4);

    DWORD s0Size = streamSizes[0];
    DWORD s0Blocks = (s0Size == 0xFFFFFFFF) ? 0 : (s0Size + bs - 1) / bs;

    // Stream 1 first block index sits right after all of stream 0's block indices
    DWORD s1BlockOff = flatBlocks[s0Blocks] * bs;
    if (s1BlockOff + sizeof(PdbInfoStreamHeader) > buf.size()) return false;

    outGuid = reinterpret_cast<PdbInfoStreamHeader*>(buf.data() + s1BlockOff)->UniqueId;
    return true;
}

// PDB Download via WinHTTP from Microsoft Symbol Server
static std::wstring BuildSymSrvUri(const GUID& g, DWORD age, const wchar_t* pdbName) {
    wchar_t guid[48];
    swprintf_s(guid,
        L"%08X%04X%04X%02X%02X%02X%02X%02X%02X%02X%02X%X",
        g.Data1, g.Data2, g.Data3,
        g.Data4[0], g.Data4[1], g.Data4[2], g.Data4[3],
        g.Data4[4], g.Data4[5], g.Data4[6], g.Data4[7],
        age);

    std::wstring uri = L"/download/symbols/";
    uri += pdbName; uri += L"/";
    uri += guid;    uri += L"/";
    uri += pdbName;
    return uri;
}

static bool DownloadPdb(const GUID& guid, DWORD age, const wchar_t* pdbNameW, const char* outPath) {
    std::wstring uri = BuildSymSrvUri(guid, age, pdbNameW);
    printf("[*] Downloading: https://msdl.microsoft.com%ls\n", uri.c_str());

    HINTERNET hSess = WinHttpOpen(L"PDBOffsets/1.0",
        WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
        WINHTTP_NO_PROXY_NAME,
        WINHTTP_NO_PROXY_BYPASS, 0);
    if (!hSess) { printf("[-] WinHttpOpen failed (%lu)\n", GetLastError()); return false; }

    HINTERNET hConn = WinHttpConnect(hSess, L"msdl.microsoft.com",
        INTERNET_DEFAULT_HTTPS_PORT, 0);
    HINTERNET hReq = hConn ? WinHttpOpenRequest(hConn, L"GET", uri.c_str(),
        nullptr, WINHTTP_NO_REFERER,
        WINHTTP_DEFAULT_ACCEPT_TYPES,
        WINHTTP_FLAG_SECURE) : nullptr;

    auto closeAll = [&] {
        if (hReq)  WinHttpCloseHandle(hReq);
        if (hConn) WinHttpCloseHandle(hConn);
        WinHttpCloseHandle(hSess);
        };

    if (!hReq) { closeAll(); return false; }

    if (!WinHttpSendRequest(hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
        WINHTTP_NO_REQUEST_DATA, 0, 0, 0) ||
        !WinHttpReceiveResponse(hReq, nullptr)) {
        printf("[-] WinHTTP request failed (%lu)\n", GetLastError());
        closeAll();
        return false;
    }

    DWORD status = 0, statusLen = sizeof(status);
    WinHttpQueryHeaders(hReq,
        WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
        WINHTTP_HEADER_NAME_BY_INDEX, &status, &statusLen, WINHTTP_NO_HEADER_INDEX);

    if (status != 200) {
        printf("[-] HTTP %lu from symbol server\n", status);
        closeAll();
        return false;
    }

    // Read body in chunks
    std::vector<BYTE> body;
    body.reserve(32 * 1024 * 1024);
    BYTE chunk[65536];
    DWORD rd = 0;
    while (WinHttpReadData(hReq, chunk, sizeof(chunk), &rd) && rd > 0)
        body.insert(body.end(), chunk, chunk + rd);

    closeAll();

    if (body.empty()) {
        printf("[-] Empty response from symbol server\n");
        return false;
    }

    HANDLE hFile = CreateFileA(outPath, GENERIC_WRITE, 0, nullptr,
        CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
    if (hFile == INVALID_HANDLE_VALUE) {
        printf("[-] Cannot write PDB to '%s' (%lu)\n", outPath, GetLastError());
        return false;
    }
    DWORD wr = 0;
    WriteFile(hFile, body.data(), static_cast<DWORD>(body.size()), &wr, nullptr);
    CloseHandle(hFile);

    printf("[+] PDB saved: %s (%zu bytes)\n", outPath, body.size());
    return true;
}

// DbgHelp Symbol Resolution
struct SymFindCtx {
    const char* name;
    DWORD64     address;
    bool        found;
};

struct TypeFindCtx {
    const char* name;
    ULONG       typeIndex;
    bool        found;
};

static BOOL CALLBACK OnSymbol(PSYMBOL_INFO pInfo, ULONG, PVOID ctx) {
    auto* s = static_cast<SymFindCtx*>(ctx);
    if (_stricmp(pInfo->Name, s->name) == 0) {
        s->address = pInfo->Address;
        s->found = true;
        return FALSE;
    }
    return TRUE;
}

static BOOL CALLBACK OnType(PSYMBOL_INFO pInfo, ULONG, PVOID ctx) {
    auto* t = static_cast<TypeFindCtx*>(ctx);
    if (_stricmp(pInfo->Name, t->name) == 0) {
        t->typeIndex = pInfo->TypeIndex;
        t->found = true;
        return FALSE;
    }
    return TRUE;
}

// Returns RVA (offset from module base) of a named global symbol.
static DWORD64 ResolveSymbolRva(HANDLE hSym, DWORD64 modBase, const char* symName) {
    SymFindCtx ctx{ symName, 0, false };
    SymEnumSymbols(hSym, modBase, symName, OnSymbol, &ctx);
    if (!ctx.found || !ctx.address) {
        printf("[-] Symbol not found: %s\n", symName);
        return 0;
    }
    return ctx.address - modBase;
}

// Returns byte offset of a named field within a named struct.
static DWORD ResolveFieldOffset(HANDLE hSym, DWORD64 modBase,
    const char* structName, const char* fieldName) {
    TypeFindCtx tCtx{ structName, 0, false };
    SymEnumTypesByName(hSym, modBase, structName, OnType, &tCtx);
    if (!tCtx.found) {
        printf("[-] Struct not found: %s\n", structName);
        return 0;
    }

    DWORD childCount = 0;
    if (!SymGetTypeInfo(hSym, modBase, tCtx.typeIndex, TI_GET_CHILDRENCOUNT, &childCount) ||
        childCount == 0)
        return 0;

    // TI_FINDCHILDREN_PARAMS has a variable-length ChildId[] at the end
    size_t paramSz = sizeof(TI_FINDCHILDREN_PARAMS) + childCount * sizeof(ULONG);
    std::vector<BYTE> paramBuf(paramSz, 0);
    auto* params = reinterpret_cast<TI_FINDCHILDREN_PARAMS*>(paramBuf.data());
    params->Count = childCount;
    params->Start = 0;

    if (!SymGetTypeInfo(hSym, modBase, tCtx.typeIndex, TI_FINDCHILDREN, params))
        return 0;

    // Convert target field name to wide for comparison with TI_GET_SYMNAME output
    wchar_t wField[256];
    MultiByteToWideChar(CP_ACP, 0, fieldName, -1, wField, 256);

    for (DWORD i = 0; i < childCount; i++) {
        WCHAR* nameW = nullptr;
        if (!SymGetTypeInfo(hSym, modBase, params->ChildId[i], TI_GET_SYMNAME, &nameW) || !nameW)
            continue;

        bool match = (_wcsicmp(nameW, wField) == 0);
        LocalFree(nameW);  // DbgHelp allocates with LocalAlloc

        if (match) {
            DWORD offset = 0;
            SymGetTypeInfo(hSym, modBase, params->ChildId[i], TI_GET_OFFSET, &offset);
            return offset;
        }
    }

    printf("[-] Field not found: %s::%s\n", structName, fieldName);
    return 0;
}


static bool ResolveKernelOffsets(KernelOffsets& out) {
    // Locate ntoskrnl.exe
    char sysDir[MAX_PATH];
    if (!GetSystemDirectoryA(sysDir, MAX_PATH)) return false;

    char ntosPath[MAX_PATH];
    snprintf(ntosPath, MAX_PATH, "%s\\ntoskrnl.exe", sysDir);
    printf("[*] Kernel image: %s\n", ntosPath);

    // Extract CodeView PDB info from PE debug directory
    PdbCodeViewInfo pdbInfo{};
    if (!GetPdbInfoFromPE(ntosPath, pdbInfo)) {
        printf("[-] Failed to extract CodeView info from PE\n");
        return false;
    }

    // Strip any path prefix from PDB filename (keep leaf only)
    char* pdbName = pdbInfo.PdbFileName;
    for (int i = static_cast<int>(strlen(pdbInfo.PdbFileName)) - 1; i >= 0; i--) {
        if (pdbInfo.PdbFileName[i] == '\\' || pdbInfo.PdbFileName[i] == '/') {
            pdbName = &pdbInfo.PdbFileName[i + 1];
            break;
        }
    }
    printf("[*] PDB: %s  Age: %lu\n", pdbName, pdbInfo.Age);

    // Local cache path: %TEMP%\<pdbname>
    char tempDir[MAX_PATH];
    GetTempPathA(MAX_PATH, tempDir);
    char localPdb[MAX_PATH];
    snprintf(localPdb, MAX_PATH, "%s%s", tempDir, pdbName);

    // Validate cached PDB by checking its MSF stream-1 GUID
    bool needDownload = true;
    DWORD attr = GetFileAttributesA(localPdb);
    if (attr != INVALID_FILE_ATTRIBUTES && !(attr & FILE_ATTRIBUTE_DIRECTORY)) {
        GUID cachedGuid{};
        if (ExtractGuidFromPdb(localPdb, cachedGuid) && IsEqualGUID(cachedGuid, pdbInfo.Guid)) {
            printf("[+] Valid cached PDB: %s\n", localPdb);
            needDownload = false;
        }
        else {
            printf("[*] Cached PDB GUID mismatch, re-downloading\n");
        }
    }

    if (needDownload) {
        wchar_t pdbNameW[MAX_PATH];
        MultiByteToWideChar(CP_ACP, 0, pdbName, -1, pdbNameW, MAX_PATH);
        if (!DownloadPdb(pdbInfo.Guid, pdbInfo.Age, pdbNameW, localPdb)) {
            printf("[-] Failed to download PDB\n");
            return false;
        }
    }

    // Initialize DbgHelp and load the PDB
    SymSetOptions(SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS);

    // Use a unique fake handle so DbgHelp doesn't collide with any real process
    HANDLE hSym = reinterpret_cast<HANDLE>(static_cast<ULONG_PTR>(0xDEAD1234));
    if (!SymInitialize(hSym, nullptr, FALSE)) {
        printf("[-] SymInitialize failed (0x%lX)\n", GetLastError());
        return false;
    }

    wchar_t localPdbW[MAX_PATH];
    MultiByteToWideChar(CP_ACP, 0, localPdb, -1, localPdbW, MAX_PATH);

    const DWORD64 kFakeBase = 0x10000000ULL;
    DWORD64 modBase = SymLoadModuleExW(hSym, nullptr, localPdbW, nullptr,
        kFakeBase, 0, nullptr, 0);
    if (modBase == 0) {
        DWORD err = GetLastError();
        if (err != ERROR_SUCCESS) {
            printf("[-] SymLoadModuleExW failed (0x%lX)\n", err);
            SymCleanup(hSym);
            return false;
        }
        modBase = kFakeBase;  // already loaded
    }

    printf("[+] PDB loaded at base 0x%llX\n", modBase);

    // Resolve EPROCESS field offsets
    out.ActiveProcessLinks = ResolveFieldOffset(hSym, modBase, "_EPROCESS", "ActiveProcessLinks");
    out.UniqueProcessId = ResolveFieldOffset(hSym, modBase, "_EPROCESS", "UniqueProcessId");
    out.Protection = ResolveFieldOffset(hSym, modBase, "_EPROCESS", "Protection");

    // Resolve global symbol RVAs (offset from ntoskrnl image base at runtime)
    out.PsLoadedModuleList = ResolveSymbolRva(hSym, modBase, "PsLoadedModuleList");
    out.PsInitialSystemProcess = ResolveSymbolRva(hSym, modBase, "PsInitialSystemProcess");

    SymUnloadModule64(hSym, modBase);
    SymCleanup(hSym);

    return out.ActiveProcessLinks != 0 && out.PsInitialSystemProcess != 0;
}


int main() {
    KernelOffsets off{};
    if (!ResolveKernelOffsets(off)) {
        printf("\n[-] Failed to resolve kernel offsets\n");
        return 1;
    }

    printf("\n[+] Resolved offsets:\n");
    printf("  EPROCESS.ActiveProcessLinks     = 0x%lX\n", off.ActiveProcessLinks);
    printf("  EPROCESS.UniqueProcessId        = 0x%lX\n", off.UniqueProcessId);
    printf("  EPROCESS.Protection             = 0x%lX\n", off.Protection);
    printf("  PsLoadedModuleList     (RVA)    = 0x%llX\n", off.PsLoadedModuleList);
    printf("  PsInitialSystemProcess (RVA)    = 0x%llX\n", off.PsInitialSystemProcess);

    return 0;
}

Proof of Concept

Windows 11:

[*] Kernel image: C:\WINDOWS\system32\ntoskrnl.exe
[*] PDB: ntkrnlmp.pdb  Age: 1
[+] Valid cached PDB: C:\Users\s12de\AppData\Local\Temp\ntkrnlmp.pdb
[+] PDB loaded at base 0x10000000

[+] Resolved offsets:
  EPROCESS.ActiveProcessLinks     = 0x1D8
  EPROCESS.UniqueProcessId        = 0x1D0
  EPROCESS.Protection             = 0x5FA
  PsLoadedModuleList     (RVA)    = 0xEF5150
  PsInitialSystemProcess (RVA)    = 0xFC5AF0

Detection

Now it’s time to see if the defenses are detecting this as a malicious threat

Kleenscan API

[*] Antivirus Scan Results:

- alyac | Status: ok | Flag: Undetected | Updated: 2026-05-11
- amiti | Status: ok | Flag: Undetected | Updated: 2026-05-11
- arcabit | Status: ok | Flag: Undetected | Updated: 2026-05-11
- avast | Status: ok | Flag: Undetected | Updated: 2026-05-11
- avg | Status: ok | Flag: Undetected | Updated: 2026-05-11
- avira | Status: scanning | Flag: Scanning results incomplete | Updated: 2026-05-11
- bullguard | Status: ok | Flag: Undetected | Updated: 2026-05-11
- clamav | Status: ok | Flag: Undetected | Updated: 2026-05-11
- comodolinux | Status: ok | Flag: Undetected | Updated: 2026-05-11
- crowdstrike | Status: ok | Flag: Undetected | Updated: 2026-05-11
- drweb | Status: ok | Flag: Undetected | Updated: 2026-05-11
- emsisoft | Status: ok | Flag: Undetected | Updated: 2026-05-11
- escan | Status: ok | Flag: Undetected | Updated: 2026-05-11
- fprot | Status: ok | Flag: Undetected | Updated: 2026-05-11
- fsecure | Status: ok | Flag: Undetected | Updated: 2026-05-11
- gdata | Status: ok | Flag: Undetected | Updated: 2026-05-11
- ikarus | Status: ok | Flag: Undetected | Updated: 2026-05-11
- immunet | Status: ok | Flag: Undetected | Updated: 2026-05-11
- kaspersky | Status: ok | Flag: Undetected | Updated: 2026-05-11
- maxsecure | Status: ok | Flag: Undetected | Updated: 2026-05-11
- mcafee | Status: ok | Flag: Undetected | Updated: 2026-05-11
- microsoftdefender | Status: ok | Flag: Undetected | Updated: 2026-05-11
- nano | Status: ok | Flag: Undetected | Updated: 2026-05-11
- nod32 | Status: ok | Flag: Undetected | Updated: 2026-05-11
- norman | Status: ok | Flag: Undetected | Updated: 2026-05-11
- secureageapex | Status: ok | Flag: Unknown | Updated: 2026-05-11
- seqrite | Status: ok | Flag: Undetected | Updated: 2026-05-11
- sophos | Status: ok | Flag: Undetected | Updated: 2026-05-11
- threatdown | Status: ok | Flag: Undetected | Updated: 2026-05-11
- trendmicro | Status: ok | Flag: Undetected | Updated: 2026-05-11
- vba32 | Status: ok | Flag: Undetected | Updated: 2026-05-11
- virusfighter | Status: ok | Flag: Undetected | Updated: 2026-05-11
- xvirus | Status: ok | Flag: Undetected | Updated: 2026-05-11
- zillya | Status: ok | Flag: Undetected | Updated: 2026-05-11
- zonealarm | Status: pending | Flag: N/A | Updated: 2026-05-11
- zoner | Status: ok | Flag: Undetected

Litterbox

Static Analysis:

Dynamic Analysis:

Windows Defender

Undetected

YARA

Here a YARA rule to detect this technique:

rule PDB_Symbol_Resolution_Kernel_Offsets
{
    meta:
        author      = "0x12 Dark Development"
        description = "Detects binaries attempting to resolve kernel structure offsets via PDB symbol server queries at runtime"
        severity    = "high"
        category    = "offensive-toolkit"
        date        = "2026-05-12"

    strings:
        // Microsoft symbol server URLs and path patterns
        $sym_srv1   = "msdl.microsoft.com" ascii wide nocase
        $sym_srv2   = "symsrv.dll"         ascii wide nocase
        $sym_srv3   = "/download/symbols/" ascii wide nocase
        $sym_srv4   = "ntoskrnl.pdb"       ascii wide nocase

        // DbgHelp functions used to load and query PDB type info
        $dbg1       = "SymInitialize"        ascii
        $dbg2       = "SymLoadModuleEx"      ascii
        $dbg3       = "SymGetTypeFromName"   ascii
        $dbg4       = "SymGetTypeInfo"       ascii
        $dbg5       = "SymFromName"          ascii
        $dbg6       = "SymSetOptions"        ascii
        $dbg7       = "TI_GET_OFFSET"        ascii
        $dbg8       = "TI_FINDCHILDREN"      ascii

        // Kernel module enumeration via NtQuerySystemInformation
        $nt1        = "NtQuerySystemInformation" ascii
        $nt2        = "SystemModuleInformation"  ascii

        // Target kernel structures commonly resolved for offensive use
        $struct1    = "_EPROCESS"            ascii wide
        $struct2    = "_ETHREAD"             ascii wide
        $struct3    = "_KTHREAD"             ascii wide
        $struct4    = "_TOKEN"               ascii wide

        // High-value fields queried for privilege escalation / token manipulation
        $field1     = "Token"                ascii wide
        $field2     = "ActiveProcessLinks"   ascii wide
        $field3     = "UniqueProcessId"      ascii wide
        $field4     = "MitigationFlags"      ascii wide
        $field5     = "SignatureLevel"       ascii wide
        $field6     = "Protection"           ascii wide

        // WinInet / HTTP used to pull PDB from network
        $inet1      = "InternetOpenUrlA"     ascii
        $inet2      = "InternetReadFile"     ascii
        $inet3      = "InternetOpenA"        ascii
        $inet4      = "WinHttpOpen"          ascii
        $inet5      = "WinHttpSendRequest"   ascii

        // PE parsing indicators — Debug Directory access patterns
        $pe1        = "IMAGE_DEBUG_TYPE_CODEVIEW" ascii
        $pe2        = "RSDS"                      ascii
        $pe3_bytes  = { 52 53 44 53 }             // 'RSDS' raw bytes

        // ntoskrnl loaded path patterns
        $path1      = "ntoskrnl.exe"         ascii wide nocase
        $path2      = "\\SystemRoot\\"       ascii wide nocase
        $path3      = "System32\\ntos"       ascii wide nocase

    condition:
        uint16(0) == 0x5A4D               // valid PE
        and filesize < 20MB

        and (
            // Core: symbol server contact + DbgHelp usage
            (
                1 of ($sym_srv*)
                and 2 of ($dbg*)
            )
            or
            // Core: kernel module enumeration + PDB parsing
            (
                all of ($nt*)
                and 1 of ($dbg*)
                and 1 of ($path*)
            )
            or
            // Broader: HTTP download + DbgHelp + kernel struct names
            (
                1 of ($inet*)
                and 2 of ($dbg*)
                and 1 of ($struct*)
            )
        )

        // Raise confidence if sensitive kernel fields are also present
        and (
            1 of ($field*)
            or 1 of ($struct*)
        )
}

Conclusions

In this post we covered how to resolve kernel structure offsets dynamically using PDB symbols, moving away from static hardcoded tables and manual WinDBG sessions. The technique adapts automatically to any Windows build by querying Microsoft’s own symbol server.

Comments are closed.