TLS Callbacks: Thread Local Storage and Early Execution Hooks

By Debraj Basak·Oct 16, 2026·15 min readWindows Internals

Objective: Understand how the Windows PE loader invokes IMAGE_TLS_CALLBACK functions before the process entry point, how to author and inspect them with MSVC and MinGW-w64, how attackers weaponize them for anti-debug and process-injection, and how defenders catch the abuse with static analysis, Sysmon, ETW, and memory forensics.


Most reversers set their first breakpoint at main or the PE entry point and feel in control. They aren’t. By the time either of those addresses executes, every TLS callback in the image has already run on the initializing thread, with full access to the process address space, from inside loader lock, with no debugger prompt. That is the entire reason malware authors like TLS callbacks: the hook sits a layer below where most analysts start looking.

This post walks the mechanism top to bottom. We’ll pull apart IMAGE_TLS_DIRECTORY, write our own callbacks under both MSVC and MinGW-w64, trace the loader in WinDbg to prove they fire before main, build an anti-debug stub, and sketch the TLS variant of process hollowing against a lab target. Then we hunt it: dumpbin, Volatility, Sysmon IDs 1/8/10, an ETW provider worth enabling, and a Sigma rule for the hollow-plus-TLS pattern.


1. Thread Local Storage, Briefly

TLS is per-thread private storage. Two threads reading the “same” variable see different values. Windows exposes it two ways:

  • Dynamic TLS – request a slot at runtime, use it from any thread:
  • TlsAlloc() returns a slot index
  • TlsSetValue(idx, ptr) writes the current thread’s slot
  • TlsGetValue(idx) reads it back
  • TlsFree(idx) releases the slot
  • Static TLS – declare at compile time with __declspec(thread) (MSVC) or __thread (GCC). The compiler emits a .tls section and populates IMAGE_TLS_DIRECTORY in the PE automatically.

The runtime anchor for all of this is the TEB. Each thread’s TEB holds a pointer to its TLS array, and that array holds pointers to the actual per-thread data blocks. When the loader creates a thread, it allocates and copies the static TLS template from the PE for that thread, then runs the TLS callbacks. Nothing in a TLS callback requires that TLS data be in use. The callback array piggybacks on the same directory and that is the whole trick.


2. The .tls Section and IMAGE_TLS_DIRECTORY

Data Directory index 9 of the PE optional header points at the TLS directory. The structure in winnt.h has 32- and 64-bit forms:

typedef struct _IMAGE_TLS_DIRECTORY64 {
    ULONGLONG   StartAddressOfRawData;   // VA: start of static TLS template
    ULONGLONG   EndAddressOfRawData;     // VA: end of template
    ULONGLONG   AddressOfIndex;          // VA of DWORD TLS index (__tls_index)
    ULONGLONG   AddressOfCallBacks;      // VA of NULL-terminated callback array
    DWORD       SizeOfZeroFill;          // zero-fill bytes beyond raw data
    DWORD       Characteristics;         // bits [23:20] = alignment, rest reserved
} IMAGE_TLS_DIRECTORY64, *PIMAGE_TLS_DIRECTORY64;

The 32-bit variant swaps ULONGLONG for DWORD, otherwise it is identical. IMAGE_TLS_DIRECTORY resolves to the right one via #ifdef _WIN64.

Field-by-field:

FieldMeaning
StartAddressOfRawDataVA where the initialized static TLS template begins
EndAddressOfRawDataVA where the initialized portion of the template ends
AddressOfIndexVA of a DWORD the loader fills in with the runtime TLS slot index
AddressOfCallBacksVA of a null-terminated array of PIMAGE_TLS_CALLBACK function pointers
SizeOfZeroFillExtra zero-initialized bytes appended to the raw data per thread
CharacteristicsBits 23:20 encode alignment with IMAGE_SCN_ALIGN_* values; the rest is reserved

One detail trips people up every time: these are absolute Virtual Addresses, not RVAs. If you hand-craft a PE with a TLS directory, every one of those addresses has to work after the image is relocated. That means a .reloc entry for each of them. Miss that and the loader will happily dereference whatever garbage ends up at the baked-in VA. On ASLR-enabled binaries this almost always ends in 0xC0000005.

An empty callback list is still a valid list, it’s an array of one element containing NULL.


Hierarchy diagram of IMAGE_TLS_DIRECTORY64 fields showing the callback array pointer resolving to a null-terminated list of absolute virtual addresses
Every pointer in IMAGE_TLS_DIRECTORY64 is an absolute VA requiring a .reloc entry – not an RVA like most PE fields.

3. The Callback Prototype

typedef VOID (NTAPI *PIMAGE_TLS_CALLBACK)(
    PVOID DllHandle,   // module base of the image owning the callback
    DWORD Reason,      // same codes as DllMain's fdwReason
    PVOID Reserved     // NULL for static TLS
);

The notification codes come straight from DllMain:

ValueConstantWhen it fires
1DLL_PROCESS_ATTACHProcess initialization. Callbacks run on the init thread, before the EXE entry point.
2DLL_THREAD_ATTACHEvery new thread the process creates after init.
3DLL_THREAD_DETACHThread exits cleanly.
0DLL_PROCESS_DETACHProcess is tearing down.

A couple of things that look obvious but aren’t:

  • Registering a callback does not spawn a thread. The DLL_PROCESS_ATTACH call executes synchronously on the thread that is currently initializing the process.
  • All of this happens inside loader lock. Calling LoadLibrary, waiting on another thread, or doing anything that re-enters the loader is a recipe for deadlock. Microsoft’s guidance for DllMain applies here one-to-one.
  • The order of callbacks within the array is the order they’re stored. If you register two callbacks via .CRT$XLB and .CRT$XLC, XLB fires first because the linker sorts section names lexicographically.

4. Loader Flow: What Runs Before main

The sequence from the moment the kernel hands control to ntdll until your main executes is roughly:

ntdll!LdrInitializeThunk
  -> LdrpInitialize
    -> LdrpInitializeProcess
       -> map dependent DLLs, resolve imports, snap IAT
       -> process static TLS for every image with a TLS directory
       -> LdrpCallTlsInitializers(DLL_PROCESS_ATTACH)   // *** here ***
    -> return to kernel32 startup stub
      -> CRT: _mainCRTStartup / __scrt_common_main_seh
        -> main()  or  WinMain()

The symbol name LdrpCallTlsInitializers is internal and unexported; it has shifted between Windows versions. On a given host, confirm with WinDbg:

0:000> x ntdll!*Tls*

Look for LdrpCallTlsInitializers, LdrpInitializeTls, or whatever the current build calls it, then set your breakpoint there. The important invariant is unchanged: the call lives inside LdrpInitializeProcess and executes before control reaches the EXE entry point.


Flowchart showing Windows loader execution sequence from LdrInitializeThunk through LdrpCallTlsInitializers and TLS callbacks before reaching main()
TLS callbacks fire inside LdrpInitializeProcess, well before the CRT startup stub hands off to main().

5. Lab: Writing Your First TLS Callback

5.1 MSVC x64

Save as tls_basic.c:

// tls_basic.c  -  MSVC x64
// Build: cl /nologo tls_basic.c /link /INCLUDE:_tls_used
#include <windows.h>
#include <stdio.h>

void NTAPI TlsCallback_A(PVOID hModule, DWORD dwReason, PVOID pContext);

#pragma data_seg(".CRT$XLB")
PIMAGE_TLS_CALLBACK _tls_cb_a = TlsCallback_A;
#pragma data_seg()
#pragma comment(linker, "/INCLUDE:_tls_used")

void NTAPI TlsCallback_A(PVOID hModule, DWORD dwReason, PVOID pContext) {
    if (dwReason == DLL_PROCESS_ATTACH) {
        printf("[TLS] Callback A fired - BEFORE main(). hModule=0x%p\n", hModule);
    }
}

int main(void) {
    printf("[main] Entry point reached.\n");
    return 0;
}

Two things make this work. .CRT$XLB is the section the CRT concatenates into __xl_a .. __xl_z, which forms the callback array. /INCLUDE:_tls_used forces the linker to pull in _tls_used (the actual IMAGE_TLS_DIRECTORY) even though your C code never references it, which otherwise gets stripped and silently drops your directory from the PE. Run it:

C:\lab> tls_basic.exe
[TLS] Callback A fired - BEFORE main(). hModule=0x00007FF6A1C40000
[main] Entry point reached.

5.2 MinGW-w64

Same idea, different incantation. Save as tls_order.c:

// tls_order.c  -  MinGW-w64 x64
// Build: gcc tls_order.c -o tls_order.exe
#include <windows.h>
#include <stdio.h>

void NTAPI cb_A(PVOID h, DWORD r, PVOID p) {
    if (r == DLL_PROCESS_ATTACH) printf("[TLS] Callback A\n");
}
void NTAPI cb_B(PVOID h, DWORD r, PVOID p) {
    if (r == DLL_PROCESS_ATTACH) printf("[TLS] Callback B\n");
}

__attribute__((section(".CRT$XLB"))) PIMAGE_TLS_CALLBACK _cb_a = cb_A;
__attribute__((section(".CRT$XLBB"))) PIMAGE_TLS_CALLBACK _cb_b = cb_B;

int main(void) {
    printf("[main] Reached main()\n");
    return 0;
}

Output:

[TLS] Callback A
[TLS] Callback B
[main] Reached main()

XLB sorts before XLBB, so A fires first. The sort key is the subsection suffix. If you want deterministic ordering, name your subsections deliberately and don’t rely on link order.

5.3 Verify the directory got written

C:\lab> dumpbin /TLS tls_basic.exe
...
Section contains the following TLS directory:

    0000000140004000 Start of raw data
    0000000140004000 End of raw data
    0000000140007040 Index
    0000000140007020 Address of callbacks
    0           Size of zero fill
    0           Characteristics

    Callbacks:

    0000000140001090

A single callback pointer at 0x140001090, exactly where our TlsCallback_A compiled. If dumpbin /TLS shows nothing, you forgot /INCLUDE:_tls_used or your pointer sat in a section the linker garbage-collected.


6. Catching the Callback in WinDbg

Running under a debugger that breaks on the EXE entry point is useless here. By the time you’re sitting at EP, the callback already executed. Launch the target under WinDbg with a deferred breakpoint on the loader:

windbg -c "sxe ld tls_basic; bu ntdll!LdrpCallTlsInitializers; g" tls_basic.exe

When it stops:

0:000> !dh -f tls_basic
...
            9000 [          28] address [size] of TLS Directory

0:000> dt ntdll!_IMAGE_TLS_DIRECTORY64 00007ff6`a1c49000
   +0x000 StartAddressOfRawData : 0x00007ff6`a1c44000
   +0x008 EndAddressOfRawData   : 0x00007ff6`a1c44000
   +0x010 AddressOfIndex        : 0x00007ff6`a1c47040
   +0x018 AddressOfCallBacks    : 0x00007ff6`a1c47020
   +0x020 SizeOfZeroFill        : 0
   +0x024 Characteristics       : 0

0:000> dq 0x00007ff6`a1c47020 L2
00007ff6`a1c47020  00007ff6`a1c41090 00000000`00000000

0:000> bp 00007ff6`a1c41090 ".echo callback entered; k 3; g"
0:000> g
callback entered
 # Child-SP          RetAddr               Call Site
00 0000006a`...  00007ffa`...     tls_basic!TlsCallback_A
01 0000006a`...  00007ffa`...     ntdll!LdrpCallTlsInitializers+0xXX
02 0000006a`...  00007ffa`...     ntdll!LdrpInitializeProcess+0xXXXX

You’re now standing on the callback body with a stack rooted in LdrpInitializeProcess. For routine triage, x64dbg has this baked in: Options -> Preferences -> Events -> “TLS Callbacks”, restart the target, and it will break on each callback before EP.


7. Weaponization 1: Anti-Debug in a TLS Callback

Any check you’d normally do at process start, malware runs here instead. The callback owns the process address space before any user code or any late-attached analysis tool gets a say.

// tls_antidebug.c  -  MSVC x64
// Build: cl /nologo tls_antidebug.c /link /INCLUDE:_tls_used
#include <windows.h>
#include <stdio.h>

void NTAPI TlsCallback_AntiDebug(PVOID hMod, DWORD dwReason, PVOID pCtx) {
    if (dwReason != DLL_PROCESS_ATTACH) return;

    // PEB->BeingDebugged (same bit IsDebuggerPresent reads)
    if (IsDebuggerPresent()) {
        ExitProcess(0xDEAD);
    }

    // Later you'd also probe ProcessDebugPort via NtQueryInformationProcess
    // (class 7). If non-zero, a user-mode debugger is attached.

    printf("[TLS] No debugger. Proceeding.\n");
}

#pragma data_seg(".CRT$XLB")
PIMAGE_TLS_CALLBACK _tls_anti = TlsCallback_AntiDebug;
#pragma data_seg()
#pragma comment(linker, "/INCLUDE:_tls_used")

int main(void) { printf("[main] Running.\n"); return 0; }

Launched normally, you’ll see [main] Running. Launched under a plain WinDbg attach-at-start, the process dies with exit code 0xDEAD and no visible reason. The fix for an analyst is the same every time: break earlier, patch the PEB.

0:000> bu ntdll!LdrpCallTlsInitializers
0:000> g
Breakpoint 0 hit
0:000> r $peb
$peb=00000000`...
0:000> dt ntdll!_PEB 00000000`... BeingDebugged
   +0x002 BeingDebugged : 0x1 ''
0:000> eb @$peb+2 0
0:000> g
[TLS] No debugger. Proceeding.

Clear the byte at offset 2, let the callback run, you walk right past the check. The first time I did this I spent an hour certain my #pragma was wrong because the process kept dying silently. It wasn’t. The callback was doing exactly what I told it to; I just hadn’t broken early enough to see it.

For ProcessDebugPort, patch returns from NtQueryInformationProcess or zero out the output buffer in the callback’s own call. ScyllaHide and TitanHide automate the common set.


Conceptual illustration of anti-debug code hiding inside a TLS callback before the debugger can attach
Anti-debug checks inside a TLS callback execute before any analyst-controlled breakpoint at the entry point, leaving the process silently dead.

8. Weaponization 2: TLS Callback Injection (Lab Only)

TLS callbacks pair naturally with process hollowing because the loader re-runs them when the new image’s EP is dispatched. The sequence against a lab EXE (target.exe, a program that just loops on Sleep):

RECON
  1. Parse target.exe headers (pefile / PE-bear). Confirm Data Directory 9
     (TLS) is empty - no existing callbacks to preserve.

HOLLOW
  2. CreateProcessA("target.exe", NULL, NULL, NULL, FALSE,
                   CREATE_SUSPENDED, NULL, NULL, &si, &pi);
  3. NtUnmapViewOfSection(pi.hProcess, remoteImageBase);
  4. VirtualAllocEx(pi.hProcess, remoteImageBase, payloadSize,
                   MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

CRAFT
  5. Build a minimal PE in memory whose IMAGE_TLS_DIRECTORY points
     AddressOfCallBacks at an array [callback_va, 0]. The callback body is
     a benign lab payload - MessageBoxA(NULL, "TLS fired", "lab", 0) or
     a ShellExecute of calc.exe. Fix up .reloc entries so the VAs survive
     relocation.

WRITE
  6. WriteProcessMemory(pi.hProcess, remoteImageBase, maliciousPE,
                       payloadSize, NULL);
  7. GetThreadContext(pi.hThread, &ctx);
     ctx.Rcx = newEntryPointVA;      // x64 ABI: first arg = EP for ntdll
     SetThreadContext(pi.hThread, &ctx);

TRIGGER
  8. ResumeThread(pi.hThread);
     -> Loader finishes init on the new image.
     -> LdrpCallTlsInitializers walks AddressOfCallBacks.
     -> Our callback runs with DLL_PROCESS_ATTACH, before new EP.
     -> MessageBox pops up from inside what looks like target.exe.

VERIFY
  9. Before step 8, attach WinDbg to pi.hProcess, break on the callback VA,
     confirm RSP sits inside ntdll!LdrpCallTlsInitializers.

Why bother with the callback instead of just pointing the thread’s RIP at your shellcode? Two reasons: the callback runs even if later analysis tooling re-redirects RIP (the loader is going to call it either way during init), and from the host EXE’s perspective, nothing about the hijacked EP looks out of place; the suspicious code hides inside the TLS directory where many sandboxes and quick triage flows don’t look. Mandiant documented an Ursnif variant that used exactly this shape to seed its first stage.

Keep the payload benign in the lab. Everything above is the technique; weaponizing it against real production hosts is not what we’re teaching.


9. Static and Dynamic Analysis Cheatsheet

ToolUse
dumpbin /TLSFirst check on any suspect binary; prints the directory and callback addresses
PE-bear / CFF ExplorerGUI inspection of Data Directory 9, section characteristics, relocations
IDA ProAuto-names callbacks TlsCallback_0, TlsCallback_1, listed in Exports/Functions
Ghidra.tls section visible; callbacks appear in Symbol Tree under TLS
WinDbgbu ntdll!LdrpCallTlsInitializers, then dt _IMAGE_TLS_DIRECTORY64 on the directory VA
x64dbgOptions -> Preferences -> Events -> “TLS Callbacks” breaks before EP
Volatility 3windows.callbacks plugin enumerates in-memory callback tables
TLSCheck 2.0Research memory-forensics tool specifically for TLS callback abuse

A sane triage habit: always dumpbin /TLS before you double-click. If the directory is populated on something that shouldn’t have one (a C# compiled binary, a packed-looking native EXE from an unexpected author), that’s your first lead, not the entry point.


10. Detection and Defense

TLS callbacks are legitimate infrastructure. Normal CRTs, Rust’s static initializers, instrumented binaries, and plenty of commercial software ship with populated TLS directories. The signal is never “has a TLS callback” alone. The signal is a TLS directory that showed up where you didn’t expect one, or an injection shape where a cross-process write is followed by resume into a hollowed image.

10.1 Sysmon

Event IDNameWhy it matters here
1ProcessCreateBaseline parent/child; flag suspended children of unusual parents
7ImageLoadUnsigned or anomalous modules loaded very early in init
8CreateRemoteThreadCore signal for the thread leg of hollowing / injection chains
10ProcessAccessPROCESS_VM_WRITE | PROCESS_VM_OPERATION on a target you don’t legitimately write to
13Registry value setPost-execution persistence that follows the TLS stage

10.2 ETW providers worth enabling

ProviderWhy
Microsoft-Windows-Kernel-ProcessFull process/thread lifecycle, catches early thread activity
Microsoft-Windows-Threat-IntelligencePPL-only. Covers VirtualAllocEx and WriteProcessMemory across processes – the exact primitives above
Microsoft-Antimalware-Scan-InterfaceAMSI content, caller, verdict – useful if the TLS stage decrypts a script payload

Threat Intelligence is PPL-protected, so your consumer has to be a protected process; Defender for Endpoint subscribes to it by default. If you’re building your own stack, this is the richest telemetry you’ll get for cross-process memory writes short of a kernel driver.

10.3 Sigma: hollowing plus TLS

title: Suspicious Process Injection via TLS Callback (Hollow then Resume)
status: experimental
logsource:
    product: windows
    service: sysmon
detection:
    cross_proc_write:
        EventID: 10
        GrantedAccess|contains:
            - '0x1FFFFF'   # PROCESS_ALL_ACCESS
            - '0x143A'     # VM_WRITE | VM_OPERATION | CREATE_THREAD
    remote_thread:
        EventID: 8
        SourceImage|endswith:
            - '\notepad.exe'
            - '\svchost.exe'
        TargetImage|endswith:
            - '\notepad.exe'
            - '\svchost.exe'
    condition: cross_proc_write and remote_thread
level: high
tags:
    - attack.t1055.005
    - attack.t1055.012
    - attack.defense_evasion

Pivot on StartModule being null in Sysmon ID 8, that is “thread started at raw memory, no module backing it” and is a strong indicator the resume landed in freshly allocated pages, which is what hollowing produces.

10.4 Hardening

  • Enable the Threat Intelligence ETW provider through a PPL consumer (Defender for Endpoint or your own PPL-signed collector).
  • WDAC / Code Integrity policies that require signed PE execution eliminate the trivial case where the injected image is unsigned.
  • CI/CD check: run dumpbin /TLS on every first-party binary; alert on any that unexpectedly grow a TLS directory between builds.
  • Memory-forensics baseline: periodically run Volatility windows.callbacks against known-good images; diff AddressOfCallBacks arrays.
  • Defender ASR rule d1e49aac-8f56-4280-b9ba-993a6d77406c reduces one of the common hollowing delivery vectors (PsExec/WMI-spawned processes).

Graph diagram mapping TLS hollowing attack steps to corresponding Sysmon event IDs, ETW provider, and Volatility plugin detections
No single signal catches the full chain – layer Sysmon ID 10 on the write, ID 8 on the resume, and Volatility for post-mortem callback table inspection.

11. MITRE ATT&CK Mapping

TechniqueIDDetection focus
Process InjectionT1055Parent technique; cross-process memory writes + thread control
Process Injection: Thread Local StorageT1055.005TLS directory present where unexpected; callback executes before EP
Process Injection: Process HollowingT1055.012Suspended child + NtUnmapViewOfSection + VirtualAllocEx + resume
Obfuscated Files or InformationT1027Unpacking/decryption performed inside the TLS callback
Debugger EvasionT1622IsDebuggerPresent, NtQueryInformationProcess checks inside TLS

Summary

  • TLS callbacks are the Windows loader’s pre-entry hook, called from LdrpCallTlsInitializers inside LdrpInitializeProcess before main or WinMain ever runs.
  • The hook lives in IMAGE_TLS_DIRECTORY.AddressOfCallBacks, a null-terminated array of PIMAGE_TLS_CALLBACK function pointers using absolute VAs that must be relocated.
  • Registering one is a two-line job: #pragma data_seg(".CRT$XLB") + /INCLUDE:_tls_used under MSVC, or __attribute__((section(".CRT$XLB"))) under MinGW-w64.
  • Attackers use the window for anti-debug, in-place unpacking, and the TLS flavor of process hollowing (ATT&CK T1055.005, often paired with T1055.012).
  • Triage with dumpbin /TLS before you ever hit the entry point; hunt live with Sysmon IDs 1, 8, and 10, the Threat Intelligence ETW provider, and Volatility’s windows.callbacks.

Related Tutorials

References

Get new drops in your inbox

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