Windows Internals

Windows internals for malware developers. PEB, TEB, NTAPI, PE format, tokens, syscall numbers, and kernel structures.

#Process Environment Block (PEB)

#PEB Structure

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.

#Accessing PEB

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 (Loaded Modules)

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;
}

#Anti-Debug via PEB

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);

#Thread Environment Block (TEB)

#TEB Structure

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.

#Accessing TEB

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;

#PE Format

#PE Structure Overview

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

#Parsing PE in C

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);

#Export Address Table (EAT)

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);
}

#Import Address Table (IAT)

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++;
}

#NTAPI Reference

#Key NT Functions

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 (Windows 10/11)

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.

#Token & Privileges

#Token Manipulation

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);

#Key Privileges

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

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);

#Useful Structures

Key kernel and NT structures for reference:

  • EPROCESS - kernel process object, contains process metadata, token, handle table
  • KPROCESS - kernel scheduler object embedded in EPROCESS, scheduling and CPU state
  • ETHREAD - kernel thread object, thread scheduling and context info
  • OBJECT_ATTRIBUTES - required for most Nt* object creation/open calls
  • IO_STATUS_BLOCK - status return structure for NT I/O operations
  • UNICODE_STRING - NT native string type (Length, MaximumLength, Buffer)
  • CLIENT_ID - PID + TID pair used in many NT functions

#Memory Management

#Memory Constants

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

#VirtualAlloc / VirtualProtect

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);

#Syscall Mechanism

#x64 Syscall Convention

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

#x86 Syscall Convention

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>

#Syscall in Rust

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 (32-bit on 64-bit)

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

#Exception Handling

#SEH (Structured Exception Handling)

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 (Vectored Exception Handling)

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 & Persistence

#Common Persistence Keys

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

#Windows Event IDs

#Security Events

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 & PowerShell Events

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

#PEB Walking in Rust

#Custom GetModuleHandle

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
}

#Process Creation & DLL Loading

#Process Creation Flow

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.

  1. NtCreateUserProcess (or legacy NtCreateProcessEx) creates the process object
  2. Kernel creates the EPROCESS/KPROCESS structures
  3. Address space is created, ntdll.dll is mapped first
  4. The PE image is mapped into the new address space
  5. PEB and initial TEB are created
  6. Initial thread is created (suspended)
  7. csrss.exe is notified (CsrClientCallServer)
  8. Windows subsystem (kernel32) is notified
  9. The initial thread begins execution at ntdll!LdrpInitialize
  10. LdrpInitialize calls LdrpInitializeProcess which loads DLL dependencies
  11. TLS callbacks execute
  12. DllMain for each loaded DLL is called with DLL_PROCESS_ATTACH
  13. Execution reaches the PE entry point (mainCRTStartup / WinMainCRTStartup)

#DLL Loading Mechanism

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):

  1. Application directory
  2. System directory (System32)
  3. 16-bit system directory
  4. Windows directory
  5. Current directory
  6. PATH directories

DLL Loading Internals:

  1. LdrLoadDll is called (ntdll)
  2. Checks if already loaded (LdrpHashTable / InLoadOrderModuleList)
  3. If not loaded, resolves the file path using search order
  4. NtCreateSection maps the DLL file
  5. NtMapViewOfSection maps it into the process
  6. A LDR_DATA_TABLE_ENTRY is created and linked into the three PEB_LDR_DATA lists
  7. Relocations are processed (base relocation table)
  8. Imports are resolved (recursive - loads dependencies)
  9. DllMain(DLL_PROCESS_ATTACH) is called
  10. TLS callbacks execute

#Also See

#Cyber Aurelien Guidi