Zydis Static TLS: В поисках LdrpHandleTlsData

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
Приветствую! Я создатель ботнета и рата MonsterV2. Недавно я писал статью, где описывал, почему от использования LoadPE следует по возможности отказаться. Но как быть, если не получается отказаться от LoadPE, например, по религиозным соображениям?

Эта статья призвана помочь крипторам решить проблему статического TLS, но сначала немного матчасти.

Static TLS

Давайте я покажу наглядно, зачем нужен Static TLS:
C++: Скопировать в буфер обмена
Код:
#include <iostream>
#include <windows.h>

int main() {
    static HMODULE hNtdll = GetModuleHandleW(L"ntdll");
    std::cout << hNtdll << std::endl;
    return EXIT_SUCCESS;
}
Этот код выводит базовый адрес Ntdll.dll, который перед выводом записывается в статическую переменную. Если мы грузим этот модуль вручную без поддержки статического TLS, то у нас выведется ноль. Всё дело в том, что статические переменные внутри функций на самом деле при компиляции становятся глобальными, а через NtCurrentTeb()->ThreadLocalStoragePointer проверяется, инициализированы ли они. Также программа использует NtCurrentTeb()->ThreadLocalStoragePointer при работе с thread_local (__declspec(thread)) переменными.

Для обработки статического TLS, начиная с Windows Vista, внутри Ntdll лежит функция LdrpHandleTlsData: она парсит IMAGE_DIRECTORY_ENTRY_TLS (9) загружаемого модуля и вызывает для всех потоков NtSetInformationProcess с классом ProcessTlsInformation (https://ntdoc.m417z.com/processinfoclass), который уже в ядре обновляет NtCurrentTeb()->ThreadLocalStoragePointer. Более подробно про работу LdrpHandleTlsData можете прочитать тут. Также я нашёл псевдокод для LdrpHandleTlsData и NtSetInformationProcess с классом ProcessTlsInformation времён Windows Vista. С тех пор код особо не менялся, единственное — соглашение о вызове у функции LdrpHandleTlsData поменялось с __stdcall на __thiscall для Windows 8.1/Windows Server 2012 R2 и выше.

Полный разбор механизма статического TLS тянет на отдельную статью + я оставил достаточно ссылок для изучения его работы, так что повторяться смысла не вижу. Сейчас нам надо разобраться, как отловить TLS для загружаемых вручную модулей.

В поисках LdrpHandleTlsData

LdrpHandleTlsData хэндлит TLS для каждого загружаемого модуля и хранит всю нужную информацию в переменных: LdrpTlsBitmap, LdrpActiveThreadCount и LdrpTlsList, ну и по-хорошему переменную LdrpTlsLock тоже надо бы захватить, чтоб всё было thread-safe.

Чем искать каждую эту переменную и писать ручные костыли, нам проще найти саму функцию LdrpHandleTlsData и вызвать её. Что нам для этого надо? Во-первых, сигнатура:
C++: Скопировать в буфер обмена
Код:
// Windows 8.1/Windows Server 2012 R2+
NTSTATUS __thiscall LdrpHandleTlsData(PLDR_DATA_TABLE_ENTRY Module);

// Windows 8 and older
NTSTATUS __stdcall LdrpHandleTlsData(PLDR_DATA_TABLE_ENTRY Module);
О нет, нам что, придётся ещё LDR_DATA_TABLE_ENTRY добавлять в peb->Ldr? Да нет, конечно: для корректной работы у LDR_DATA_TABLE_ENTRY достаточно лишь проиницилизовать поля DllBase и SizeOfImage — к другим эта функция не обращается.

Но как же нам найти адрес самой функции LdrpHandleTlsData, если она не экспортируется? Предлагаете по паттернам искать для каждой версии Винды? или, может, грузить PDB и через него искать? Нет, мы всего лишь продизасмим Ntdll!
meme.jpg


Мы знаем, что внутри LdrpHandleTlsData будет вызываться NtSetInformationProcess с классом ProcessTlsInformation: адрес NtSetInformationProcess мы знаем, так как это экспортируемая функция, значение ProcessTlsInformation равно 35; такой вызов на всю .text секцию Ntdll один — найдём его, найдём и LdrpHandleTlsData.

Для начала получим адрес NtSetInformationProcess
C++: Скопировать в буфер обмена
Код:
uintptr_t zydis_tls::getNtSetInformationProcessAddress() {
    return reinterpret_cast<uintptr_t>(&NtSetInformationProcess);
    /* or
    HMODULE hNtdll = GetModuleHandleW(L"ntdll");
    if (!hNtdll) {
        return 0;
    }
    return reinterpret_cast<uintptr_t>(GetProcAddress(hNtdll, "NtSetInformationProcess"));*/
}
Теперь получим .text секцию Ntdll:
C++: Скопировать в буфер обмена
Код:
bool zydis_tls::findNtdllTextSection(uintptr_t& addr, size_t& size) {
    HMODULE hNtdll = GetModuleHandleW(L"ntdll");
    if (!hNtdll) {
        return false;
    }
    PIMAGE_NT_HEADERS headers = RtlImageNtHeader(hNtdll);
    if (!headers) {
        return false;
    }

    PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(headers);
    bool ret = false;
    for (WORD i = 0; i < headers->FileHeader.NumberOfSections; ++i) {
        if (!_strnicmp(".text", reinterpret_cast<const char*>(section->Name), IMAGE_SIZEOF_SHORT_NAME)) {
            addr = reinterpret_cast<uintptr_t>(hNtdll) + section->VirtualAddress;
            size = section->Misc.VirtualSize;
            ret = true;
            break;
        }

        ++section;
    }
    return ret;
}
Все нужные адреса у нас есть, приступаем к поиску. Нам нужно пройтись по всей .text секции и записать все адреса, которые являются адресами функций (на которые выполняется call инструкция), а также записать все вызовы NtSetInformationProcess. Для дизасма я буду использовать Zydis, но вы можете это сделать без него, если захотите.
C++: Скопировать в буфер обмена
Код:
#include <set>

#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <phnt.h>
#include <Zydis/Zydis.h>

std::set<ZyanU64> zydis_tls::gHrefs{};
std::set<ZyanU64> zydis_tls::gNtSetInformationProcessCalls{};

FARPROC zydis_tls::findLdrpHandleTlsData() {
    uintptr_t sectionStart = 0;
    size_t sectionSize = 0, offset = 0;
    if (!findNtdllTextSection(sectionStart, sectionSize)) {
        return nullptr;
    }
    uintptr_t sectionEnd = sectionStart + sectionSize;
    uintptr_t ntSetInformationProcessAddress = getNtSetInformationProcessAddress();
    if (!ntSetInformationProcessAddress) {
        return nullptr;
    }
    ZydisDecoder decoder;
#ifdef _WIN64
    ZyanStatus status = ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_64, ZYDIS_STACK_WIDTH_64);
#else
    ZyanStatus status = ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_COMPAT_32, ZYDIS_STACK_WIDTH_32);
#endif
    if (ZYAN_FAILED(status)) {
        return nullptr;
    }
    ZydisDecodedInstruction instruction{};
    ZydisDecodedOperand operands[ZYDIS_MAX_OPERAND_COUNT]{};
    uintptr_t currentAddress = sectionStart;
    while (currentAddress <= sectionEnd && currentAddress >= sectionStart) {
        status = ZydisDecoderDecodeFull(
            &decoder,
            reinterpret_cast<PVOID>(currentAddress), sectionSize - offset,
            &instruction, operands
        );
        if (ZYAN_FAILED(status)) {
            offset++;
            goto endAddr;
        }
        // Check if it's call mnemonic with immediate address value
        if (
            instruction.mnemonic == ZYDIS_MNEMONIC_CALL &&
            instruction.operand_count_visible == 1 &&
            operands[0].type ==ZYDIS_OPERAND_TYPE_IMMEDIATE
        ) {
            ZyanU64 callAddress = 0;
            // Calculate absolute address
            if (operands[0].imm.is_relative) {
                status = ZydisCalcAbsoluteAddress(&instruction, operands, sectionStart + offset, &callAddress);
                if (!ZYAN_SUCCESS(status)) {
                    goto endOffset;
                }
            }
            else {
                callAddress = operands[0].imm.value.u;
            }
            if (callAddress == ntSetInformationProcessAddress) {
                gNtSetInformationProcessCalls.insert(currentAddress);
            }
            gHrefs.insert(callAddress);
        }
endOffset:
        offset += instruction.length;
endAddr:
        currentAddress = sectionStart + offset;
    }
    for (const ZyanU64 addr : gNtSetInformationProcessCalls) {
        if (isProcessTlsInformationCall(
            addr,
            &decoder, &instruction, operands,
            sectionStart, sectionEnd, sectionSize
        )) {
            FARPROC LdrpHandleTlsData = findFunctionStart(addr, sectionStart, sectionEnd);
            if (LdrpHandleTlsData) {
                return LdrpHandleTlsData;
            }
        }
    }
    return nullptr;
}
Собрав все вызовы NtSetInformationProcess (их на всю .text секцию ntdll будет примерно штук 10–15), смотрим, что он вызывается именно с классом ProcessTlsInformation. Для 32 битов это делается легко: отступаем 4 байта назад и смотрим на значение операнда.
Код: Скопировать в буфер обмена
Код:
push 23h (ProcessTlsInformation)
push 0FFFFFFFFh (NtCurrentProcess())
Для 64 битов класс записывается в регистр edx. Я сделал костыльно: отступаю назад, пока не найду следующую инструкцию:
Код: Скопировать в буфер обмена
mov edx, 23h (ProcessTlsInformation)
Чтоб не отступать до конца секции, я установил ограничение в 24 байта — этого должно хватить, чтоб найти нужную инструкцию. Но моя реализация для x64 довольно костыльная и, возможно, потребует доработки напильником.
C++: Скопировать в буфер обмена
Код:
bool zydis_tls::isProcessTlsInformationCall(
    uintptr_t address,
    const ZydisDecoder* decoder,
    ZydisDecodedInstruction* instruction,
    ZydisDecodedOperand operands[ZYDIS_MAX_OPERAND_COUNT],
    uintptr_t sectionStart,
    uintptr_t sectionEnd,
    size_t sectionSize
) {
    if (!decoder || !instruction || !address || !sectionStart || !sectionEnd || !sectionSize || !operands) {
        return false;
    }
#ifndef _WIN64
    if ((address - 4) < sectionStart) {
        return false;
    }
    address -= 2; // push instruction size is 2 bytes
    ZyanStatus status = ZydisDecoderDecodeFull(
        decoder,
        reinterpret_cast<PVOID>(address), 2,
        instruction, operands
    );
    // push 0FFFFFFFFh
    if (ZYAN_FAILED(status) || instruction->mnemonic != ZYDIS_MNEMONIC_PUSH) {
        return false;
    }
    address -= 2;
    status = ZydisDecoderDecodeFull(
        decoder,
        reinterpret_cast<PVOID>(address), 2,
        instruction, operands
    );
    if (ZYAN_FAILED(status) || instruction->mnemonic != ZYDIS_MNEMONIC_PUSH) {
        return false;
    }
    //push 23h (ProcessTlsInformation)
    return instruction->operand_count_visible == 1 &&
        operands[0].type == ZYDIS_OPERAND_TYPE_IMMEDIATE &&
        operands[0].imm.value.s == ProcessTlsInformation;
#else
    uintptr_t limitAddress = std::max(address - 24, sectionStart);
    ZyanStatus status = ZYAN_STATUS_SUCCESS;
    size_t offset = address - sectionStart;
    while (address <= sectionEnd && address >= limitAddress) {
        status = ZydisDecoderDecodeFull(
            decoder,
            reinterpret_cast<PVOID>(address), sectionSize - offset,
            instruction, operands
        );
        if (ZYAN_FAILED(status)) {
            goto end;
        }
     
        // mov edx, 23h (ProcessTlsInformation)
        if (
            instruction->mnemonic == ZYDIS_MNEMONIC_MOV &&
            instruction->operand_count_visible == 2 &&
            operands[0].type == ZYDIS_OPERAND_TYPE_REGISTER &&
            operands[0].reg.value == ZYDIS_REGISTER_EDX &&
            operands[1].type == ZYDIS_OPERAND_TYPE_IMMEDIATE &&
            operands[1].imm.value.s == ProcessTlsInformation
            ) {
            return true;
        }
end:
        offset--;
        address = sectionStart + offset;
    }
    return false;
#endif
}
Ну а теперь дело за малым: мы нашли нужный нам вызов внутри тела искомой функции, так что просто отступаем назад, пока не наткнёмся на адрес, на который где-то в Ntdll выполняется call, благо все call'ы мы запомнили.
C++: Скопировать в буфер обмена
Код:
FARPROC zydis_tls::findFunctionStart(
    uintptr_t functionBodyAddr,
    uintptr_t sectionStart,
    uintptr_t sectionEnd
) {
    while (functionBodyAddr <= sectionEnd && functionBodyAddr >= sectionStart) {
        if (gHrefs.contains(functionBodyAddr)) {
            return reinterpret_cast<FARPROC>(functionBodyAddr);
        }
        functionBodyAddr--;
    }
    return nullptr;
}
Ну а теперь просто вызываем нашу функцию для загруженного в память модуля:
C++: Скопировать в буфер обмена
Код:
// LdrpHandleTlsData has __thiscall calling convention starting from Windows 8.1/Windows Server 2012 R2
static bool needThisCall() {
    ULONG major = 0, minor = 0, build = 0;
    RtlGetNtVersionNumbers(&major, &minor, &build);
    if (major > 6) {
        return true;
    }
    else if (major < 6) {
        return false;
    }
    else {
        return minor >= 3;
    }
}

bool zydis_tls::setupStaticTlsForModule(
    PVOID moduleBase,
    size_t moduleSize
) {
    if (!moduleBase || !moduleSize) {
        return false;
    }
    FARPROC LdrpHandleTlsDataAddress = findLdrpHandleTlsData();
    if (!LdrpHandleTlsDataAddress) {
        return false;
    }
    using STDCALL = NTSTATUS(__stdcall*)(PLDR_DATA_TABLE_ENTRY);
    using THISCALL = NTSTATUS(__thiscall*)(PLDR_DATA_TABLE_ENTRY);
    union {
        STDCALL stdcall;
        THISCALL thiscall;

        FARPROC ptr;
    } LdrpHandleTlsData = {0};
    bool thiscall = needThisCall();
    LdrpHandleTlsData.ptr = LdrpHandleTlsDataAddress;
    LDR_DATA_TABLE_ENTRY entry{};
    entry.DllBase = moduleBase;
    entry.SizeOfImage = moduleSize;
    __try {
        NTSTATUS status = thiscall ? LdrpHandleTlsData.thiscall(&entry) : LdrpHandleTlsData.stdcall(&entry);
        return NT_SUCCESS(status);
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        return false;
    }
}

Вместо заключения

Спасибо за прочтение! Я не могу гарантировать стабильную работу моего кода для всех версий Windows, начиная с Висты, для 32 и 64 бита, но мой метод будет лучше, чем поиск по паттернам с вычитанием оффсетов или подгрузка PDB, так что при желании вы уже сможете доработать его напильником, как вам будет удобно. У меня не получилось в паблике найти кода, который искал бы функцию LdrpHandleTlsData таким же образом (но это не значит, что его нет, так что если вы видели, то дайте знать), хотя, как по мне, мой способ гораздо более очевиднее, чем поиск по паттернам. Но я находил китайский код, который ищет LdrpHandleTlsData через строковое упоминание в обработчиках исключений: https://github.com/howmp/LdrpHandleTlsData/blob/main/src/main.zig. Я его не тестировал, но, может, вам будет интересно.

Свой же я тестировал только на Windows 10 и 11 (32 и 64 бита) — работает как часы.

Контакты / Contacts
Форумы
 
Сверху Снизу