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 SymInitialize, SymLoadModuleEx, SymEnumTypesByName, and SymGetTypeInfo to resolve fields like _EPROCESS.Token, ActiveProcessLinks, UniqueProcessId, 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:
- Find the loaded kernel module: First, we need to know where
ntoskrnl.exeis loaded in memory and where its file lives on disk. We useNtQuerySystemInformationwithSystemModuleInformationto get the full path of the loaded kernel image (e.g.\SystemRoot\System32\ntoskrnl.exe) - 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
- 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 - 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 - 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;
}
ImageBasegives us the runtime address.FullPathNamegives 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.

