Лоадер без секретов в main: как малварь прячет пейлоад в TLS Callbacks

Лоадер без секретов в main: как малварь прячет пейлоад в TLS Callbacks

Дата публикации: 22 September 2026

TL;DR

Механизм Thread Local Storage (TLS) в формате Portable Executable позволяет регистрировать функции обратного вызова (PIMAGE_TLS_CALLBACK), которые загрузчик Windows вызывает до передачи управления на точку входа модуля (AddressOfEntryPoint). В контексте анализа вредоносного ПО это создаёт зону внимания: стандартные брейкпоинты отладчика на точке входа или функции main не перехватывают раннее исполнение, в ходе которого код успевает проверить флаги PEB, подготовить расшифровку пейлоада или противодействовать отладке. Техника опирается на штатный PE-лоадер операционной системы начиная с ранних версий Windows и не требует инъекций или повышенных привилегий, маскируясь под инициализацию рантайма компилятора. В исследовательской практике точку входа часто воспринимают как безусловный старт выполнения логики исполняемого файла. Однако в PE-архитектуре инициализация подсистем потока выполняется раньше: системный загрузчик исполняет коллбэки TLS на этапе DLL_PROCESS_ATTACH ещё до того, как основной поток получит доступ к коду по адресу AddressOfEntryPoint, откуда рантайм компилятора (CRT) обычно передаёт управление в main или WinMain. Ниже разобран механизм работы загрузчика, корректный способ регистрации массива коллбэков на этапе линковки MSVC, специфика векторов антиотладки и методы детектирования.

1. Контекст: жизненный цикл потока и вызов PIMAGE_TLS_CALLBACK

В штатном режиме механизм TLS предназначен для инициализации статических и глобальных переменных, уникальных для каждого потока (thread_local в стандартах C++ или __declspec(thread) в MSVC). Для управления жизненным циклом таких данных спецификация PE предусматривает директорию IMAGE_DIRECTORY_ENTRY_TLS (индекс 9 в DataDirectory). В обобщённом виде порядок инициализации процесса загрузчиком Windows для исполняемого файла выглядит следующим образом:

  1. Загрузчик Windows инициализирует процесс и загружает зависимости (Import Table / DLL).
  2. Инициализируется TLS, затем вызываются пользовательские TLS Callbacks с DLL_PROCESS_ATTACH.
  3. Загрузчик передаёт управление на AddressOfEntryPoint.
  4. CRT-рантайм запускает main() или WinMain().

Системный загрузчик обходит массив указателей на функции, завершающийся нулевым адресом (NULL-terminated array). Сигнатура коллбэка напоминает точку входа DLL, но возвращает VOID (в отличие от BOOL у DllMain):

1
2
3
4
5
typedef VOID (NTAPI *PIMAGE_TLS_CALLBACK)(
    PVOID DllHandle,
    DWORD Reason,
    PVOID Reserved
);

Когда загрузчик инициализирует процесс, он вызывает каждый зарегистрированный коллбэк с аргументом Reason = DLL_PROCESS_ATTACH (значение 1). При создании каждого последующего потока в рамках процесса коллбэки вызываются повторно с Reason = DLL_THREAD_ATTACH (значение 2).

2. Анатомия структуры: от PE-заголовка до массива указателей

В опциональном заголовке PE девятая запись DataDirectory указывает на структуру IMAGE_TLS_DIRECTORY32 или IMAGE_TLS_DIRECTORY64:

1
2
3
4
5
6
7
8
9
// winnt.h
typedef struct _IMAGE_TLS_DIRECTORY64 {
    ULONGLONG StartAddressOfRawData;
    ULONGLONG EndAddressOfRawData;
    ULONGLONG AddressOfIndex;         // VA ячейки памяти, куда загрузчик помещает TLS-индекс
    ULONGLONG AddressOfCallBacks;     // VA массива указателей PIMAGE_TLS_CALLBACK
    DWORD     SizeOfZeroFill;
    DWORD     Characteristics;
} IMAGE_TLS_DIRECTORY64;

Ключевое поле для выполнения стороннего кода — AddressOfCallBacks. Оно содержит абсолютный виртуальный адрес (VA), по которому расположен массив указателей, завершающийся нулём.

Регистрация коллбэка через MSVC CRT

В компиляторе MSVC секции с одинаковым префиксом до символа $ группируются и сортируются линковщиком по алфавиту фрагмента после доллара (обычно сливаясь в секцию .rdata). Маркер начала массива находится в .CRT$XLA, а терминатор — в .CRT$XLZ. Для размещения собственной функции разработчики и авторы вредоносного ПО используют сегмент от .CRT$XLB до .CRT$XLY. Чтобы линковщик не удалил указатель при оптимизации и гарантированно связал TLS-директорию CRT, необходимо передать соответствующие директивы компоновщику (/include):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <windows.h>
#include <intrin.h>

#pragma section(".CRT$XLB", read)

void NTAPI TlsCallbackBlock(PVOID DllHandle, DWORD Reason, PVOID Reserved) {
    if (Reason == DLL_PROCESS_ATTACH) {
        // Код выполняется ДО передачи управления на AddressOfEntryPoint

        // GS:[0x60] / FS:[0x30] хранят адрес PEB; BeingDebugged находится по смещению 0x02.
#if defined(_M_X64)
        // GS:[0x60] — адрес PEB; PEB+0x02 — поле BeingDebugged
        const BYTE* peb = reinterpret_cast<const BYTE*>(__readgsqword(0x60));
#elif defined(_M_IX86)
        // FS:[0x30] — адрес PEB; PEB+0x02 — поле BeingDebugged
        const BYTE* peb = reinterpret_cast<const BYTE*>(__readfsdword(0x30));
#else
#error This example supports only Windows x86 and x64.
#endif
        const BYTE beingDebugged = peb[2];

        if (beingDebugged) {
            // Завершение процесса до срабатывания пользовательских брейкпоинтов в main
            ExitProcess(0xDEAD);
        }
    }
}

// C linkage оставляет имя символа предсказуемым для директивы /include.
extern "C" {
__declspec(allocate(".CRT$XLB")) PIMAGE_TLS_CALLBACK pTlsCallback = TlsCallbackBlock;
}

// Удержание символов от вырезания компоновщиком
#if defined(_M_X64)
    #pragma comment(linker, "/include:pTlsCallback")
    #pragma comment(linker, "/include:_tls_used")
#elif defined(_M_IX86)
    #pragma comment(linker, "/include:_pTlsCallback")
    #pragma comment(linker, "/include:__tls_used")
#endif

int main() {
    // Сюда выполнение не дойдёт, если флаг отладки был обнаружен в коллбэке
    MessageBoxA(NULL, "Main function reached", "Notice", MB_OK);
    return 0;
}

Сборка (Developer Command Prompt нужной архитектуры; user32.lib нужна для MessageBoxA):

1
2
3
cl /nologo /W4 /O2 /EHsc tls_demo.cpp /link user32.lib
dumpbin /tls tls_demo.exe
tls_demo.exe

3. Векторы применения: раннее исполнение и специфика антианализа

Поскольку TLS-коллбэк вызывается до инициализации пользовательского кода приложения, он исполняется в контексте целевого процесса и имеет доступ к его адресному пространству.

Детектирование отладчика через прямое чтение PEB

Аналитические песочницы и исследователи часто отслеживают вызовы стандартных WinAPI (IsDebuggerPresent, CheckRemoteDebuggerPresent). Прямой доступ к сегментным регистрам (GS на x64, FS на x86) для чтения поля BeingDebugged структуры PEB позволяет провести базовую проверку без вызова экспортируемых функций подсистемы Win32.

Манипуляция аппаратными брейкпоинтами (Dr0–Dr3)

Распространённый сценарий противодействия динамическому анализу — попытка обнаружить или очистить отладочные регистры процессора (Dr0–Dr3, Dr7). Важное архитектурное ограничение WinAPI: вызов GetThreadContext(GetCurrentThread(), ...) не возвращает валидный контекст для текущего работающего потока (согласно спецификации Microsoft), а попытка вызвать SetThreadContext для текущего потока приводит к неопределённому поведению. Поэтому в реальных образцах вредоносного ПО для изменения отладочных регистров изнутри TLS-коллбэка применяют другие механизмы:

  • Зарегистрировать обработчик исключений (SEH/VEH), вызвать исключение и изменить PCONTEXT перед продолжением исполнения.
  • Создать вспомогательный поток, приостановить целевой и вызвать GetThreadContext / SetThreadContext.

Предварительная подготовка и расшифровка данных

Коллбэк может динамически выделить память, выполнить расшифровку полезной нагрузки в секциях бинарника или подготовить структуры до того, как аналитик перейдёт к исследованию кода за точкой входа (AddressOfEntryPoint).

4. Сравнение ожиданий аналитика и реального поведения

СценарийОжидание аналитикаВозможное поведение с TLS callback
Точка останова на AddressOfEntryPoint / mainОтладчик остановится на стартовой инструкции или входе в main.Коллбэк может завершить процесс через ExitProcess до передачи управления на EP.
Аппаратные брейкпоинты (Dr0Dr3)Контроль обращений к защищённым участкам памяти с начала выполнения.Обработчик исключений или вспомогательный поток может изменить регистры отладки.
Статический анализАнализ кода около точки входа.Код может быть расшифрован в памяти во время раннего коллбэка.
Перехват API-вызововМониторинг WinAPI-функций проверки отладки.Прямое чтение PEB через FS / GS обходит перехват этих API.

5. Практические примеры применения

Использование TLS Callbacks для выполнения вредоносной логики встречается как в авторских загрузчиках, так и в целевых атаках: Инъекции через TLS (T1055.005): В исследовании FireEye/Mandiant описан вариант банковского трояна Ursnif (Gozi-ISFB), который применял модификацию заголовка TLS при внедрении в дочерний процесс: вредоносный код прописывал адрес полезной нагрузки в массив TLS-коллбэков целевого процесса, обеспечивая автоматическое исполнение инжектированного кода загрузчиком ОС без создания новых потоков через CreateRemoteThread.

6. Детектирование: статика, динамика и сигнатуры

Статический анализ

PE-bear / CFF Explorer: Открыть файл → перейти в раздел Data Directories. Проверить строку TLS Table (индекс 9). Если VirtualAddress и Size не равны нулю — файл содержит TLS-директорию. В структуре TLS найти поле AddressOfCallBacks. Если по указанному адресу в дампе расположен ненулевой массив — зафиксировать виртуальные адреса (VA) функций для дальнейшего реверса. CLI (MSVC dumpbin): dumpbin /tls sample.exe

Утилита покажет наличие TLS-директории и перечислит виртуальные адреса зарегистрированных функций обратного вызова при их наличии. Дизассемблеры (IDA Pro / Ghidra): В IDA Pro открыть список точек входа: Ctrl + E (окно Entry Points). Зарегистрированные коллбэки отображаются в списке наряду с точкой входа модуля (например, под именами TlsCallback_0, TlsCallback_1).

Динамический анализ: настройка отладчика

По умолчанию многие отладчики передают процесс на откуп пользователю только при достижении системного брейкпоинта загрузчика или точки входа модуля. Чтобы перехватить исполнение в коллбэках: x64dbg / x32dbg: Перейти в Options → Preferences (Alt + P), открыть вкладку Events и отметить флаг TLS Callbacks. Отладчик установит одноразовые брейкпоинты на каждый зарегистрированный коллбэк модуля при его загрузке. IDA Pro Debugger: В настройках отладчика (Debugger → Debugger Options) включить остановку на раннем этапе (например, Stop on process start или Stop on debugging start), после чего выставить точки останова на адреса TlsCallback_*, полученные из окна Entry Points.

YARA: базовое правило для поиска PE с объявленной TLS-директорией

Поскольку в стандартном модуле pe библиотеки YARA нет встроенного счётчика количества коллбэков, корректное правило базового скрининга проверяет корректность заголовка PE и валидность размера директории TLS:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import "pe"

rule PE_Has_TLS_Directory {
    meta:
        description = "Identifies PE binaries containing an initialized TLS Directory"
        author = "Threat Research"
        severity = "Info"
        mitre_technique = "T1055.005 / T1622"
    condition:
        // Проверка сигнатуры MZ
        uint16(0) == 0x5A4D and
        
        // Корректный PE-заголовок
        pe.is_pe and
        
        // Наличие TLS-директории в DataDirectory и ненулевой размер
        pe.data_directories[pe.IMAGE_DIRECTORY_ENTRY_TLS].virtual_address > 0 and
        pe.data_directories[pe.IMAGE_DIRECTORY_ENTRY_TLS].size > 0
}

Примечание: Наличие TLS-директории само по себе не является признаком вредоносности бинарника (многие стандартные C++ бинарники содержат TLS для многопоточных рантайм-переменных). Задача аналитика и правил первичного триажа — локализовать массив AddressOfCallBacks и проверить, содержит ли он пользовательские функции.

Вывод

Механизм TLS Callbacks наглядно иллюстрирует, что точка входа AddressOfEntryPoint не является абсолютным стартом программы. Наличие легитимного раннего вызова функций в архитектуре загрузчика Windows делает эту технику удобным инструментом для обхода поверхностного динамического анализа, если исследователь заранее не сконфигурировал отладчик на перехват событий инициализации TLS.

Источники