Существует несколько форматов исполняемых файлов. Один из таких форматов — Portable Executable (PE), используемый в преимущественно в операционной системе Windows. В этой лабораторной работе вам предстоит разобраться с устройством файлов формата PE, а также написать разборщик файлов такого формата.
Полезные материалы:
В результате выполнения задания у вас получится исполняемый файл pe-parser
(в этот раз не PE, а ELF). Он должен принимать два аргумента командной строки — название операции и путь к исполняемому файлу. Операции и примеры будут приведены ниже.
В рамках этого задания вам не потребуется запускать какие-либо PE-файлы.
В директории examples
находятся примеры — они помогут вам при написании и отладке кода. Также к каждому примеру приложен исходный код на языке C.
Выполняйте задание в ветке
pe
.
Разработчики формата PE решили сохранить обратную совместимость с MS-DOS и исполняемыми файлами DOS MZ — любой исполняемый PE-файл одновременно является корректным файлом формата DOS MZ.
Однако, разработчики программ, как правило, не заботятся об обратной совместимости с DOS, поэтому большинство PE-файлов начинаются с заглушки — она выводит сообщение о том, что исполняемый файл не может быть запущен под DOS:
> 1.EXE
This program cannot be run in DOS mode
Такая заглушка может иметь произвольный размер, поэтому в файле существует специальное поле — четыре байта, начиная с адреса 0x3C
, обозначают позицию, на которой начинается сигнатура PE-файла. В корректном исполняемом PE-файле начиная с этой позиции должны быть расположены четыре символа: ['P', 'E', '\0', '\0']
.
Реализуйте операцию is-pe
, которая по исходному файлу проверяет, является ли он исполняемым файлом формата Portable Executable. В этом задании достаточно проверить лишь сигнатуру файла.
- Если был передан PE-файл, выведите
PE
и выйдите с кодом 0. - Если был передан не PE-файл, выведите
Not PE
и выйдите с кодом 1.
Пример исполнения программы:
$ ./pe-parser is-pe ./examples/1/1.exe
PE
$ ./pe-parser is-pe ./pe-parser
Not PE
$
Напишите Makefile
для сборки вашей программы. Необходимо реализовать две цели — all
(собирает файл pe-parser
в корневой директории) и clean
(удаляет артефакты сборки).
Вы можете добавить иные правила на ваше усмотрение.
Для запуска тестов добавьте ещё одну цель:
validation-pe-tests: all
python3 -m tests ValidatingPeTestCases -f
Следующая операция, которую мы реализуем — import-functions
. Она позволит вывести функции, которые требуются для работы исполняемого файла, а также DLL-библиотеки, экспортирующие их.
К примеру, второй пример использует функцию __stdio_common_printf
из библиотеки api-ms-win-crt-stdio-l1-1-0.dll
.
В этой части задания мы реализуем просмотр списка библиотек.
В этой и следующих частях вы можете считать, что вам всегда необходимо обрабатывать только корректные PE-файлы в формате PE32+. Поведение вашего решения на некорректных файлах проверяться не будет. В частности, не нужно исполнять код из задания 1 перед выводом списка зависимостей.
Рассмотрим структуру PE-файла. Сразу за подписью идёт 20-байтовый заголовок COFF. В нём ничего важного мы не найдём.
Сразу после него идёт Optional Header размером 240 байт — в нём, среди прочего, находится и информация о таблице импорта. Первые два байта этого заголовка определяют один из двух форматов PE — PE32
для 32-битных систем или 64-битный PE32+
. В рамках этого задания будем разбирать только формат PE32+
.
В Optional Header нас интересуют два поля — адрес и размер таблицы импорта. Оба этих поля имеют размер 4 байта. Они идут друг за другом по смещению 0x78
от начала заголовка. Однако, не всё так просто — вместо настоящей позиции нам дают relative virtual address (RVA) — смещение относительно начала исполняемого файла в оперативной памяти после его загрузки. Назовём этот адрес import_table_rva
. Нам же нужно получить позицию этой таблицы в нашем файле.
Все данные в файле (как сами инструкции, так и различные метаданные — в частности, искомая таблица импорта) расположены в секциях файла. Заголовки секций идут сразу же после Optional Header.
Размер каждого заголовка — 40 байт. В заголовке нас интересует размер секции в виртуальном адресном пространстве (назовём это значение section_virtual_size
) по смещению 0x8
, виртуальный адрес начала секции (section_rva
, 0xC
) и указатель на начало данных секции в файле (section_raw
, 0x14
). Нетрудно понять, что наша таблица импорта будет расположена в этой секции, если import_table_rva
находится где-то между section_rva
и section_rva + section_virtual_size
. Как правило, таблица импорта находится в секции .rdata
или .idata
, но компилятор может поместить таблицу в произвольное место.
Теперь мы можем найти, где же начинается наша таблица импорта:
import_raw = section_raw + import_table_rva - section_rva
Сама таблица импорта (Import Directory Table) состоит из набора зависимостей. Каждая зависимость описывается 20-байтовой записью. Четыре байта, начиная со смещения 0xC
, содержат RVA имени библиотеки.
Последняя запись — пустая, состоит из двадцати нулевых байтов.
Таким образом, ваша реализация операции import-functions
на этом этапе должна находить Import Directory Table и извлекать из неё названия DLL-файлов, которые требуются для запуска исполняемого файла:
$ ./pe-parser import-functions examples/2/2.exe
VCRUNTIME140.dll
api-ms-win-crt-runtime-l1-1-0.dll
api-ms-win-crt-math-l1-1-0.dll
api-ms-win-crt-stdio-l1-1-0.dll
api-ms-win-crt-locale-l1-1-0.dll
api-ms-win-crt-heap-l1-1-0.dll
KERNEL32.dll
$
Запустите тесты, дописав соответствующую цель в Makefile
:
import-dll-tests: all
python3 -m tests ImportDllTestCases -f
В Windows существует несколько способов импортировать функции. Мы в рамках задания рассмотрим лишь один из них — импорт по имени функции. Дополним операцию import-functions
выводом имён импортируемых функций.
Первые четыре байта каждой записи в Import Directory Table — RVA другой таблицы. Она называется Import Lookup Table и как раз содержит информацию об именах необходимых функций.
Import Lookup Table в файлах PE32+
состоит из записей размером 8 байт. Самый старший бит отвечает за способ импорта — значение 0 означает импорт по имени функции, а 1 — импорт по номеру внутри библиотеки. Мы рассматриваем только случай со старшим битом 0 (записи с битом 1 пропускаются). В таких записях младший 31 бит содержит RVA названия функции.
Как и в случае с Import Directory Table, последняя запись всегда состоит из 8 нулевых байт.
Добавьте в реализацию функции import-functions
вывод имён функций — под соответствующим этим функциям названием библиотеки выведите названия импортируемых из неё функций с отступом в 4 пробела. Порядок вывода библиотек и функций не важен (однако, выводить функции нужно именно после нужной библиотеки).
$ ./pe-parser import-functions examples/2/2.exe
VCRUNTIME140.dll
__current_exception
__current_exception_context
memset
__C_specific_handler
api-ms-win-crt-stdio-l1-1-0.dll
__stdio_common_vfprintf
__acrt_iob_func
__p__commode
_set_fmode
api-ms-win-crt-runtime-l1-1-0.dll
_register_onexit_function
_crt_atexit
terminate
_seh_filter_exe
_set_app_type
_cexit
_register_thread_local_exe_atexit_callback
__p___argv
__p___argc
_c_exit
_exit
exit
_initterm_e
_initterm
_get_initial_narrow_environment
_initialize_narrow_environment
_configure_narrow_argv
_initialize_onexit_table
api-ms-win-crt-math-l1-1-0.dll
__setusermatherr
api-ms-win-crt-locale-l1-1-0.dll
_configthreadlocale
api-ms-win-crt-heap-l1-1-0.dll
_set_new_mode
KERNEL32.dll
GetCurrentThreadId
RtlLookupFunctionEntry
RtlVirtualUnwind
UnhandledExceptionFilter
SetUnhandledExceptionFilter
GetModuleHandleW
IsDebuggerPresent
InitializeSListHead
GetSystemTimeAsFileTime
RtlCaptureContext
GetCurrentProcessId
QueryPerformanceCounter
IsProcessorFeaturePresent
TerminateProcess
GetCurrentProcess
$
Обратите внимание на формат вывода: перед названиями функций должно быть ровно четыре пробела, лишних строк в выводе быть не должно.
Тесты к этой части можно запустить с помощью цели в Makefile
:
import-function-tests: all
python3 -m tests ImportFunctionTestCases -f
Помните, что бонус сдаётся только вместе с основным заданием.
Динамические библиотеки — файлы .dll
— также являются файлами формата PE. Как правило, они используются только для экспорта каких-то функций для других исполняемых файлов.
В этом задании вам необходимо реализовать операцию export-functions
. Она должна выводить список всех экспортируемых библиотекой функций в любом порядке.
$ ./pe-parser export-functions ./examples/3/3.dll
sum_three_ints
sum_two_doubles
sum_two_ints
$
Запустите тесты, добавив ещё одну цель в Makefile
:
export-function-tests: all
python3 -m tests ExportFunctionTestCases -f