TLS Directory¶
Data directory 9
IMAGE_DIRECTORY_ENTRY_TLS: DataDirectory[9] in the Optional header. All data directories
Thread Local Storage gives each thread its own copy of some variables. It also gives the loader a list of TLS callbacks: functions it calls on process and thread start and exit, before AddressOfEntryPoint runs. A debugger that breaks on the entry point is already too late, which is why malware uses TLS callbacks for anti-debugging, environment checks and early unpacking.
How the structure works¶
IMAGE_TLS_DIRECTORY (all addresses are VAs, not RVAs, because the loader relocates them):
| Field | Meaning |
|---|---|
StartAddressOfRawData / EndAddressOfRawData | The template data copied into each thread's TLS block |
AddressOfIndex | Where the loader stores the TLS slot index |
AddressOfCallBacks | VA of a zero-terminated array of callback VAs |
SizeOfZeroFill | Extra zero-initialized bytes after the template |
Characteristics | Alignment flags |
AddressOfCallBacks ──► [ cb1 VA ][ cb2 VA ][ 0 ]
│ │
▼ ▼
void NTAPI cb(PVOID DllHandle, DWORD Reason, PVOID Reserved)
Reason: 1 = PROCESS_ATTACH (before the entry point), 2 = THREAD_ATTACH, 3 = THREAD_DETACH, 0 = PROCESS_DETACH
What PPEE shows¶
- Upper list: the directory fields (Member · Value · Comment), with addresses resolved to their sections.
- Lower list, always visible: every Callback VA with the section it points into in Comment, and the terminating
0marked Last item. - The tree label shows the callback count: DIR_ENTRY_TLS (2).
- Follow in Hex View (Ctrl+H) on a callback jumps to its bytes, and callback VAs are editable.
Windows screenshot: TLS directory of a ransomware DLL: two callbacks in .text and the terminating zero
The ransomware DLL from the walkthrough below: two callbacks, both in .text [RX], then the 0 marked Last item. The template data is in .tls [RW]. The small triangle on each callback is the corner mark: click it to read the callback's code.
Reading TLS like an analyst¶
| Observation | What it suggests |
|---|---|
| One or more callbacks in a small, unfamiliar binary | Code that runs before main: check it first for IsDebuggerPresent, NtQueryInformationProcess(ProcessDebugPort), timing checks, VM detection, or self-decryption |
| Callback in a writable or high-entropy section, or in a section with an odd name | The callback code is unpacked or patched at run time |
| Callback VA outside the image or in no section | Broken or deliberately confusing, or points into memory prepared by an earlier callback |
| Callback array in a writable section | Callbacks can be added at run time: the first callback writes the next one's address into the array before the loader reads it |
| TLS directory present but zero callbacks | Normal for MSVC programs using __declspec(thread). Not suspicious by itself |
| Callbacks in Rust, MinGW-GCC or Delphi binaries | Normal: these runtimes register callbacks for their own thread-local cleanup |
Walkthrough: are these callbacks a trick?
ef431e36a8ac12051af588d800e1d8128d583a9d4c68187709263a13564081cd.dll (ransomware DLL):
$ ppee-cli --tls ef431e36….dll
TLS: index=6DAF6044 zeroFill=0 callbacks=2
callback VA=6D741870
callback VA=6D741830
$ ppee-cli --disasm tls:0 --count 10 ef431e36….dll
Disassembly: TlsCallback_0, x86, section .text
6D741870 55 push ebp
6D741871 89 E5 mov ebp, esp
…
6D741877 8B 45 0C mov eax, dword ptr [ebp+0xC]
6D74187A 83 3D EC 30 AF 6D 02 cmp dword ptr [0x6DAF30EC], 0x2
6D741881 74 0A jz 0x6D74188D
6D741883 C7 05 EC 30 AF 6D 02 00 00 00 mov dword ptr [0x6DAF30EC], 0x2
6D74188D 83 F8 02 cmp eax, 0x2
6D741890 74 0E jz 0x6D7418A0
[ebp+0xC] is the Reason argument; the callback sets a global to 2 and branches on THREAD_ATTACH (2) / PROCESS_ATTACH (1). That is MinGW-w64's __dyn_tls_callback, and the file confirms it (GCC: (GNU) 15-win32 strings, no Rich header). Not a trick: the interesting code is elsewhere. A callback that calls IsDebuggerPresent, reads the PEB or decrypts memory would be. The Code analysis shows each callback's first instructions without a command.
The same callback in the GUI, opened from its corner mark. The window names it TlsCallback_0 and colors the branches, so the cmp eax, 0x2 / cmp eax, 0x1 tests on Reason stand out.
More samples
- A Rust (MSVC target) build has 2 callbacks,
140006D34and140006DD8, both in.text: the Rust runtime's own TLS destructors, which are legitimate. - A 32-bit Rust/MinGW build has 2 callbacks at
D77F50andD77F00(image base0x400000), inside.text. - Another sample has a TLS directory with no callbacks: ordinary thread-local data, nothing runs early.
Debugging TLS callbacks
Break before the entry point, set breakpoints on the callback addresses PPEE lists, rebased to the module's load address (the rebase preview does the arithmetic), and enable your debugger's option to stop on TLS callbacks if it has one.
CLI and JSON¶
(A RustyStealer build, dbccfce8d0ebc5ea70b601130d6453cb31db779c002149c9f2a3c6b0236fe8af.exe: one callback, the Rust runtime's.)
JSON: tls → present, startAddressOfRawData, endAddressOfRawData, addressOfIndex, addressOfCallBacks, sizeOfZeroFill, characteristics, callbacks[] (VA hex strings).
Hunting recipes¶
# Which section does each callback live in? (VA -> RVA -> section)
ppee-cli --json --tls --sections --headers f.dll | jq -r '
def h: ascii_downcase | explode | reduce .[] as $c (0; . * 16 + (if $c >= 97 then $c - 87 else $c - 48 end));
def x: [recurse(if . >= 16 then (. / 16 | floor) else empty end) | . % 16 | "0123456789ABCDEF"[.:.+1]] | reverse | join("");
(.headers["OptionalHeader.ImageBase"] | h) as $ib | .sections as $s
| [range(0; 96) | select($s["Section[\(.)].Name"] != null) | {n: $s["Section[\(.)].Name"], va: ($s["Section[\(.)].VirtualAddress"] | h),
vs: ($s["Section[\(.)].VirtualSize"] | h), ch: $s["Section[\(.)].Characteristics"]}] as $secs
| .tls.callbacks[] | (h - $ib) as $rva | ([$secs[] | select($rva >= .va and $rva < .va + .vs)][0]) as $sec
| "callback VA=\(.) RVA=\($rva | x) section=\($sec.n // "OUTSIDE IMAGE") characteristics=\($sec.ch // "-")"'
callback VA=104CB220 RVA=4CB220 section=.text characteristics=60000020
callback VA=104CBFA0 RVA=4CBFA0 section=.text characteristics=60000020
# Every sample in a folder that runs code before its entry point
for f in samples/*; do
n=$(ppee-cli --no-similarity --json --tls "$f" 2>/dev/null | jq '.tls.callbacks | length')
[ "${n:-0}" -gt 0 ] && echo "$n callback(s): $f"
done
Related: --tls · Exception directory · Base relocation (rebase preview)
References¶
- Microsoft PE format specification, TLS section: the TLS directory and callback array.
- Thread local storage (Microsoft): what TLS is used for at run time.

