PE-формат. Часть 1 — Базовая информация

Данная статья является первым маленьким шажком на пути к написанию собственного несложного упаковщика. Знаний предстоит получить весьма большое количество, так что садитесь поудобнее, запасайтесь попкорном и готовьтесь к чтению. В статье большое количество отсылок к MSDN, поэтому не ленитесь открывать и изучать те структуры, на которые я ссылаюсь. Если будете следовать моим простым советам, обучение пойдет гораздо проще. Еще я бы рекомендовал скачать какой-нибудь PE-редактор, например, CFF Explorer и скормить ему реальный PE-файл, чтобы можно было вживую пробежаться по тем структурам, которые я здесь описываю.

PE-формат (Portable Executable) - это формат всех 32- и 64-разрядных исполняемых файлов в ОС Windows. Такой формат имеют файлы exe, dll, ocx, sys и т.д. (Разумеется, exe под DOS не в счет). В этой статье я расскажу самую базовую информацию об устройстве этого формата и его структурах. Практически самое полное и доступно изложенное описание можно найти в статье Криса Касперски, и она является обязательной к прочтению, если вы действительно решили во всем этом разобраться. Помните - никто не говорил, что будет легко, но у вас появился отличный шанс показать, что вы настоящие мужики с железными волосатыми яйцами, разобравшись во всем этом.

Каждый исполняемый файл формата PE состоит из множества взаимосвязанных структур, содержащих информацию о самом файле, об импортируемых и экспортируемых им функциях, о перемещаемых элементах, ресурсах, Thread Local Storage и многое другое. Начнем по порядку. На рисунке ниже приведена обобщенная структура файла PE-формата:

Каждый PE-файл состоит из вышеперечисленных элементов, они являются обязательными. С самого верху находится MS-DOS-заголовок - наследие еще с тех самых времен, когда происходил переход с DOS на Windows, поддерживающий новый PE-формат. Наверняка вы замечали, что все исполняемые файлы начинаются с букв "MZ" - это сигнатура как раз характерна для структуры, названной IMAGE_DOS_HEADER и располагающейся в самом начале PE-файла. Поля этой структуры по большей части нам неинтересны, так как необходимы для запуска из-под DOS. Следует обратить внимание на следующие поля: e_magic - собственно, это поле размером два байта содержит сигнатуру 'MZ' (сокращение от имени Марк Збиновски, который являлся ведущим разработчиком MS-DOS и архитектором формата PE); e_lfanew - указатель на начало PE-заголовка (см. рисунок выше). Это поле должно указывать на первый байт PE-заголовка (IMAGE_NT_HEADERS), т.е. на сигнатуру "PE\0\0", причем значение этого поля должно быть выровнено по границе двойного слова. Крис Касперски в своей статье упоминает еще поле e_cparhdr, но, по всей видимости, оно по-прежнему никем не проверяется.

Весь смысл DOS-заголовка в том, чтобы передать управление на идущую далее DOS-заглушку, если вдруг кто-то запустить виндовый бинарник под досом. Обычно эта заглушка (по сути - обычная DOS-программа) выдает текст "This program cannot be run in DOS mode.", но ничто не мешает запихать туда и досовую версию программы :)

Далее идет уже упомянутая сигнатура PE-файла (4 байта: 'P', 'E', 0, 0), после которой начинается структура IMAGE_FILE_HEADER. Эта структура подробно описана в MSDN или в статье Криса, тем не менее, я заострю внимание на некоторых ее полях:
Machine - архитектура, на которой может запускаться файл;
NumberOfSections - количество секций в PE-файле. Допустимое значение - от 1 до 0х60. Секция - это некая область памяти, обладающая определенными характеристиками и выделяемая системой при загрузке исполняемого файла;
SizeOfOptionalHeader - размер идущего за этой структурой опционального заголовка в байтах;
Characteristics - поле флагов характеристик PE-файла. Тут содержится информация о том, имеет ли файл экспортируемые функции, перемещаемые элементы, отладочную информацию и т.д.
Остальные поля при загрузке ни на что не влияют.

Далее идет опциональный заголовок PE-файла. На самом деле, никакой он не опциональный, без него файл загружен не будет, хотя размер этого заголовка может и варьироваться. И вновь я приведу описание самых важных полей:
Magic - для 32-разрядных PE-файлов это поле должно содержать значение 0х10B, а для 64-разрядных - 0х20B. Дальше я расскажу, в чем отличие 32- и 64-разрядных версий.
AddressOfEntryPoint - адрес точки входа относительно базового адреса загрузки файла (ImageBase). О способах адресации, используемых в PE-файлах, я расскажу дальше.
ImageBase - базовый адрес загрузки PE-файла. В памяти по этому адресу после загрузки будет располагаться вышеописанная структура IMAGE_DOS_HEADER. Если у файла имеется таблица перемещаемых элементов (о ней тоже далее), то этот адрес может варьироваться, а ImageBase будет содержать лишь рекомендуемый адрес загрузки.
FileAlignment и SectionAlignment - файловое и виртуальное выравнивание секций. В обязательном порядке должны быть выполнены следующие условия:
1. SectionAlignment >= 0х1000;
2. FileAlignment >= 0х200;
3. SectionAlignment >= FileAlignment.
В Windows NT возможно создание невыровненных файлов, но в этом случае физические и виртуальные адреса каждой секции должны совпадать, и SectionAlignment должно быть равно FileAlignment.

SizeOfImage - это поле содержит размер в байтах загруженного образа PE-файла, который должен быть равен виртуальному адресу последней секции плюс ее виртуальный выровненный размер.
SizeOfHeaders - размер всех заголовков. Это поле говорит загрузчику, сколько байт считать от начала файла, чтобы получить всю необходимую информацию для загрузки файла. Значение поля не должно превышать относительного виртуального адреса первой секции.
CheckSum - контрольная сумма файла, которая проверяется загрузчиком только для самых важных системных файлов.
Subsystem - подсистема файла. Самые распространенные - IMAGE_SUBSYSTEM_WINDOWS_GUI (GUI-интерфейс Windows) и IMAGE_SUBSYSTEM_WINDOWS_CUI (консольный интерфейс). За остальными - в статью Криса или MSDN.
SizeOfStackReserve и SizeOfStackCommit, SizeOfHeapReserve и SizeOfHeapCommit - размер соответственно стека и кучи, которые должны быть зарезервированы и выделены для PE-файла. 0 - значение по умолчанию. Если SizeOfStackCommit > SizeOfStackReserve или SizeOfHeapCommit > SizeOfHeapReserve, то файл загружен не будет.
NumberOfRvaAndSizes - количество элементов в таблице DATA_DIRECTORY, расположенной в самом конце опционального заголовка. Может варьироваться от 0 до 16, но все линковщики ставят значение 16, даже если не используют все элементы таблицы. Это связано с ошибками в системном загрузчике (как я понял, только в Win7 загрузчик наконец-то не содержит этих ошибок).
Остальные поля снова никому не сдались, в том числе и системному загрузчику.

Перед тем, как рассматривать таблицу DATA_DIRECTORY, я расскажу о способах адресации, которые наиболее часто используются в PE-файлах.
1. Виртуальная адресация, VA. Такие адреса отсчитываются от начала адресного пространства (т.е. от 0) и являются абсолютными.
2. Относительная виртуальная адресация (RVA). Эти адреса отсчитываются от базового адреса загрузки образа исполняемого файла, т.е. от того адреса, по которому был загружен исполняемый файл.
3. Сырые адреса, т.е. адреса непосредственно от начала файла формата PE на диске, а не в памяти.
Некоторые структуры используют и другие типы адресации.
RVA и VA легко преобразуются друг в друга: VA = RVA + базовый адрес загрузки.
Как я уже говорил выше, базовый адрес загрузки содержится в поле ImageBase опционального заголовка PE, но может варьироваться, если файл имеет таблицу перемещаемых элементов (relocations).

Теперь перейдем к разбору DATA_DIRECTORY. Каждый элемент в этой таблице (IMAGE_DATA_DIRECTORY), располагающейся в конце опционального заголовка, имеет собственное назначение. Лучше всего про это прочесть в MSDN или в статье Криса.
Каждый элемент таблицы представляет из себя структуру, содержащую два поля - виртуальный адрес тех данных, на которые указывает данный элемент, и их размер. Например, первый элемент таблицы (IMAGE_DIRECTORY_ENTRY_IMPORT) указывает на таблицу импортируемых функций из различных модулей (как правило, DLL-файлов).
Некоторые из подобных таблиц я разберу в следующих статьях, но, если не терпится узнать побольше прямо сейчас, читайте опять-таки статью Касперски, хотя и там не все эти таблицы описаны.

За DATA_DIRECTORY (т.е. после конца опционального заголовка) начинается таблица секций. Между концом опционального заголовка и таблицей секций могут присутствовать неиспользуемые байты. Существует макрос, позволяющий найти начало таблицы секций, называется он IMAGE_FIRST_SECTION и определен в WinNT.h. Каждая секция описывается структурой IMAGE_SECTION_HEADER, и идут эти структуры друг за другом. Их количество содержится в поле NumberOfSection файлового заголовка.
Как обычно, опишу только реально используемые загрузчиком поля этой структуры.
Name - имя секции. Предоставляется только для удобства и может содержать что угодно. Единственное, что нужно знать - имя секции, содержащей ресурсы файла, должно всегда быть равно ".rsrc", иначе Проводник Windows не сможет отобразить информацию о версии файла и его иконку. Разумеется, это косяк разработчиков Windows.
VirtualAddress - VA секции в памяти, должен быть выровнен на величину Section Alignment.
PointerToRawData - указатель на данные в файле, которые будут использоваться для инициализации памяти секции, должен быть выровнен на величину File Alignment.
VirtualSize и SizeOfRawData - виртуальный и физический размер секции, соответственно. Значение VirtualSize может быть невыровненным, но при загрузке образа всегда автоматически выравнивается по границе SectionAlignment. Значение SizeOfRawData обязано быть выровненным на границу FileAlignment, иначе файл загружен не будет. Впрочем, SizeOfRawData для последней секции может быть и невыровненным. Если SizeOfRawData <= VirtualSize, то оставшиеся (VirtualSize - SizeOfRawData) байт будут заполнены нулями. В противном случае поведение загрузчика не определено, потому такие PE лучше не делать. Если VirtualSize = 0 и SizeOfRawData = 0, то файл не загрузится. Если VirtualSize = 0 и SizeOfRawData != 0, то файл загрузится, а в памяти под секцию будет выделено VirtualSize, выровненное на Section Alignment, байтов. Characteristics - поле характеристик секции. Реально тут используются только флаги IMAGE_SCN_MEM_EXECUTE, IMAGE_SCN_MEM_READ, IMAGE_SCN_MEM_WRITE, настраивающие атрибуты памяти секции (исполнение, чтение, запись соответственно), а также IMAGE_SCN_MEM_DISCARDABLE - секция может быть выгружена после использования (таким свойством обладает таблица перемещаемых элементов), IMAGE_SCN_MEM_SHARED - секция совместно используется несколькими PE-файлами.

Остальные поля, как всегда, загрузчиком игнорируются.

Осталось сказать про отличия PE32 и PE64. Единственное отличие в структурах этих форматов заключается в том, что все виртуальные адреса (VA) - это 64-разрядные числа (8-байтовые). Все относительные адреса (RVA) так и остались 32-разрядными.

Вот и все, что я хотел рассказать в первой статье про формат исполняемых файлов Portable Executable. Будут еще статьи, в которых я опишу специальные таблицы (экспорт, импорт, перемещаемые элементы и т.д.).

PE-формат. Часть 1 — Базовая информация: 17 комментариев

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

    Есть экзешник, к которому по не вполне ясным причинам компилятор приделал таблицу экспорта. Посмотрел в OllyDbg, вроде он из себя ничего не грузит. Пробовал удалить секцию с помощью утилиты strip, но тогда экзешник перестает загружаться, дескать "неверный формат". В чем может быть проблема? Может, IMAGE_FILE_HEADER.Characteristics нужно подправить? Или еще какие-то тонкие моменты могут быть?

    1. Из IMAGE_FILE_HEADER.Characteristics надо убрать флаг IMAGE_FILE_DLL, скорее всего он там есть.
      Убить экспорт несложно, достаточно занулить VirtualAddress и Size в нулевой IMAGE_DATA_DIRECTORY (удобно сделать с помощью того же CFF Explorer'а), а потом убрать флаг IMAGE_FILE_DLL.

  2. Еще небольшой вопрос. А размеры полей в PE-заголовке для 64-х битных программ отличаются? Или там просто флаг, дескать прога 64-х битная. а остальное - как обычно? Насколько я понимаю, там нет смысла удваивать размеры полей.

    1. Я же в статье написал, в чем отличие. В PE64 все поля, которые содержат VA, имеют размер QWORD, т.е. 64 бита.

  3. >Между концом опционального заголовка и таблицей секций могут присутствовать неиспользуемые байты.
    У других авторов написано, что нет, она сразу следует за заголовком. На практике я не встречал, чтобы там был пробел.

    1. [image_file_header] SizeOfOptionalHeader

      Размер опционального заголовка, идущего следом за IMAGE_FILE_HEADER'ом. Должен указывать на первый байт Section Table (т.е. e_lfanew + 18h + SizeOfOptionalHeader = &Section Table), где 18h - sizeof(IMAGE_FILE_HEADER). Если это не так, файл не загружается. И хотя некоторые загрузчики вычисляют указатель на Section Table отталкиваясь от NumberOfRvaAndSizes, закладываться на это не стоит, т.к. системные загрузчики этого мнения не разделяют.

      Вот цитата из статьи Криса. По всей видимости, ничто мешает SizeOfOptionalHeader сделать больше, чем реальный размер OptionalHeader. Например, можно урезать NumberOfRvaAndSizes, не изменяя при этом SizeOfOptionalHeader, и получить некоторое количество неиспользуемых байтов. Сам не пробовал так делать (или пробовал, но уже не помню об этом), но не думаю, что это сделает бинарник неработоспособным.

    2. Вот, сделал бинарник с урезанным NumberOfRvaAndSizes (разумеется, он запускается и работает). В нем, если в hex-редакторе глянуть, между опциональными заголовками и таблицей секций написано, что Вася Пупкин передает привет Москве :)

      http://www.sendspace.com/file/4ee6l6

  4. Да, вынужден согласиться. Оф-стандарт подтверждает Криса:
    This table immediately follows the optional header, if any. This positioning is required because the file header does not contain a direct pointer to the section table. Instead, the location of the section table is determined by calculating the location of the first byte after the headers. Make sure to use the size of the optional header as specified in the file header.

    Кстати, одно замечание про NumberOfRvaAndSizes.
    >Может варьироваться от 0 до 16, но все линковщики ставят значение 16, даже если не используют все элементы таблицы. Это связано с ошибками в системном загрузчике (как я понял, только в Win7 загрузчик наконец-то не содержит этих ошибок).
    Я не думаю, что дело в загрузчике (хотя может и в нем тоже). В стандарте ничего не сказано про это, но, скажем, в winnt.h прописано жестко: #define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16 и далее в описании структуры: IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];. То есть, как бэ негласное соглашение. Ну или другими словами, правая рука не знала, что делает левая.

    PS. Прикол от моего любимого NeoHexEditor:
    http://imglink.ru/pictures/01-07-12/8db65cf426b34389a5c33eb4ababcb4a.jpg

    1. В WinXP'овом загрузчике проблем с отсутствием неких неиспользуемых DataDirectories нет в целом, мой бинарник, который я ради примера сделал, запускается и на ней. Но там есть проблема, например, если будет отсутствовать столько DataDirectory'ий, что Debug Directory тоже не будет (не помню уже, до какого числа надо урезать NumberOfRvaAndSizes, чтобы такое получилось). В таком случае, даже если Debug-директория была пустой (заполненной нулями), в XP файл не загрузится. В Win Nt (и, кажется, при каких-то условиях и в XP) файл не загрузится, если у него нет таблицы импорта. Были и еще какие-то ошибки, не помню уже. В Vista какие-то из них поправили, в семерке вроде как совсем всё хорошо.

      С NumberOfRvaAndSizes ошибки есть у многих. В NeoHexEditor, как я вижу, совсем всё плохо :)
      В Cff Explorer'е при NumberOfRvaAndSizes < 16 отображается (NumberOfRvaAndSizes - 1), т.е. на одну директорию меньше, но с таблицей секций при этом все нормально.

  5. На мой взгляд самая адекватная статья в Руненте про формат PE. Мыщъх хоть и пишет много, пытается дать как можно больше инфы, но мягкоговоря описывает всё не совсем внятно. На статью наткнулся случайно, остановился почитать - не зря потратил время, сам хоть и кодер, но время потратил не зря. DX, ты молодец ;)

  6. "Практически самое полное и доступно изложенное описание можно найти в статье Криса Касперски"... - а название статьи не подскажете?

  7. Большое спасибо) Я ее нашел, но есть и еще одна (кому интересно из тех, кто будет читать комменты: "Техника внедрения и удаления кода из PE-файлов"). Вот и гадал, какая из двух подразумевается. Не считая его книги "Компьютерные вирусы изнутри и снаружи".

  8. Дебильную рожу в начале лучше оставить т.к. она "отфильтровывает" совсем дибилов которые напрягаются от ее вида. Ради Вашего же блага, ептель...

    p.s. Статейка зачет, в принципе как и всегда. Зашел перечитать - подзамылилось немного...

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

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