TLS Callbacks: Thread Local Storage and Early Execution Hooks
Objective: Understand how the Windows PE loader invokes
IMAGE_TLS_CALLBACKfunctions 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 indexTlsSetValue(idx, ptr)writes the current thread’s slotTlsGetValue(idx)reads it backTlsFree(idx)releases the slot- Static TLS – declare at compile time with
__declspec(thread)(MSVC) or__thread(GCC). The compiler emits a.tlssection and populatesIMAGE_TLS_DIRECTORYin 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:
| Field | Meaning |
|---|---|
StartAddressOfRawData | VA where the initialized static TLS template begins |
EndAddressOfRawData | VA where the initialized portion of the template ends |
AddressOfIndex | VA of a DWORD the loader fills in with the runtime TLS slot index |
AddressOfCallBacks | VA of a null-terminated array of PIMAGE_TLS_CALLBACK function pointers |
SizeOfZeroFill | Extra zero-initialized bytes appended to the raw data per thread |
Characteristics | Bits 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.

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:
| Value | Constant | When it fires |
|---|---|---|
1 | DLL_PROCESS_ATTACH | Process initialization. Callbacks run on the init thread, before the EXE entry point. |
2 | DLL_THREAD_ATTACH | Every new thread the process creates after init. |
3 | DLL_THREAD_DETACH | Thread exits cleanly. |
0 | DLL_PROCESS_DETACH | Process is tearing down. |
A couple of things that look obvious but aren’t:
- Registering a callback does not spawn a thread. The
DLL_PROCESS_ATTACHcall 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 forDllMainapplies here one-to-one. - The order of callbacks within the array is the order they’re stored. If you register two callbacks via
.CRT$XLBand.CRT$XLC,XLBfires 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.

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.

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
| Tool | Use |
|---|---|
dumpbin /TLS | First check on any suspect binary; prints the directory and callback addresses |
| PE-bear / CFF Explorer | GUI inspection of Data Directory 9, section characteristics, relocations |
| IDA Pro | Auto-names callbacks TlsCallback_0, TlsCallback_1, listed in Exports/Functions |
| Ghidra | .tls section visible; callbacks appear in Symbol Tree under TLS |
| WinDbg | bu ntdll!LdrpCallTlsInitializers, then dt _IMAGE_TLS_DIRECTORY64 on the directory VA |
| x64dbg | Options -> Preferences -> Events -> “TLS Callbacks” breaks before EP |
| Volatility 3 | windows.callbacks plugin enumerates in-memory callback tables |
| TLSCheck 2.0 | Research 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 ID | Name | Why it matters here |
|---|---|---|
1 | ProcessCreate | Baseline parent/child; flag suspended children of unusual parents |
7 | ImageLoad | Unsigned or anomalous modules loaded very early in init |
8 | CreateRemoteThread | Core signal for the thread leg of hollowing / injection chains |
10 | ProcessAccess | PROCESS_VM_WRITE | PROCESS_VM_OPERATION on a target you don’t legitimately write to |
13 | Registry value set | Post-execution persistence that follows the TLS stage |
10.2 ETW providers worth enabling
| Provider | Why |
|---|---|
Microsoft-Windows-Kernel-Process | Full process/thread lifecycle, catches early thread activity |
Microsoft-Windows-Threat-Intelligence | PPL-only. Covers VirtualAllocEx and WriteProcessMemory across processes – the exact primitives above |
Microsoft-Antimalware-Scan-Interface | AMSI 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 /TLSon every first-party binary; alert on any that unexpectedly grow a TLS directory between builds. - Memory-forensics baseline: periodically run Volatility
windows.callbacksagainst known-good images; diffAddressOfCallBacksarrays. - Defender ASR rule
d1e49aac-8f56-4280-b9ba-993a6d77406creduces one of the common hollowing delivery vectors (PsExec/WMI-spawned processes).

11. MITRE ATT&CK Mapping
| Technique | ID | Detection focus |
|---|---|---|
| Process Injection | T1055 | Parent technique; cross-process memory writes + thread control |
| Process Injection: Thread Local Storage | T1055.005 | TLS directory present where unexpected; callback executes before EP |
| Process Injection: Process Hollowing | T1055.012 | Suspended child + NtUnmapViewOfSection + VirtualAllocEx + resume |
| Obfuscated Files or Information | T1027 | Unpacking/decryption performed inside the TLS callback |
| Debugger Evasion | T1622 | IsDebuggerPresent, NtQueryInformationProcess checks inside TLS |
Summary
- TLS callbacks are the Windows loader’s pre-entry hook, called from
LdrpCallTlsInitializersinsideLdrpInitializeProcessbeforemainorWinMainever runs. - The hook lives in
IMAGE_TLS_DIRECTORY.AddressOfCallBacks, a null-terminated array ofPIMAGE_TLS_CALLBACKfunction pointers using absolute VAs that must be relocated. - Registering one is a two-line job:
#pragma data_seg(".CRT$XLB")+/INCLUDE:_tls_usedunder 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 withT1055.012). - Triage with
dumpbin /TLSbefore you ever hit the entry point; hunt live with Sysmon IDs 1, 8, and 10, the Threat Intelligence ETW provider, and Volatility’swindows.callbacks.
Related Tutorials
- Windows Scheduler Internals: Priority Levels, Quantum, and Thread Selection
- APCs: Asynchronous Procedure Calls and Thread Hijacking Surface
- Threads and the TEB (Thread Environment Block)
- Access Tokens and Privileges: The Kernel’s Security Context
- SIDs and Security Descriptors: Identity in Windows Security
References
- learn.microsoft.com
- github.com
- d3fend.mitre.org
- www.picussecurity.com
- cocomelonc.github.io
- mallory.ai
- research.splunk.com
- arxiv.org
Get new drops in your inbox
Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.