Пишем упаковщик по шагам. Шаг шестой. TLS.

Предыдущий шаг здесь.

Появилась новая версия библиотеки для работы с PE-файлами (0.1.5). Перекачайте и пересоберите ее.

Пришло время заняться обработкой такой важной вещи, как Thread Local Storage (TLS) - локальной памяти потока. Что она из себя представляет? Это небольшая структура, которая говорит загрузчику PE-файлов о том, где находятся данные, которые должны быть выделены в памяти для каждого потока. Загрузчиком также производится вызов функции TlsAlloc, и значение, возвращенное ей, записывается по адресу, также указанному в этой структуре (называется это индексом). Кроме того, эта же структура может содержать адрес массива, хранящего набор коллбэков (адресов функций), которые будут вызваны загрузчиком при загрузке файла в память или при создании нового потока в процессе.

С TLS, признаться честно, все будет несколько хардкорнее, чем с остальным, так что приготовьтесь и напрягите мозг. Мой прошлый упаковщик TLS-коллбэки не поддерживал, трусливо выдавая сообщение о том, что они есть, но не обрабатываются. В принципе, поведение разумное, так как TLS-коллбеки имеют в основном всякие странные файлы, использующие эту вещь как антиотладочный прием. Ни один штатный линкер, вроде линкера от Майкрософт или Борланд, не поддерживают создание TLS-коллбэков. Тем не менее, для создания годного упаковщика мы их поддержку запилим.

Но начнем по порядку. Как обычно, будем править структуру packed_file_info (файл structures.h проекта simple_pe_packer). В нее на этот раз добавятся четыре поля:

В этих полях мы будем хранить необходимые для распаковщика значения, связанные с TLS. Отдельно поясню про коллбэки. Поле AddressOfCallBacks структуры IMAGE_TLS_DIRECTORY указывает на массив абсолютных виртуальных адресов (т.е. на адреса, идущие друг за другом), которые, в свою очередь, указывают на функции, являющиеся коллбэками. Завершается этот массив нулевым элементом. Загрузчик по очереди дергает все функции в этом массиве при следующих событиях: создание процесса, создание потока, завершение потока, завершение процесса. Первый раз они вызываются даже до того, как процесс получит управление. Чтобы дать понять загрузчику, что в нашем упакованном файле есть TLS-коллбеки (разумеется, если они были в оригинальном), мы сделаем следующее: поле AddressOfCallBacks обнулять не будем, а запишем туда адрес массива, содержащий один-единственный пустой коллбэк (не нули, а реальный коллбэк, который ничего не делает). При загрузке упакованного образа в память этот коллбэк будет выполнен и загрузчик с этого момента будет знать, что у файла есть TLS-коллбэки. Если бы мы записали в поле AddressOfCallbacks ноль или указатель на пустой массив, то загрузчика впоследствии уже не смогли бы убедить в том, что коллбэки есть. А вот сам массив коллбэков можно будет впоследствии уже менять, так как загрузчик перечитывает его каждый раз, когда он ему становится нужен.

Индекс TLS и данные, которыми инициализируется память потока, мы переместим в свою секцию (которую мы назвали kaimi.io, помните?), дабы они не перетерлись после распаковки, а непосредственно в распаковщике уже имеющийся от загрузчика индекс запишем туда, где он должен быть в оригинальном файле. Саму структуру IMAGE_TLS_DIRECTORY (точнее, IMAGE_TLS_DIRECTORY32, мы ведь пакуем 32-разрядные бинарники) мы также запишем в свою секцию. Туда же запишем и массив наших подложных коллбэков из единственного пустого, если они есть в оригинальном файле.

Переходим к написанию кода. После этого блока кода:

допишем следующее:

Теперь добавим немного кода после места, где мы пересобираем ресурсы:

Здесь мы просто сохраняем в нашу структуру с информацией об оригинальном файле всяческую необходимую информацию об оригинальном TLS. Кроме того, в нее же загрузчик теперь запишет TLS-индекс, который мы в распаковщике переложим туда, где он должен находиться.

Далее работаем с секцией "kaimi.io", в которую мы раньше записывали только тело распаковщика. Добавим ей, во-первых, атрибут доступа на запись, заменив строку

на

Заменим также строку

на

потому что с этой секцией мы теперь будем работать.

Теперь на некоторое время перейдем к проекту распаковщика (unpacker). Опишу детально, как мы будем обрабатывать TLS, имеющий коллбеки. Мы сохраняем все оригинальные адреса TLS (это мы уже сделали). Далее мы создаем свой массив коллбэков, имеющий всего один коллбэк, да и тот пустой, чтобы просто дать понять загрузчику, что коллбэки есть. Мы в новом TLS указываем на этот массив. Далее, сразу после распаковки файла мы вручную выполняем все коллбэки оригинального файла, потому что загрузчик по понятным причинам этого не сделает - у него всего один пустой коллбэк. После этого мы правим массив коллбэков, созданный нами, записывая туда все адреса уже оригинальных функций, и с этого момента управление TLS-коллбэками переходит во власть загрузчика, нам больше делать ничего не надо. Таким образом, наша текущая задача - сделать в распаковщике пустой TLS-коллбэк. Дабы не плодить дополнительных функций, просто модифицируем пролог функции unpacker_main:

Распаковщик, таким образом, начнет исполняться с инструкции jmp next, сразу перепрыгнув на свое основное тело. А вот тот самый пустой коллбэк, который нам нужен, выглядит как "ret 0xC", и на эту инструкцию мы сделаем указатель в массиве коллбэков. Эта инструкция просто отдает управление, предварительно убрав из стека 0xC = 12 байтов. Если кто-то не в курсе, прототип TLS-коллбэка выглядит так:

и использует он соглашение вызовов stdcall и три четырехбайтовых параметра. Итого, мы должны убрать из стека 3 * 4 = 12 байтов. Коллбэк не возвращает значения, поэтому модифицировать регистр eax в его теле необязательно.

Теперь заменим все эти строки:

на одно копирование структуры, а то слишком много лишнего кода получается:

Заменим теперь все обращения к вышеперечисленным переменным соответствующим образом, меняя, например, original_import_directory_rva на info_copy.original_import_directory_rva.

Поправим файлик parameters.h, у нас изменились необходимые для упаковщика смещения, кроме того, добавилось еще одно:

Последнее смещение в коде распаковщика (empty_tls_callback_offset) - это как раз смещение на инструкцию ret, осуществляющую выход из TLS-коллбэка.

Идем дальше. В отличие от директории импортов и директории ресурсов, директорию TLS мы восстанавливать не будем - это бессмысленно. Загрузчик все равно ее не будет перечитывать. Перейдем к обработке TLS. Код будем размещать в распаковщике после той части, в которой фиксим импорты. Для начала скопируем индекс, который нам предоставил загрузчик, в ячейку памяти, где он должен лежать:

Далее - более сложная часть. Обработаем TLS-коллбэки:

Сначала мы перечисляем все адреса коллбэков в оригинальном массиве и копируем их в наш массив TLS-коллбэков, чтобы загрузчик их подхватил, когда они понадобятся в следующий раз. Однако, при создании процесса загрузчик дернул только наш единственный пустой коллбэк, а PE-файл ожидает, что будут вызваны его коллбэки с параметром DLL_PROCESS_ATTACH. Поэтому нам нужен второй цикл, в котором мы вызываем все коллбэки из оригинального массива, передав в них базовый адрес загрузки образа в первом параметре и DLL_PROCESS_ATTACH (=1) во втором. Третий параметр не используется, смотрите прототип выше. Можно было бы, конечно, скопировать адреса коллбэков и вызвать их и в одном цикле, но вдруг в коллбэках бинарник модифицирует сам себя или ожидает, чтобы массив коллбэков был сразу заполнен? В любом случае, два цикла - тоже не панацея, но это более надежно.

С распаковщиком все, и теперь мы возвращаемся к упаковщику. Необходимо разместить директорию TLS в секции "kaimi.io", а также скопировать туда файловые данные, использующиеся для инициализации локальных данных новых потоков.

Опишу этот внушительный кусок кода. Сначала мы зарезервировали место под структуру IMAGE_TLS_DIRECTORY32 в последней секции с распаковщиком ("kaimi.io") сразу после его кода, затем выделели место под массив TLS-коллбэков по их оригинальному количеству (каждый из них занимает 4 байта, плюс последний элемент - нулевой). В новый массив коллбэков записали указатель на код в распаковщике, который ничего не делает, кроме выравнивания стека (ret 0xC). Это даст понять загрузчику, что коллбэки у файла есть. Далее пересчитали указатели на данные, которые загрузчик будет использовать для инициализации локальных данных потоков. Мы размещаем эти данные после структуры IMAGE_TLS_DIRECTORY32 и массива TLS-коллбэков. Потом мы пересобираем TLS с помощью библиотеки для работы с PE (по сути, она просто записывает структуру IMAGE_TLS_DIRECTORY32 куда надо и заполняет ее, автоматическую запись коллбэков и данных мы отключили). Наконец, мы пересчитываем виртуальный размер секции с учетом значения поля SizeOfZeroFill в TLS оригинального файла (мы это значение не меняем). Я точно не могу сказать, как это поле обрабатывается (и описаний толковых в интернете, увы, не нашел) - зануляет ли загрузчик данные после EndAddressOfRawData прямо внутри секции, либо же после инициализации локальной памяти потока, но лучше перестраховаться и выделить место прямо в секции. На размер упакованного файла это не влияет, так как мы увеличиваем виртуальный размер секции, а не физический. После всего этого мы наконец-то обрезаем с конца секции ненужные нулевые байты (она последняя, и мы можем так сделать, об этом я уже как-то писал) и пересчитываем виртуальный и физический размеры секции (реально может поменяться только физический, так как виртуальный мы сами назначили, и он больше или равен физическому).

Уберем теперь добавленную раньше строку:

Остается протестировать работоспособность. Обработку TLS без коллбэков можно проверить на любой скомпилированной Борландом программе. Можно также собрать программу с помощью Microsoft Visual Studio любой версии, используя в коде __declspec(thread). TLS с коллбэками сделать не так просто, я пользовался примером отсюда, компилируя его в Visual C++ 6.0, хотя можно было бы собрать TLS с коллбэками руками и на MASM32. После небольшой проверки я удостоверился, что все работает, как надо!

Честно сказать, я заметил одну особенность, которая присуща всем упаковщикам, которые я опробовал - они все не меняют значение адреса индекса TLS. Не могу пока что сказать, почему это так, но вполне вероятно, что причина такого поведения есть. В комментариях в исходных кодах UPX сказано, что массив TLS-коллбэков должен быть также, как и структура IMAGE_TLS_DIRECTORY32, выровнен на границу DWORD'а, однако я не стал этого делать, так как даже на XP PE-файл с невыровненным массивом отлично работал.

Есть еще замечание по старому коду. Внезапно выяснилось, что Win XP плохеет, если урезаны директории данных в PE-заголовке (Data Directory), и его explorer.exe перестает отображать иконки у файлов. Поэтому придется закомментировать строку

для сохранения совместимости.

Полный солюшен для этого шага: Own PE Packer, step 6

Пишем упаковщик по шагам. Шаг шестой. TLS.: 9 комментариев

  1. Честно сказать, я заметил одну особенность, которая присуща всем упаковщикам, которые я опробовал - они все не меняют значение адреса индекса TLS. Не могу пока что сказать, почему это так, но вполне вероятно, что причина такого поведения есть.
    И таки есть, да - этот индекс юзается в коде для корректного обращения к локальной памяти потоков именно данного загруженного модуля и логично, что его адрес никак не переместить без полной правки всех референсов. Индексы накидываются по мере загрузки модулей со статическим тлс, и если в случае с exe он всегда 0, то стоит появится длл со статическим тлс, и для нее он будет уже 1. В большинстве пакеров эта фишка не обдумана, и адрес для индекса тоже попадает под упаковку, а при распаковке оно перезаписывает тот индекс, что выставил загрузчик. Из этого получается, что если длл со статическими тлс, накрытая таким пакером, грузится после любого другого модуля, в котором тоже есть статический тлс, то она запарывает тлс этого модуля.

    1. А я писал, что попробовал загрузить упакованный своим упаковщиком EXE-файл со статическим TLS, который, в свою очередь, грузит DLL-файл со статическим TLS (статически прилинкован к нему). Этот DLL-файл я тоже упаковал упаковщиком. Проблем в работе не наблюдал, хотя в обоих файлах работа с TLS велась. Поэтому меня и смутило то, что индексы не перемещаются в некоторых упаковщиках.

      Поэтому не совсем понимаю, в чем проблема. Загрузчик же записывает по адресу индекса некое число (например, для exe-файлов - 0, и т.д.), а я это число в процессе распаковки кладу туда, где оно изначально должно было лежать. В процессе работы бинарника оно не меняется, так что все вроде бы как окей.

  2. В таком случае проблемы и нет, классическим путем всегда было просто не делать запись распаковываемой памяти по адресу индекса, тот же смысл.
    PS: Борландские ехешки вообще его принудительно в ноль ставят.

  3. И чего мучиетесь с полями структуры IMAGE_TLS_DIRECTORY... С ней всё достаточно просто (http://www.microsoft.com/whdc/system/platform/firmware/PECOFF.mspx).
    На всякий случай разъясню (если ошибаюсь, то подправьте, а не пинайте или закидывайте козявками):
    * Допустим, у Вас используется в программе 2 переменные - А и В.
    * А имеет тип дворд и должна инициализироваться в -1
    * В имеет пофиг какой тип, но занимает 1 метр памяти и должна быть заполнена нулями
    Таким образом:
    * в тлс секции для каждого нового потока будет выделяться память в 1 метр и 4 байта (точнее наоборот - 4 байта и 1 метр)
    * а так же проинициализированы

    Для чего я сделал уточнение 4Б + 1МБ? Вот тут-то и раскроется назначение полей тлс:
    * поля StartAddressOfRawData и EndAddressOfRawData будут указывать на -1 (начало и конец соответственно)
    * а поле SizeOfZeroFill примет значение 1 метр
    В результате "шаблон" (так называется этот кусок памяти проинициализированный в "-1" в документе по ссылке) для инициализации тлс займет 4 байта.
    И только эти 4 байта будут сидеть в исполняемом файла, и при его исполнении будут браться они же для инициализации памяти потоков. А 1 метр будет появляться "из воздуха".

    Как думаете, что произойдет если переменные А и В поменять местами?
    "Шаблон" вынужден будет заглотнуть кроме 4Б еще и 1М. И будет содержать 1МБ нулей и завершаться четырмя байтами содержащими дворд "-1".
    Т.е. поля StartAddressOfRawData и EndAddressOfRawData укажут на 1МБ+4Б, а поле SizeOfZeroFill будет равно нулю.

    В общем, эти поля всего лишь оптимизация размера "шаблона" в исполняемом файле, позволяющие не забивать его ерундой.

    1. Это-то все понятно :)
      Кстати, например, MSVC++ совершенно похрену, и полем SizeOfZeroFill он не пользуется, равно как и инициализированными данными. Он просто с помощью полей StartAddressOfRawData и EndAddressOfRawData выделяет себе необходимое количество памяти, располагает их в секции с нулевым сырым размером (потому что данные нулевые и неинициализированные), а потом инициализирует их вручную, уже как надо.

      1. Я это дело как-то проверял и пришел к выводу, что в екзешниках и дллках тлс ведет себя поразному:
        * в дллках я всегда получал то, что описали Вы
        * в екзешниках, же:
        ** индекс тлс всегда игнорируется и принимается равным нулю
        ** активно используются эти поля
        * И как Вы сказали, сложно найти модуль, использующий колбэки.

        По этому на них забивать не следует - может случиться так, что модуль (в частности екзешник) не подымится.

        В Вашей же реализации похоже, что всё будет пучком ;)

        1. Старался учесть все, даже специально нагенерировал себе всяких exe и dll-файлов с разнообразными директориями TLS, в том числе и с коллбэками)

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *