Thread Internals
The kernel ETHREAD structure, the Thread Environment Block, thread states and context, how execution flows across scheduling, and why threads are the primary injection target for malware
Process injection via CreateRemoteThread is one of the most common techniques in the MITRE ATT&CK framework (T1055.003). The API takes a target process handle, a start address, and a parameter — and the kernel creates a new thread in the target process at that address. Understanding what a thread actually is at the kernel level — ETHREAD, TEB, thread context, thread states — explains why this works, what it looks like in EDR telemetry, and how variations like APC injection differ.
ETHREAD — The Kernel Thread Structure
Every thread has a kernel object called ETHREAD (Executive Thread). Like EPROCESS, it lives in kernel memory. ETHREAD contains everything the kernel needs to manage, schedule, and identify a thread.
ETHREAD Key Layout (simplified, Windows 10/11 x64)
─────────────────────────────────────────────────────────────────────
+0x000 Tcb (KTHREAD — scheduler-level thread object)
└─ Stack pointer, context, scheduling state, APC queues
+0x498 CreateTime (LARGE_INTEGER — when thread was created)
+0x4A0 ExitTime (LARGE_INTEGER)
+0x4A8 ExitStatus (LONG)
+0x4B0 ThreadsProcess (PEPROCESS — back-pointer to parent EPROCESS)
+0x4B8 StartAddress (PVOID — the function the thread started executing)
+0x508 Cid (CLIENT_ID: UniqueProcess + UniqueThread = PID+TID)
+0x538 Win32StartAddress (PVOID — the Win32 start address, may differ)
+0x560 ThreadName (PUNICODE_STRING — optional thread name)
The Win32StartAddress field is particularly important for detection: it records the thread's start address as reported to Win32. Injection techniques often create threads with a start address in shellcode or an unusual DLL, making Win32StartAddress fall outside any known module's address range — a reliable sign of injection.
Thread Environment Block (TEB)
The Thread Environment Block (TEB) is the user-mode per-thread data structure, analogous to what the PEB is for processes. Every thread has its own TEB in user-mode address space. The TEB is always accessible via:
- 32-bit:
fs:[0x00]→ TEB address (self-pointer) - 64-bit:
gs:[0x30]→ TEB address (self-pointer, though gs:[0x00] also works)
// Access TEB in C
PTEB pTeb = NtCurrentTeb(); // SDK macro
// Manual access in 64-bit:
PTEB pTeb = (PTEB)__readgsqword(0x30);
TEB Key Fields
TEB Key Fields (x64, Windows 10/11)
─────────────────────────────────────────────────────────────────────
+0x000 NtTib (NT_TIB — Thread Information Block)
+0x000 ExceptionList (top of SEH chain, legacy x86)
+0x008 StackBase (top of stack — highest address)
+0x010 StackLimit (bottom of stack — lowest address)
+0x018 SubSystemTib (fiber data when using fibers)
+0x028 ArbitraryUserPointer
+0x030 Self (TEB self-pointer at gs:[0x30])
+0x038 EnvironmentPointer
+0x040 ClientId (CLIENT_ID: ProcessId + ThreadId)
+0x060 Peb (PPEB — pointer to the process PEB)
+0x100 LastErrorValue (ULONG — GetLastError() reads this)
+0x1480 TlsSlots (void*[64] — TLS slot array, first 64 TLS indices)
+0x1778 TlsExpansionSlots (pointer to extended TLS if > 64 slots)
+0x02C0 DeallocationStack (PVOID — base of stack allocation for cleanup)
+0x17C8 RealClientId (thread's actual PID/TID for WOW64 processes)
| TEB Field | Security / Malware Relevance |
|---|---|
NtTib.StackBase / StackLimit |
Stack boundaries. Used to detect stack pivoting (when the stack pointer is redirected outside these bounds — a sign of ROP exploitation) and to validate return addresses. |
LastErrorValue |
GetLastError() reads directly from this field. Shellcode doesn't need to call SetLastError/GetLastError — it can read/write this directly to check operation results. |
ClientId |
Contains the PID and TID of this thread. Accessible without a system call — a quick way for shellcode to identify itself. |
Peb |
Pointer to the process's PEB. Alternative access path — if you have the TEB, you have the PEB. |
TlsSlots |
Thread-local storage values. Position 0 through 63 in the inline array; slots above 63 in the TlsExpansionSlots extension. C++ thread_local variables map to these slots. |
Thread Scheduling States
A thread transitions through a set of states managed by the kernel scheduler (KTHREAD.State):
Thread State Machine
─────────────────────────────────────────────────────────────────────
Initialized ──► Ready ──► Running ──► Standby ──► (back to Running)
▲ │
│ ▼
└─── Waiting ◄──── I/O, Sleep, Mutex, Alert
│
▼
Transition (waiting for page-in)
│
▼
Terminated
States:
Ready — eligible for scheduling, waiting for a CPU
Standby — selected as next to run on a specific processor
Running — currently executing on a CPU
Waiting — blocked (I/O, sleep, synchronization object wait)
Transition — waiting for its kernel stack to be paged in
Terminated — thread function returned, cleanup pending
Thread Context
The thread context is the snapshot of CPU register state for a thread. When a thread is switched off a CPU, its context (all general-purpose registers, instruction pointer, stack pointer, flags) is saved to a CONTEXT structure. When the thread is scheduled back on, this context is restored.
User-mode code can read and write a thread's context (while it's suspended) via GetThreadContext / SetThreadContext. This is how debuggers implement breakpoints and single-step execution. It's also how process hollowing works — the primary thread is suspended, its context is modified to point to injected code, and then resumed.
// Read and modify a suspended thread's context
#include <windows.h>
void HijackThread(HANDLE hThread, LPVOID pShellcode) {
SuspendThread(hThread);
CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(hThread, &ctx);
// Save original RIP and redirect to shellcode
ULONGLONG original_rip = ctx.Rip;
ctx.Rip = (ULONGLONG)pShellcode;
// Optionally adjust stack pointer (RSP) if shellcode needs alignment
ctx.Rsp &= ~0xFULL; // 16-byte align
SetThreadContext(hThread, &ctx);
ResumeThread(hThread);
// Thread now executes shellcode; shellcode must eventually return to original_rip
}
Fibers
Fibers are user-mode cooperative threads — they share a thread's TEB but have their own stack and register context. The OS scheduler doesn't know about fibers; the programmer manually switches between them with SwitchToFiber(). From the kernel's perspective, all fibers in a thread look like one thread.
Malware uses fibers as a shellcode execution mechanism: allocate a fiber with shellcode as its start address, switch to it. The shellcode runs without creating a new thread (avoiding thread creation EDR telemetry). The limitation is that shellcode running in a fiber still runs in the same thread context — it inherits the current thread's token and can only run when that thread explicitly switches to the fiber.
Thread-Based Malware Patterns
| Technique | API Used | Detection Signal |
|---|---|---|
| Remote thread injection | CreateRemoteThread |
Thread start address outside any loaded module; ETHREAD.Win32StartAddress in unrelated memory region |
| Thread context hijacking | SuspendThread + SetThreadContext + ResumeThread |
Legitimate thread IP suddenly jumps to allocated memory; Suspend+Set+Resume sequence on another process's thread |
| NtCreateThreadEx (direct) | NtCreateThreadEx |
Same as CreateRemoteThread but bypasses some userland hooks; kernel callback still fires |
| Fiber execution | CreateFiber + ConvertThreadToFiber + SwitchToFiber |
No thread creation event; only detectable through memory inspection or API monitoring |
Thread pool abuse (RtlQueueWorkItem) |
RtlQueueWorkItem / TpAllocWork |
Shellcode queued to thread pool; thread pool threads are pre-existing so no thread creation event |
Q & A
Why does CreateRemoteThread require both VirtualAllocEx and WriteProcessMemory first?
CreateRemoteThread takes a start address — a virtual address in the target process where the new thread should begin execution. You need to get your shellcode or DLL loader stub into the target process's virtual address space first. The sequence is: (1) VirtualAllocEx: allocates memory in the target process's virtual address space, returning an address in that process where your code will live. (2) WriteProcessMemory: writes your shellcode or loader stub into that allocated memory. (3) CreateRemoteThread: creates a new thread in the target process with its start address pointing to the memory you just wrote. The thread then executes your code in the context of the target process, with access to the target's memory, handles, and security token. The three-function sequence is a canonical detection pattern — any process performing all three on a different process is almost certainly injecting code.
What is the difference between a thread and a fiber from a security perspective?
From a security monitoring perspective, the key difference is visibility: (1) Threads: the kernel knows about every thread. Thread creation fires PsSetCreateThreadNotifyRoutine callbacks, which EDR drivers register. Sysmon Event ID 8 (CreateRemoteThread) is generated by these callbacks. The thread appears in NtQuerySystemInformation output, has an ETHREAD structure, and is listed in process thread enumerations. (2) Fibers: the kernel has no concept of fibers — they're entirely managed in user mode by kernel32.dll. No kernel callback fires when a fiber is created or switched to. No event appears in ETW from kernel callbacks. The only way to detect fiber-based shellcode execution is through user-mode API monitoring (fiber creation APIs), memory scanning for shellcode patterns in the fiber's stack/registers, or behavioral analysis of what the fiber actually does. This makes fibers attractive as an injection/execution mechanism for evasion, though the practical limitation (cooperative scheduling, requires the fiber's host thread to yield) makes them less reliable for persistent implants.