Skip to content

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 0 marked 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

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 first TLS callback in the Code window: the Reason argument compared with 2 and 1

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, 140006D34 and 140006DD8, both in .text: the Rust runtime's own TLS destructors, which are legitimate.
  • A 32-bit Rust/MinGW build has 2 callbacks at D77F50 and D77F00 (image base 0x400000), 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

$ ppee-cli --tls dbccfce8….exe
TLS: index=14026736C zeroFill=0 callbacks=1
  callback VA=14014E820
PS C:\> ppee-cli.exe --tls C:\MalwareSamples\dbccfce8d0ebc5ea70b601130d6453cb31db779c002149c9f2a3c6b0236fe8af.exe
C:\MalwareSamples\dbccfce8d0ebc5ea70b601130d6453cb31db779c002149c9f2a3c6b0236fe8af.exe: 2584576 bytes, PE32+

TLS: index=14026736C zeroFill=0 callbacks=1
  callback VA=14014E820

(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