Threads and the TEB (Thread Environment Block)

By Debraj Basak·Jul 24, 2025 · Updated Aug 1, 2026·12 min readWindows Internals

Every call to GetLastError(), GetCurrentThreadId(), or TlsGetValue() reads from the same place: a per-thread structure the kernel mapped into your address space before your entry point ever fired. That structure is the Thread Environment Block. It is also the first thing shellcode touches when it needs to find kernel32.dll without a single import table entry.

This tutorial tears the TEB apart field by field – from the stable NT_TIB core through the segment-register path the CPU uses to find it, then into the offensive primitives it enables: the PEB module walk and thread execution hijacking. If you’ve only ever seen the TEB through !teb in WinDbg, this will fill the gaps that command hides behind hex.


1. Kernel Thread Anatomy: ETHREAD, KTHREAD, and the TEB

Windows represents a thread across three nested structures. Get the layering straight and everything downstream clicks.

ETHREAD is the executive-layer kernel object. It owns the thread’s security context, IRP list, and impersonation state. Its first embedded member is KTHREAD – the scheduler-facing struct that holds the kernel stack pointer, priority, quantum, wait state, and thread affinity. Inside KTHREAD sits a pointer to the thread’s TEB – the user-mode representation that lives in the process’s virtual address space, readable and writable from ring 3.

Creation flows downward. A call to CreateThread() enters kernelbase.dll, which calls NtCreateThreadEx() in ntdll.dll, which issues the syscall. The kernel allocates an ETHREAD, initializes the KTHREAD scheduler fields, carves out a user-mode stack, maps a fresh TEB page, and sets the appropriate segment-register base so the new thread can find its TEB the instant it begins executing.

Thread Lifecycle

StageMechanism
CreationCreateThreadNtCreateThreadEx → kernel allocates ETHREAD/KTHREAD, maps TEB
ReadyThread enters the ready queue; priority level (0-31) determines position
RunningDispatcher selects thread; quantum (time-slice) begins counting down
WaitingWaitForSingleObject, Sleep, I/O completion – thread yields the processor
TerminatedReturn from start routine, ExitThread, or (dangerous) TerminateThread

Threads that call TerminateThread() on themselves or others skip DLL detach notifications and can leave critical sections permanently locked. In practice, you will see this in malware that cleans up injected threads before detection – and in buggy commercial software.

Hierarchy diagram showing ETHREAD at top containing KTHREAD which holds a pointer to the user-mode TEB, which in turn contains NT_TIB and a PEB pointer
The three-layer thread model: ETHREAD owns security context, KTHREAD drives the scheduler, and the TEB is the user-mode window into both.

2. The NT_TIB: Stable Inner Structure

The very first member of every TEB is NT_TIB, defined in winnt.h and ntddk.h. A comment in the DDK notes it “appears as the first part of the TEB for all threads which have a user mode component.” Its layout has been stable for decades, which is why shellcode authors trust it.

typedef struct _NT_TIB {
    struct _EXCEPTION_REGISTRATION_RECORD *ExceptionList; // SEH chain head (x86)
    PVOID StackBase;                                       // top of committed stack
    PVOID StackLimit;                                      // current guard page
    PVOID SubSystemTib;
    union {
        PVOID FiberData;
        DWORD Version;
    };
    PVOID ArbitraryUserPointer;
    struct _NT_TIB *Self;   // linear address of this TIB (== TEB base)
} NT_TIB, *PNT_TIB;

The Self pointer is the linchpin. Code executing with a segment-register override reads this single field to obtain a flat pointer, then accesses the rest of the TEB without further overrides. ExceptionList roots the x86 SEH chain – on x64 SEH is table-based, so this field is typically NULL. StackBase and StackLimit define the committed range; the kernel adjusts StackLimit as the guard page is hit and the stack grows.

3. The Full _TEB: Key Fields

Microsoft’s public winternl.h header is famously unhelpful – twelve members named Reserved1 through Reserved6. The real field names come from debug symbols, Geoff Chappell’s documentation, and the phnt headers (System Informer project). Here are the security-relevant ones an analyst actually cares about:

FieldOffset (x64)TypePurpose
NtTib+0x000NT_TIBSEH chain, stack bounds, self-pointer
ClientId+0x040CLIENT_ID.UniqueProcess (PID) and .UniqueThread (TID)
ThreadLocalStoragePointer+0x058PVOIDPer-thread TLS data vector
ProcessEnvironmentBlock+0x060PPEBPointer to the process-wide PEB
LastErrorValue+0x068ULONGWhat GetLastError() actually reads
CountOfOwnedCriticalSections+0x06CULONGNumber of critical sections this thread holds
WOW32Reserved+0x100PVOIDWoW64 fast-syscall pointer
CurrentLocale+0x108LCIDGetThreadLocale() value
TlsSlots[64]+0xE10*PVOID[64]First 64 TLS slots (in-TEB)
TlsExpansionSlotsvariesPVOIDHeap-allocated block for slots 64-1088
SameTebFlagsvariesUSHORTBitfield: InitialThread, LoadOwner, LoaderWorker

*x86 offsets differ – TlsSlots starts at +0xE10 on x86 as well, but earlier field offsets are halved because pointers are 4 bytes.

Microsoft states that the TEB layout may change between Windows versions. Application code should use documented APIs (GetLastError, GetCurrentThreadId, TlsGetValue). For reverse engineering and analysis, however, you read the struct directly – just confirm offsets against the target build with dt ntdll!_TEB in WinDbg.

4. Segment Registers and Programmatic TEB Access

The CPU reaches the TEB through a segment-register base that the kernel sets per-thread during context switches.

ArchitectureRegisterTEB Self-PointerPEB PointerUnderlying mechanism
x86FSFS:[0x18]FS:[0x30]GDT entry updated on context switch
x64GSGS:[0x30]GS:[0x60]IA32_GS_BASE MSR (written via swapgs)

On x64 the kernel writes the TEB’s linear address into the IA32_GS_BASE MSR during every thread switch. User-mode code never needs to touch the MSR – it just reads through the GS override.

Intrinsics and NtCurrentTeb()

MSVC provides compiler intrinsics that avoid inline assembly entirely:

#include <windows.h>
#include <winternl.h>

void DumpTebBasics() {
    // x64 intrinsic — reads GS:[0x30] (NtTib.Self)
    PTEB pTeb = (PTEB)__readgsqword(0x30);

    // ClientId lives right after EnvironmentPointer
    DWORD tid = (DWORD)(ULONG_PTR)pTeb->ClientId.UniqueThread;
    DWORD pid = (DWORD)(ULONG_PTR)pTeb->ClientId.UniqueProcess;

    printf("TEB @ %p | PID %lu | TID %lu\n", pTeb, pid, tid);
    printf("PEB @ %p\n", pTeb->ProcessEnvironmentBlock);
    printf("LastError: %lu\n", pTeb->LastErrorValue);
}

NtCurrentTeb() is a macro on x64 that expands to the same __readgsqword(0x30) intrinsic. On x86 it is an exported function in ntdll.dll. Either way, the result is a flat TEB*. The Win32 wrappers GetCurrentThreadId() and GetLastError() are thin shims that read ClientId.UniqueThread and LastErrorValue from this pointer – there is no syscall involved.

Flow diagram tracing the path from the GS segment register through the TEB self-pointer to the PEB, then to PEB_LDR_DATA and the InLoadOrderModuleList used by shellcode
Every shellcode module walk starts here: GS register → TEB → PEB → Ldr → module list, entirely without Win32 API calls.

5. WinDbg Lab: Live TEB Inspection

Attach WinDbg to any user-mode process and try the following. The $teb pseudo-register points to the current thread’s TEB automatically.

!teb                            ; formatted dump — shows StackBase, StackLimit, PEB, TLS
dt ntdll!_TEB @$teb             ; raw struct with offsets
dt ntdll!_NT_TIB @$teb          ; just the inner TIB
dt ntdll!_PEB @$peb             ; follow the PEB pointer
dt ntdll!_PEB_LDR_DATA poi(@$peb+0x18)  ; Ldr from PEB
~                               ; list all threads
~1s                             ; switch to thread 1
!teb                            ; now shows thread 1's TEB

The first time I traced this chain manually, the jump from PEB_LDR_DATA to the module list felt like a leap of faith until I realized the LIST_ENTRY Flink values are pointers into LDR_DATA_TABLE_ENTRY, not to its base – off-by-one-struct-member errors will burn you if you compute DllBase from the wrong anchor. We’ll walk this explicitly in section 7.

6. Thread Local Storage Internals

TLS lets each thread maintain private copies of a variable. The mechanism maps directly into TEB fields.

TlsAlloc() grabs the next free bit from the PEB’s TlsBitmap (first 64 bits) or TlsExpansionBitmap (bits 64-1088) and returns a slot index. TlsSetValue(index, value) writes into TEB.TlsSlots[index] for indices 0-63, or into the heap block pointed to by TEB.TlsExpansionSlots for higher indices. TlsGetValue reads from the same location. No lock is needed because each thread writes to its own TEB.

The C/C++ __declspec(thread) storage class uses implicit TLS – the linker builds a .tls section, and the loader copies template data into each thread’s TLS array at creation time. Malware occasionally abuses TLS callbacks (IMAGE_TLS_DIRECTORY) for anti-debug or early initialization code that runs before the entry point.

7. The TEB→PEB Walk: Shellcode’s Favorite Primitive

Shellcode that needs to call Windows APIs – LoadLibraryA, VirtualAlloc, anything – must first find kernel32.dll‘s base address. Importing the function statically would defeat the purpose of position-independent code, so shellcode walks the TEB→PEB→Ldr chain instead:

; x64 — resolve kernel32.dll base via TEB -> PEB -> Ldr
; NASM syntax, Intel format
    mov rax, [gs:0x60]          ; TEB+0x60 = ProcessEnvironmentBlock (PEB*)
    mov rax, [rax + 0x18]       ; PEB+0x18 = Ldr (PEB_LDR_DATA*)
    mov rsi, [rax + 0x10]       ; Ldr+0x10 = InLoadOrderModuleList.Flink
    ; rsi -> first LDR_DATA_TABLE_ENTRY (the exe itself)
    mov rsi, [rsi]              ; -> second entry (ntdll.dll)
    mov rsi, [rsi]              ; -> third entry (kernel32.dll)
    mov rdi, [rsi + 0x30]       ; LDR_DATA_TABLE_ENTRY+0x30 = DllBase
    ; rdi now holds kernel32.dll's base address

From here the shellcode parses the PE export directory at DllBase to find GetProcAddress by hash, then resolves everything else. The entire sequence touches no import table and no Win32 API, which is why static analysis tools that only inspect the IAT will miss it entirely.

Why defenders care: any thread whose initial instruction pointer lands inside a PAGE_EXECUTE_READWRITE allocation and whose first memory reads target GS:[0x60] → PEB → Ldr is almost certainly running injected shellcode. Sysmon’s StartModule field being empty (no backing image) is the detection signal for this pattern.


8. Thread Execution Hijacking (T1055.003)

Thread execution hijacking avoids CreateRemoteThread entirely – no new thread is created, so Event ID 8 never fires. Instead, an attacker suspends an existing thread, redirects its instruction pointer into injected code, and resumes it.

The Primitive Step by Step

// 1. Open target thread — requires THREAD_SUSPEND_RESUME |
//    THREAD_GET_CONTEXT | THREAD_SET_CONTEXT
HANDLE hThread = OpenThread(
    THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT,
    FALSE, targetTid);

// 2. Suspend it
SuspendThread(hThread);

// 3. Capture register state
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(hThread, &ctx);
DWORD64 origRip = ctx.Rip;    // save for optional restoration

// 4. Allocate + write shellcode in the target process
//    (hProc obtained via OpenProcess with PROCESS_VM_OPERATION | PROCESS_VM_WRITE)
LPVOID remoteAddr = VirtualAllocEx(hProc, NULL, scSize,
    MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READ);
WriteProcessMemory(hProc, remoteAddr, shellcode, scSize, NULL);

// 5. Redirect execution
ctx.Rip = (DWORD64)remoteAddr;
SetThreadContext(hThread, &ctx);

// 6. Resume — shellcode runs in the context of the hijacked thread
ResumeThread(hThread);

Notice that step 4 uses PAGE_EXECUTE_READ, not PAGE_EXECUTE_READWRITE. RWX allocations are a well-known detection signal; writing first with PAGE_READWRITE and then flipping to PAGE_EXECUTE_READ via VirtualProtectEx is the cleaner tradecraft – and the harder telemetry to catch.

The detection challenge is real: no new thread handle appears, no remote-thread callback fires, and the hijacked thread’s stack looks normal to a casual observer. The tell is the OpenThread call requesting THREAD_SET_CONTEXT across a process boundary – Sysmon Event ID 10 can surface this if the access mask is logged.


Flow diagram of the five-step thread execution hijacking sequence from OpenThread through ResumeThread, with a detection branch showing Sysmon Event ID 10 triggered at the OpenThread stage
Thread hijacking leaves no new-thread event (EID 8 is blind); the only reliable telemetry hook is the cross-process THREAD_SET_CONTEXT handle request captured by Sysmon EID 10.

9. Common Attacker Techniques

TechniqueDescription
TEB→PEB module walkResolve API addresses without imports; foundation of all position-independent shellcode
CreateRemoteThread injectionAllocate + write + spawn a new thread in a remote process – the “classic” injection
NtCreateThreadEx injectionNative-API variant; bypasses some user-mode hooks on CreateRemoteThread
Thread execution hijackingSuspend → redirect RIP → resume; avoids new-thread creation entirely
QueueUserAPC injectionQueue an APC to a thread in an alertable wait; runs payload on next alert
TLS callback abuseRegister a callback in IMAGE_TLS_DIRECTORY; code runs before main/entry point
PEB.BeingDebugged checkRead TEB→PEB→BeingDebugged (offset +0x02 in PEB) for anti-debug evasion

MITRE ATT&CK Mapping

TechniqueMITRE IDDetection Anchor
Process Injection (parent)T1055Sysmon EID 8, EID 10
Thread Execution HijackingT1055.003EID 10 – THREAD_SET_CONTEXT cross-process
Debugger EvasionT1622PEB.BeingDebugged read via TEB walk
Native APIT1106NtCreateThreadEx / NtOpenThread direct syscalls
Process DiscoveryT1057PEB Ldr module list enumeration

10. Defensive Strategies & Detection

Sysmon Events

Event IDNameWhat It Catches
8CreateRemoteThreadNew threads spawned cross-process. Key fields: SourceImage, TargetImage, StartAddress, StartModule, StartFunction. An empty StartModule means the start address is outside any loaded image – shellcode.
10ProcessAccessOpenProcess / OpenThread with suspicious GrantedAccess. Watch for 0x0010 (THREAD_SET_CONTEXT) targeting threads in lsass.exe, svchost.exe, etc.
1ProcessCreateBaseline parent-child relationships; flag unexpected parents launching injection tooling.

Limitation: Sysmon’s Event ID 8 hooks PsSetCreateThreadNotifyRoutine – it only fires when a new thread is created remotely. Thread execution hijacking (T1055.003) creates no new thread, so EID 8 alone will not catch it. You need EID 10 for the handle-access telemetry.

Sigma Rule – Thread Hijacking Detection

title: Cross-Process Thread Context Manipulation
logsource:
  product: windows
  service: sysmon
detection:
  selection:
    EventID: 10
    GrantedAccess|contains:
      - '0x0010'     # THREAD_SET_CONTEXT
      - '0x001FFFFF' # THREAD_ALL_ACCESS
    TargetImage|endswith:
      - '\lsass.exe'
      - '\svchost.exe'
      - '\csrss.exe'
  filter:
    SourceImage|endswith:
      - '\MsMpEng.exe'
      - '\csrss.exe'
  condition: selection and not filter
level: high

ETW Providers

ProviderUse
Microsoft-Windows-Kernel-ProcessThread create/terminate events; exposes TID, StartAddr
Microsoft-Windows-Threat-Intelligence (PPL-protected)High-fidelity injection telemetry consumed by EDRs via ELAM drivers

Windows Security Audit

  • Event 4656 (Handle Requested): With object-access SACLs on sensitive processes, logs every NtOpenThread requesting THREAD_SET_CONTEXT.
  • Event 4688 (Process Creation): Full command-line logging to correlate injection chains.

Hardening

  1. Deploy Sysmon with a tuned config – capture all EID 8 and EID 10 events; filter on empty StartModule (shellcode indicator).
  2. Enable AuditObjectAccess SACLs on lsass.exe and other critical processes to generate Event 4656 on suspicious handle requests.
  3. Protected Process Light (PPL) – PPL-protected processes deny OpenThread with THREAD_SET_CONTEXT from unprotected callers.
  4. Windows Defender Credential Guard – isolates LSASS secrets so thread-context manipulation cannot dump credentials.
  5. HVCI (Memory Integrity) – prevents unsigned kernel code from unhooking PsSetCreateThreadNotifyRoutine callbacks.

11. Tools for Thread & TEB Analysis

ToolPurposeLink
WinDbg!teb, dt ntdll!_TEB, register inspection, kernel-mode !threadlearn.microsoft.com
Process Hacker / System InformerPer-thread TEB address, stack, start address, tokensysteminformer.com
Process MonitorReal-time thread create/terminate events with stackslearn.microsoft.com/sysinternals
x64dbgUser-mode debugger; TEB visible in SEH/Memory tabx64dbg.com
Volatility 3windows.threads / windows.handles plugins for memory forensicsvolatilityfoundation.org
GhidraStatic analysis of TEB-walking shellcode and TLS callbacksghidra-sre.org
phnt headersFull _TEB definition with named fields (System Informer project)github.com/winsiderss/phnt

12. Summary

  • The TEB is a per-thread, user-mode structure the kernel maps before a thread’s first instruction executes. It backs every GetLastError, GetCurrentThreadId, and TLS access – no syscall required.
  • NT_TIB is its stable core – SEH chain root, stack bounds, and the self-pointer that converts a segment-register read into a flat address.
  • Segment registers (FS on x86, GS on x64) point to the TEB; the kernel updates IA32_GS_BASE on every context switch.
  • The TEB→PEB→Ldr walk is the foundation of shellcode API resolution – defenders should flag execution from non-module memory that reads GS:[0x60].
  • Thread execution hijacking (T1055.003) bypasses CreateRemoteThread detection entirely; Sysmon Event ID 10 with THREAD_SET_CONTEXT access-mask filtering is the primary catch.

Related Tutorials

References

Get new drops in your inbox

Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.