Chapter 14

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

Scenario

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:

// 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 FieldSecurity / 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

TechniqueAPI UsedDetection 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.