Windows internals for malware developers. PEB, TEB, NTAPI, PE format, tokens, syscall numbers, and kernel structures.
The Process Environment Block is the user-mode representation of a process, maintained by ntdll. It lives in the process's own address space, meaning you can read it directly from memory without calling any API. This makes it a goldmine for maldev - you get access to loaded modules, heap info, debug status, and process parameters with zero API calls that could be hooked or logged.
Key offsets (x64):
typedef struct _PEB {
BOOLEAN InheritedAddressSpace; // 0x000
BOOLEAN ReadImageFileExecOptions; // 0x001
BOOLEAN BeingDebugged; // 0x002
union {
BOOLEAN BitField; // 0x003
struct {
BOOLEAN ImageUsesLargePages : 1;
BOOLEAN IsProtectedProcess : 1;
BOOLEAN IsImageDynamicallyRelocated : 1;
BOOLEAN SkipPatchingUser32Forwarders : 1;
BOOLEAN IsPackagedProcess : 1;
BOOLEAN IsAppContainer : 1;
BOOLEAN IsProtectedProcessLight : 1;
BOOLEAN IsLongPathAwareProcess : 1;
};
};
BYTE Padding0[4]; // 0x004
HANDLE Mutant; // 0x008
PVOID ImageBaseAddress; // 0x010
PPEB_LDR_DATA Ldr; // 0x018
PRTL_USER_PROCESS_PARAMETERS ProcessParameters; // 0x020
PVOID SubSystemData; // 0x028
PVOID ProcessHeap; // 0x030
// ... (gap)
ULONG NtGlobalFlag; // 0x0BC (x64)
// ...
ULONG SessionId; // 0x2C0
} PEB, *PPEB;
x86 offsets differ: BeingDebugged = 0x002, ImageBaseAddress = 0x008, Ldr = 0x00C, ProcessParameters = 0x010, ProcessHeap = 0x018, NtGlobalFlag = 0x068.
On x64 Windows, the GS segment register points to the TEB, and the PEB pointer sits at offset 0x60. On x86, use FS:0x30 instead. These segment register reads compile to single instructions, leaving no API call trace. NtQueryInformationProcess is the alternative when you need to read a remote process's PEB.
// x64 - via GS segment register
PPEB pPeb = (PPEB)__readgsqword(0x60);
// x86 - via FS segment register
PPEB pPeb = (PPEB)__readfsdword(0x30);
// Via NtQueryInformationProcess
PROCESS_BASIC_INFORMATION pbi;
NtQueryInformationProcess(hProcess, ProcessBasicInformation,
&pbi, sizeof(pbi), NULL);
PPEB pPeb = pbi.PebBaseAddress;
// C# via Marshal
IntPtr pPeb = Marshal.ReadIntPtr(pbi.PebBaseAddress);
PEB_LDR_DATA contains three linked lists of all loaded DLLs in the process, ordered by load order, memory order, and initialization order. Walking these lists is how you resolve module base addresses without calling GetModuleHandle - which is commonly hooked by EDRs. This technique is the foundation of custom GetModuleHandle/GetProcAddress implementations used in shellcode and reflective loaders.
typedef struct _PEB_LDR_DATA {
ULONG Length;
BOOLEAN Initialized;
PVOID SsHandle;
LIST_ENTRY InLoadOrderModuleList; // Load order
LIST_ENTRY InMemoryOrderModuleList; // Memory order
LIST_ENTRY InInitializationOrderModuleList; // Init order
} PEB_LDR_DATA;
// Walking loaded modules
PPEB pPeb = (PPEB)__readgsqword(0x60);
PLIST_ENTRY head = &pPeb->Ldr->InMemoryOrderModuleList;
PLIST_ENTRY curr = head->Flink;
while (curr != head) {
PLDR_DATA_TABLE_ENTRY entry = CONTAINING_RECORD(curr,
LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
printf("Module: %ws @ %p\n",
entry->BaseDllName.Buffer, entry->DllBase);
curr = curr->Flink;
}
These are the simplest anti-debug checks available - they read PEB fields directly from memory with no API calls. BeingDebugged is the same flag IsDebuggerPresent checks. NtGlobalFlag gets set to 0x70 when a debugger creates the process (heap debug flags). ProcessHeap flags are another giveaway since the debug heap sets Flags and ForceFlags differently. Trivial to bypass, but useful as a first layer.
// Check BeingDebugged flag
PPEB pPeb = (PPEB)__readgsqword(0x60);
if (pPeb->BeingDebugged) ExitProcess(0);
// Check NtGlobalFlag (0x70 when debugged -
// FLG_HEAP_ENABLE_TAIL_CHECK |
// FLG_HEAP_ENABLE_FREE_CHECK |
// FLG_HEAP_VALIDATE_PARAMETERS)
if (*(DWORD*)((BYTE*)pPeb + 0xBC) & 0x70)
ExitProcess(0);
// Check ProcessHeap flags
PVOID pHeap = pPeb->ProcessHeap;
DWORD flags = *(DWORD*)((BYTE*)pHeap + 0x70); // Flags
DWORD forceFlags = *(DWORD*)((BYTE*)pHeap + 0x74); // ForceFlags
if (flags & ~2 || forceFlags) ExitProcess(0);
The Thread Environment Block is a per-thread structure that lives in user-mode memory. It contains the pointer to the PEB (offset 0x60 on x64), the thread's CLIENT_ID with both PID and TID (offset 0x40), and the last error value. For maldev, TEB is useful for retrieving the current PID/TID without calling GetCurrentProcessId/GetCurrentThreadId, which avoids API-level monitoring.
Key offsets (x64):
typedef struct _TEB {
NT_TIB NtTib; // 0x000
// NT_TIB contains:
// ExceptionList // 0x000 (SEH chain head)
// StackBase // 0x008 (top of stack)
// StackLimit // 0x010 (bottom of stack)
// SubSystemTib // 0x018
// FiberData / Version // 0x020
// ArbitraryUserPointer // 0x028
// Self (TEB pointer) // 0x030
PVOID EnvironmentPointer; // 0x038
CLIENT_ID ClientId; // 0x040 (PID + TID)
PVOID ActiveRpcHandle; // 0x050
PVOID ThreadLocalStoragePointer; // 0x058
PPEB ProcessEnvironmentBlock; // 0x060 <- PEB pointer
ULONG LastErrorValue; // 0x068
// ...
} TEB, *PTEB;
x86 offsets: StackBase = 0x004, StackLimit = 0x008, Self = 0x018, ClientId = 0x020, PEB = 0x030, LastErrorValue = 0x034.
On x64, the GS segment register base points directly to the TEB, and reading GS:0x30 gives you the TEB's self-reference pointer. On x86, FS:0x18 serves the same purpose. From the TEB you can reach the PEB and extract PID/TID - all through direct memory reads, no API calls involved.
// x64 - GS register
PTEB pTeb = (PTEB)__readgsqword(0x30);
// x86 - FS register
PTEB pTeb = (PTEB)__readfsdword(0x18);
// Get PEB from TEB
PPEB pPeb = pTeb->ProcessEnvironmentBlock;
// Get current PID/TID
DWORD pid = (DWORD)pTeb->ClientId.UniqueProcess;
DWORD tid = (DWORD)pTeb->ClientId.UniqueThread;
The Portable Executable format is the binary format for Windows executables, DLLs, and drivers. Understanding PE internals is essential for building custom loaders, reflective DLL injection, shellcode that parses modules in memory, and IAT/EAT hooking. Every offensive tool that touches a Windows binary needs to parse these headers.
| Offset | Structure | Key Fields |
|---|---|---|
| 0x00 | DOS Header | e_magic (MZ), e_lfanew (PE offset) |
| e_lfanew | NT Headers | Signature (PE\0\0) |
| +0x04 | File Header | Machine, NumberOfSections, TimeDateStamp, SizeOfOptionalHeader |
| +0x18 | Optional Header (x64) | AddressOfEntryPoint, ImageBase, SectionAlignment, SizeOfImage |
| +0x88 | Data Directories | Export, Import, Resource, Exception, Security, BaseReloc, Debug, TLS, IAT |
| After Optional | Section Headers | .text, .rdata, .data, .rsrc, .reloc |
This is the foundation for any custom PE loader. You memory-map the file and walk the DOS header to find e_lfanew, which points to the NT headers. From there you access the Optional Header for entry point, image base, and the Data Directories array that leads to imports, exports, relocations, and TLS. Every reflective loader and packer starts with this exact parsing logic.
// Map PE file
HANDLE hFile = CreateFileA(path,
GENERIC_READ, 0, NULL,
OPEN_EXISTING, 0, NULL);
HANDLE hMap = CreateFileMapping(
hFile, NULL, PAGE_READONLY, 0, 0, NULL);
LPVOID pBase = MapViewOfFile(
hMap, FILE_MAP_READ, 0, 0, 0);
// Parse headers
PIMAGE_DOS_HEADER pDos =
(PIMAGE_DOS_HEADER)pBase;
PIMAGE_NT_HEADERS pNt =
(PIMAGE_NT_HEADERS)((BYTE*)pBase
+ pDos->e_lfanew);
PIMAGE_OPTIONAL_HEADER pOpt =
&pNt->OptionalHeader;
printf("Entry: 0x%X\n",
pOpt->AddressOfEntryPoint);
printf("ImageBase: 0x%llX\n",
pOpt->ImageBase);
printf("Sections: %d\n",
pNt->FileHeader.NumberOfSections);
Walking the EAT is how you resolve exported function addresses without calling GetProcAddress - the same technique used in shellcode and custom loaders. The EAT has three parallel arrays: names, ordinals, and function RVAs. You search the names array, use the matching index to get the ordinal, then use the ordinal to index into the functions array. This is the core of manual API resolution.
// Walk exports
DWORD eatRVA = pOpt->DataDirectory[
IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
PIMAGE_EXPORT_DIRECTORY pExport =
(PIMAGE_EXPORT_DIRECTORY)(
(BYTE*)pBase + eatRVA);
DWORD* pNames = (DWORD*)(
(BYTE*)pBase + pExport->AddressOfNames);
WORD* pOrdinals = (WORD*)(
(BYTE*)pBase
+ pExport->AddressOfNameOrdinals);
DWORD* pFunctions = (DWORD*)(
(BYTE*)pBase
+ pExport->AddressOfFunctions);
for (DWORD i = 0;
i < pExport->NumberOfNames; i++) {
char* name = (char*)(
(BYTE*)pBase + pNames[i]);
DWORD funcRVA =
pFunctions[pOrdinals[i]];
printf("%s @ RVA 0x%X\n",
name, funcRVA);
}
The IAT is where the Windows loader writes the resolved addresses of imported functions at load time. This is also the primary target for EDR user-mode hooking - security products overwrite IAT entries to redirect calls through their inspection code. Understanding IAT layout is critical for both IAT hooking (offensive) and detecting/unhooking IAT patches (evasion).
// Walk imports
DWORD iatRVA = pOpt->DataDirectory[
IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
PIMAGE_IMPORT_DESCRIPTOR pImport =
(PIMAGE_IMPORT_DESCRIPTOR)(
(BYTE*)pBase + iatRVA);
while (pImport->Name) {
char* dllName = (char*)(
(BYTE*)pBase + pImport->Name);
printf("DLL: %s\n", dllName);
PIMAGE_THUNK_DATA pThunk =
(PIMAGE_THUNK_DATA)(
(BYTE*)pBase
+ pImport->OriginalFirstThunk);
while (pThunk->u1.AddressOfData) {
if (!(pThunk->u1.Ordinal
& IMAGE_ORDINAL_FLAG)) {
PIMAGE_IMPORT_BY_NAME pName =
(PIMAGE_IMPORT_BY_NAME)(
(BYTE*)pBase
+ pThunk->u1.AddressOfData);
printf(" -> %s\n",
pName->Name);
}
pThunk++;
}
pImport++;
}
These are the lowest user-mode API layer - the Nt* functions in ntdll.dll that sit just above the syscall instruction. Every kernel32/advapi32 function (VirtualAlloc, CreateRemoteThread, etc.) is a thin wrapper around these. Calling them directly bypasses kernel32-level hooks. Going one step further, you can extract the syscall numbers and execute syscalls directly to bypass ntdll hooks as well.
| NTAPI (ntdll.dll) | Kernel32 Equivalent | Use |
|---|---|---|
| NtAllocateVirtualMemory | VirtualAlloc/VirtualAllocEx | Memory allocation |
| NtProtectVirtualMemory | VirtualProtect/Ex | Change page protection |
| NtWriteVirtualMemory | WriteProcessMemory | Write to process memory |
| NtCreateThreadEx | CreateRemoteThread | Create thread in process |
| NtOpenProcess | OpenProcess | Get process handle |
| NtCreateSection | CreateFileMapping | Create section object |
| NtMapViewOfSection | MapViewOfFile | Map section into process |
| NtUnmapViewOfSection | UnmapViewOfFile | Unmap section |
| NtQueueApcThread | QueueUserAPC | Queue APC to thread |
| NtCreateFile | CreateFile | File operations |
| NtQueryInformationProcess | - | Query process info |
| NtQuerySystemInformation | - | System info (process list) |
| NtClose | CloseHandle | Close handle |
| NtResumeThread | ResumeThread | Resume suspended thread |
| NtSuspendThread | SuspendThread | Suspend thread |
| NtSetContextThread | SetThreadContext | Modify thread context |
| NtGetContextThread | GetThreadContext | Read thread context |
| NtDuplicateObject | DuplicateHandle | Duplicate handle |
Syscall numbers (SSNs) are the numeric identifiers passed in EAX before the syscall instruction. They change between Windows builds - NtCreateThreadEx alone shifts from 0xC1 to 0xC7 across versions shown below. Hardcoding SSNs breaks portability, which is why runtime resolution (reading the mov eax, SSN stub from ntdll, or using tools like SysWhispers/HellsGate) is the standard approach.
| Function | Win10 1809 | Win10 21H2 | Win11 22H2 |
|---|---|---|---|
| NtAllocateVirtualMemory | 0x18 | 0x18 | 0x18 |
| NtProtectVirtualMemory | 0x50 | 0x50 | 0x50 |
| NtWriteVirtualMemory | 0x3A | 0x3A | 0x3A |
| NtCreateThreadEx | 0xC1 | 0xC2 | 0xC7 |
| NtOpenProcess | 0x26 | 0x26 | 0x26 |
| NtCreateSection | 0x4A | 0x4A | 0x4A |
| NtMapViewOfSection | 0x28 | 0x28 | 0x28 |
| NtQueueApcThread | 0x45 | 0x45 | 0x45 |
Note: syscall numbers may vary by build. Use runtime resolution (e.g., reading ntdll stubs or SyscallsInline/SysWhispers) for reliability.
SeDebugPrivilege is the key privilege for offensive operations - it grants the ability to open a handle to any process regardless of its security descriptor. Without it, OpenProcess on SYSTEM or protected processes fails. This snippet shows how to enable it in your own token, which is the first step before any cross-process operation like injection or token theft. Requires running as admin.
// Enable SeDebugPrivilege
HANDLE hToken;
OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&hToken);
TOKEN_PRIVILEGES tp;
LookupPrivilegeValue(NULL, SE_DEBUG_NAME,
&tp.Privileges[0].Luid);
tp.PrivilegeCount = 1;
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
AdjustTokenPrivileges(hToken, FALSE, &tp,
sizeof(tp), NULL, NULL);
Each privilege unlocks a specific offensive capability. SeDebugPrivilege enables process injection into any process. SeImpersonatePrivilege is what makes potato-style privilege escalation work (service accounts to SYSTEM). SeBackup/SeRestore bypass all file ACLs for data exfil or planting payloads. SeLoadDriverPrivilege lets you load a vulnerable or malicious kernel driver. Knowing which privileges to target is essential for privilege escalation paths.
| Privilege | Use |
|---|---|
| SeDebugPrivilege | Debug any process (process injection) |
| SeImpersonatePrivilege | Impersonate tokens (potato attacks) |
| SeAssignPrimaryTokenPrivilege | Assign token to process |
| SeBackupPrivilege | Read any file (bypass ACL) |
| SeRestorePrivilege | Write any file (bypass ACL) |
| SeTakeOwnershipPrivilege | Take ownership of objects |
| SeLoadDriverPrivilege | Load kernel drivers |
| SeTcbPrivilege | Act as part of TCB |
Token impersonation lets you steal the security identity of another process. You open the target's process token, duplicate it as an impersonation token, and apply it to your current thread. From that point, your thread runs with the target's identity and privileges. You can also spawn a new process under the stolen token with CreateProcessWithTokenW. This is the core mechanic behind token theft in post-exploitation.
// Steal and impersonate token from another process
HANDLE hProcess = OpenProcess(
PROCESS_QUERY_INFORMATION, FALSE, targetPid);
HANDLE hToken;
OpenProcessToken(hProcess,
TOKEN_DUPLICATE | TOKEN_IMPERSONATE, &hToken);
HANDLE hDupToken;
DuplicateTokenEx(hToken, MAXIMUM_ALLOWED, NULL,
SecurityImpersonation, TokenImpersonation,
&hDupToken);
SetThreadToken(NULL, hDupToken);
// Now current thread has the target's identity
// Create process with stolen token
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi;
CreateProcessWithTokenW(hDupToken,
LOGON_WITH_PROFILE,
L"C:\\Windows\\System32\\cmd.exe",
NULL, 0, NULL, NULL, &si, &pi);
Key kernel and NT structures for reference:
These constants appear in nearly every injection technique. VirtualAlloc/VirtualProtect take protection flags and allocation types as parameters. Memorize the hex values since you will see them constantly in shellcode, loaders, and injection code. PAGE_EXECUTE_READWRITE (0x40) is the classic red flag that security products look for - allocating RWX memory is almost never legitimate.
| Constant | Value | Description |
|---|---|---|
| PAGE_NOACCESS | 0x01 | No access allowed |
| PAGE_READONLY | 0x02 | Read only |
| PAGE_READWRITE | 0x04 | Read/write |
| PAGE_WRITECOPY | 0x08 | Copy-on-write |
| PAGE_EXECUTE | 0x10 | Execute only |
| PAGE_EXECUTE_READ | 0x20 | Execute + read |
| PAGE_EXECUTE_READWRITE | 0x40 | Execute + read + write (RWX) |
| PAGE_EXECUTE_WRITECOPY | 0x80 | Execute + copy-on-write |
| PAGE_GUARD | 0x100 | Guard page (modifier) |
| MEM_COMMIT | 0x1000 | Commit physical storage |
| MEM_RESERVE | 0x2000 | Reserve address range |
| MEM_DECOMMIT | 0x4000 | Decommit pages |
| MEM_RELEASE | 0x8000 | Release pages |
| MEM_FREE | 0x10000 | Free pages |
| MEM_PRIVATE | 0x20000 | Private memory |
| MEM_MAPPED | 0x40000 | Mapped memory |
| MEM_IMAGE | 0x1000000 | Image-backed memory |
The standard two-step for shellcode execution: allocate RW memory, write shellcode, change protection to RX, then execute. Splitting the allocation and protection change avoids ever having a single RWX allocation, which is slightly less suspicious. Using NTAPI equivalents (NtAllocateVirtualMemory, NtProtectVirtualMemory) bypasses kernel32-level hooks.
// Allocate RW, write, flip to RX
LPVOID pMem = VirtualAlloc(NULL, shellcodeLen,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
memcpy(pMem, shellcode, shellcodeLen);
DWORD oldProtect;
VirtualProtect(pMem, shellcodeLen,
PAGE_EXECUTE_READ, &oldProtect);
// Execute
((void(*)())pMem)();
// NTAPI equivalent
PVOID pBase = NULL;
SIZE_T sz = shellcodeLen;
NTSTATUS status = NtAllocateVirtualMemory(
GetCurrentProcess(), &pBase, 0, &sz,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
memcpy(pBase, shellcode, shellcodeLen);
ULONG oldProt;
NtProtectVirtualMemory(GetCurrentProcess(),
&pBase, &sz, PAGE_EXECUTE_READ, &oldProt);
On x64 Windows, the syscall instruction transitions from user-mode to kernel-mode. The syscall number (SSN) goes in EAX, and arguments follow the standard Windows x64 calling convention: RCX, RDX, R8, R9, then stack. R10 gets a copy of RCX because the syscall instruction overwrites RCX with the return address. The stub in ntdll.dll for every Nt* function follows this exact pattern.
; Standard ntdll Nt* stub (x64)
mov r10, rcx ; save rcx (syscall clobbers it)
mov eax, <SSN> ; syscall number in eax
test byte ptr [7FFE0308h], 1 ; SharedUserData->SystemCall
jne 3 ; use int 2e if set
syscall ; transition to kernel
ret
int 2eh ; fallback (rare)
ret
// Direct syscall in C (inline assembly, MSVC x64)
// MSVC does not support inline asm for x64,
// so use a separate .asm file:
; syscall.asm (MASM)
NtAllocateVirtualMemory PROC
mov r10, rcx
mov eax, 18h ; SSN for NtAllocateVirtualMemory
syscall
ret
NtAllocateVirtualMemory ENDP
On 32-bit Windows, the transition to kernel-mode uses either sysenter (Intel, fast path) or int 0x2E (legacy/fallback). The syscall number goes in EAX and EDX points to the argument array on the stack. On modern x86 Windows, ntdll calls through KiFastSystemCall which uses sysenter.
; x86 ntdll stub (KiFastSystemCall path)
mov eax, <SSN> ; syscall number
mov edx, esp ; pointer to args on stack
sysenter ; fast transition to kernel
ret <n> ; clean stack (stdcall)
; Legacy path
mov eax, <SSN>
lea edx, [esp+4]
int 2Eh ; software interrupt
ret <n>
Using the ntapi and windows-sys crates, or raw inline assembly with core::arch::asm!. For direct syscalls, Rust's inline assembly support works well since it does not have the MSVC x64 inline asm restriction.
use std::arch::asm;
// Direct syscall: NtAllocateVirtualMemory
// SSN must be resolved at runtime for portability
unsafe fn nt_allocate_virtual_memory(
process: isize, // HANDLE
base: *mut *mut u8,
zero_bits: usize,
size: *mut usize,
alloc_type: u32,
protect: u32,
ssn: u32,
) -> i32 {
let status: i32;
asm!(
"mov r10, rcx",
"syscall",
in("eax") ssn,
in("rcx") process,
in("rdx") base,
in("r8") zero_bits,
in("r9") size,
// alloc_type and protect go on shadow
// stack at [rsp+0x28] and [rsp+0x30]
// - must be set up by caller
lateout("eax") status,
clobber_abi("system"),
);
status
}
WoW64 is the compatibility layer that lets 32-bit processes run on 64-bit Windows. When a 32-bit process makes a syscall, it goes through wow64cpu.dll which transitions from 32-bit to 64-bit mode via the "Heaven's Gate" segment selector (0x33). The process has both a 32-bit PEB and a 64-bit PEB. This matters for maldev because injection from a 32-bit process into a 64-bit process (or vice versa) requires crossing this boundary.
| Component | Role |
|---|---|
| wow64.dll | Core WoW64 runtime, thunking logic |
| wow64cpu.dll | CPU mode switching (x86 to x64) |
| wow64win.dll | Win32k syscall thunking |
| ntdll.dll (32-bit) | 32-bit ntdll loaded in WoW64 process |
| ntdll.dll (64-bit) | 64-bit ntdll also mapped (hidden) |
| Heaven's Gate | Far jump to CS:0x33 to enter 64-bit mode |
; Heaven's Gate - switch from 32-bit to 64-bit mode
; Used in WoW64 and exploited for evasion
push 0x33 ; 64-bit code segment selector
push <64bit_code_addr>
retf ; far return -> switches to long mode
SEH is the x86 exception handling mechanism. Exception handlers are stored as a linked list on the stack, with the head pointer at TEB offset 0x000 (NT_TIB.ExceptionList). Each node points to a handler function and the next node. SEH is exploitable because overwriting the handler pointer on the stack during a buffer overflow redirects execution. SafeSEH and SEHOP are mitigations. On x64, SEH is table-based (stored in the PE .pdata section), not stack-based.
// x86 SEH - register a handler
__try {
// guarded code
*(int*)0 = 0; // trigger access violation
}
__except(EXCEPTION_EXECUTE_HANDLER) {
printf("Caught exception\n");
}
// Manual SEH registration (x86)
typedef struct _EXCEPTION_REGISTRATION {
struct _EXCEPTION_REGISTRATION* Next;
PEXCEPTION_ROUTINE Handler;
} EXCEPTION_REGISTRATION;
VEH is a process-wide exception handling mechanism independent of the call stack. Handlers are called before SEH, making them useful for implementing custom exception dispatching, anti-debug tricks (hardware breakpoint detection), and code flow obfuscation via intentional exceptions. VEH handlers are stored in a linked list in ntdll and managed via AddVectoredExceptionHandler.
// Register a VEH handler
LONG CALLBACK VehHandler(
PEXCEPTION_POINTERS pExInfo) {
if (pExInfo->ExceptionRecord->ExceptionCode
== EXCEPTION_SINGLE_STEP) {
// Hardware breakpoint detected (DR regs)
// Anti-debug: clear DRs and continue
pExInfo->ContextRecord->Dr0 = 0;
pExInfo->ContextRecord->Dr7 = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
AddVectoredExceptionHandler(1, VehHandler);
Registry is the most common persistence mechanism on Windows. These keys control what runs at startup for the current user or all users. Security tools monitor these paths heavily, so advanced persistence uses less obvious locations like WMI subscriptions, scheduled tasks, or COM hijacking - but registry Run keys remain the baseline technique.
| Key Path | Scope |
|---|---|
| HKCU\Software\Microsoft\Windows\CurrentVersion\Run | Current user logon |
| HKLM\Software\Microsoft\Windows\CurrentVersion\Run | All users logon |
| HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnce | Current user (once) |
| HKLM\Software\Microsoft\Windows\CurrentVersion\RunOnce | All users (once) |
| HKLM\SYSTEM\CurrentControlSet\Services | Services (requires admin) |
| HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\Shell | Shell replacement |
| HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options | IFEO debugger |
| HKCU\Environment\UserInitMprLogonScript | Logon script |
| HKLM\SOFTWARE\Classes\CLSID\{...}\InprocServer32 | COM hijacking |
These keys control security settings, logging, and defensive tool behavior. Knowing these paths is important both for hardening assessments and for understanding what telemetry is active.
| Key Path | Purpose |
|---|---|
| HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\EnableLUA | UAC enabled |
| HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\ConsentPromptBehaviorAdmin | UAC prompt level |
| HKLM\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest\UseLogonCredential | WDigest plaintext creds |
| HKLM\SYSTEM\CurrentControlSet\Control\Lsa\RunAsPPL | LSA Protection |
| HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging | PS script block logging |
| HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription | PS transcription |
| HKLM\SYSTEM\CurrentControlSet\Services\EventLog\Security | Security event log config |
| HKLM\SOFTWARE\Microsoft\AMSI | AMSI providers |
These Event IDs are the primary telemetry sources that SOC teams and SIEM rules look for. Knowing what gets logged is essential for operational security - if you know Event 4688 records command lines, you obfuscate them. If you know 4624 type 3 is network logon, you understand what lateral movement looks like in logs.
| Event ID | Source | Description |
|---|---|---|
| 4624 | Security | Successful logon |
| 4625 | Security | Failed logon |
| 4634 | Security | Logoff |
| 4648 | Security | Explicit credential logon (runas) |
| 4672 | Security | Special privileges assigned (admin logon) |
| 4688 | Security | Process creation (w/ command line if enabled) |
| 4689 | Security | Process termination |
| 4697 | Security | Service installed |
| 4698 | Security | Scheduled task created |
| 4702 | Security | Scheduled task updated |
| 4720 | Security | User account created |
| 4732 | Security | Member added to local group |
| 7045 | System | New service installed |
Sysmon provides much richer process and network telemetry than the built-in Security log. If Sysmon is running, assume everything is logged: process creation with hashes, network connections, file creation, registry changes, and named pipe events. PowerShell 5.0+ script block logging (4104) captures the deobfuscated script content, making encoded/obfuscated commands transparent.
| Event ID | Source | Description |
|---|---|---|
| 1 | Sysmon | Process creation (w/ hashes, parent info) |
| 3 | Sysmon | Network connection |
| 7 | Sysmon | Image loaded (DLL) |
| 8 | Sysmon | CreateRemoteThread detected |
| 10 | Sysmon | Process access (OpenProcess) |
| 11 | Sysmon | File create |
| 12/13/14 | Sysmon | Registry events |
| 17/18 | Sysmon | Named pipe create/connect |
| 22 | Sysmon | DNS query |
| 25 | Sysmon | Process tampering |
| 4103 | PowerShell | Module logging |
| 4104 | PowerShell | Script block logging |
| 4105/4106 | PowerShell | Script start/stop |
| 800 | PowerShell | Pipeline execution |
Walking the PEB in Rust to resolve module base addresses without calling GetModuleHandle. This is the Rust equivalent of the C PEB walking code shown earlier. Uses raw pointer arithmetic and the Windows TEB/PEB structures. The windows-sys crate provides the necessary type definitions, but you can also define them manually to avoid dependencies in shellcode scenarios.
use core::arch::asm;
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
#[repr(C)]
struct ListEntry {
flink: *mut ListEntry,
blink: *mut ListEntry,
}
#[repr(C)]
struct UnicodeString {
length: u16,
maximum_length: u16,
_pad: [u8; 4],
buffer: *const u16,
}
#[repr(C)]
struct LdrDataTableEntry {
in_load_order_links: ListEntry,
in_memory_order_links: ListEntry,
in_initialization_order_links: ListEntry,
dll_base: *mut u8,
entry_point: *mut u8,
size_of_image: u32,
_pad: [u8; 4],
full_dll_name: UnicodeString,
base_dll_name: UnicodeString,
}
/// Walk the PEB InMemoryOrderModuleList.
/// Returns the base address of the module matching `name`.
unsafe fn get_module_base(name: &str) -> Option<*mut u8> {
// Read PEB from TEB (GS:[0x60] on x64)
let peb: *const u8;
asm!("mov {}, gs:[0x60]", out(reg) peb, options(nostack, nomem));
// PEB.Ldr is at offset 0x18
let ldr = *(peb.add(0x18) as *const *const u8);
// PEB_LDR_DATA.InMemoryOrderModuleList at offset 0x20
let head = ldr.add(0x20) as *const ListEntry;
let mut curr = (*head).flink;
let target: Vec<u16> = name.encode_utf16().collect();
while curr as *const _ != head {
let entry = (curr as *const u8).sub(0x10)
as *const LdrDataTableEntry;
let dll_name = &(*entry).base_dll_name;
if dll_name.length > 0 && !dll_name.buffer.is_null() {
let len = (dll_name.length / 2) as usize;
let slice = std::slice::from_raw_parts(
dll_name.buffer, len);
// Case-insensitive compare
if slice.len() == target.len()
&& slice.iter().zip(target.iter()).all(
|(a, b)| a.to_ascii_lowercase()
== b.to_ascii_lowercase())
{
return Some((*entry).dll_base);
}
}
curr = (*curr).flink;
}
None
}
When CreateProcess is called, the kernel does far more than just starting code execution. Understanding this flow is critical for process hollowing, process doppelganging, and process herpaderping - all of which intercept or manipulate specific stages. The key insight is that the image is mapped and the PEB is created before any user-mode code runs, giving you a window to modify the process before it starts.
When the loader processes a DLL dependency (from IAT or LoadLibrary call), it follows a specific search order and initialization sequence. Understanding this enables DLL hijacking, DLL proxying, and search order abuse. Known DLLs are cached in \KnownDlls\ object directory and bypass the filesystem search entirely.
Default DLL Search Order (SafeDllSearchMode enabled):
DLL Loading Internals: