Динамическая компоновка импортируемых функций в Mach-O
Зная принцип связывания импортируемых функций в библиотеках Mach-O, можно достичь очень интересного эффекта: перенаправлять их вызовы на свой код, в котором, в свою очередь, пользоваться оригналом. Для этого достаточно притвориться динамическим загрузчиком и подкорректировать в памяти таблицу импорта целевой библиотеки. Давайте посмотрим на формат Mach-O и на то, как динамический загрузчик производит переразмещение его таблицы импорта.
Лучший способ понять Mach-O – это посмотреть на картинку ниже.
Похоже, человечество еще не сумело изобразить его структуру более наглядно. В первом приближении все выглядит примерно так:
Заголовок - здесь хранится информация о целевой архитектуре и различные опции дальнейшей интерпретации содержимого файла.
Команды загрузки - сообщают как и куда загружать части Mach-O: сегменты (см. ниже), таблицы символов, а также - от каких библиотек зависит этот файл, чтобы сперва загрузить их
Сегменты - описывают регионы памяти, куда загружать секции с кодом или данными.
Для второго приближения придется познакомится с некоторыми утилитами:
otool - представляет собой консольную программу, поставляемую вместе с системой. Она способна отображать содержимое различных частей файла: заголовков, команд загрузки, сегментов, секций и прочее. Особо полезно добавлять при вызове ключ -v (verbose).
MachOView - распространяется под GPL, имеет GUI, работает только на Mac OS 10.6 и выше. Позволяет просматривать полное содержимое Mach-O, дополняет информацию по некоторым разделам, на основании данных из других частей, что очень удобно.
По большому счету, чтобы обычному пользователю разобраться с Mach-O, достаточно поиграть с MachOView на различных примерах. Но, этого недостаточно для программирования Mach-O, поскольку неизвестны точные структуры заголовков, команд загрузки, сегментов, секций, таблиц символов и точное описание их полей. Но, это не большая беда, при наличии спецификации. А она всегда доступна на официальном сайте Apple. А при наличии установленных средств разработки, можно заглянуть в заголовочные файлы из /usr/include/mach-o (особенно loader.h). Кроме того, стоит помнить, что, хотя содержимое файла и размещается в памяти в точно в таком же порядке, как оно есть на диске, но во время загрузки компоновщик может удалять некоторые части таблицы символов, всю таблицу строк и проставлять значения реальных смещений в памяти там, где это потребуется, в то время, как в файле, эти значения могут вообще быть обнулены или соответствовать смещению на диске. Структура заголовка проста (привожу для 32-битной архитектуры, 64-битная не сильно отличается):
struct mach_header { uint32_t magic; cpu_type_t cputype; cpu_subtype_t cpusubtype; uint32_t filetype; uint32_t ncmds; uint32_t sizeofcmds; uint32_t flags; };
Все начитается с магического значения (0xFEEDFACE или наоборот, в зависимости от соглашения относительно порядка байт в машинных словах). Затем указан тип архитектуры процессора, количество и размер команд загрузки и флаги, описывающие прочие особенности. Например:
Существенные команды загрузки перечислены ниже:
LC_SEGMENT - содержит различную информацию о некотором сегменте: размер, количество секций, смещение в файле и, после загрузки, в памяти
LC_SYMTAB - загружает таблицу символов и строк
LC_DYSYMTAB - создает таблицу импорта, данные о символах берутся из таблицы символов
LC_LOAD_DYLIB - указывает зависимость от некоторой сторонней библиотеки
Например (32- и 64-битные версии соответственно):
Наиболее важные сегменты следующие:
__TEXT - исполняемый код и други данные только для чтения
__DATA - данные, доступные для записи; в том числе и таблицы импорта, которые имеют свойство изменяться динамическим загрузчиком во время позднего связывания
__OBJC - различная информация стандартной библиотеки языка Objective-C времени выполнения
__IMPORT - таблица импорта исключительно для 32-битной архитектуры (у меня генерировалась только на Mac OS 10.5)
__LINKEDIT - здесь динамический загрузчик располагает свои данные для уже загруженных модулей: таблицы символов, строк и прочее
Любая команда загрузки начинается такими полями:
struct load_command { uint32_t cmd; //числовой код команды uint32_t cmdsize; //размер текущей команды в байтах };
После которых могут идти еще много различных полей, в зависимости от типа команды. Например:
Самые интересные секции в перечисленных сегментах такие:
__TEXT,__text - собственно код
__TEXT,__cstring - константные строки (в двойных кавычках)
__TEXT,__const - различные константы
__DATA,__data - инициализированные переменные (строки и массивы)
__DATA,__la_symbol_ptr - таблица указателей на импортируемые функции
__DATA,__bss - неинициализированные статические переменные
__IMPORT,__jump_table - заглушки для вызовов импортируемых функций
Забегая вперед, отмечу, что в одном Mach-O в качестве таблицы импорта может быть либо __IMPORT,__jump_table (32 бита, Mac OS 10.5), либо __DATA,__la_symbol_ptr (64 бита, либо Mac OS 10.6 и старше). Секции в сегментах имеют следующую структуру:
struct section { char sectname[16]; char segname[16]; uint32_t addr; uint32_t size; uint32_t offset; uint32_t align; uint32_t reloff; uint32_t nreloc; uint32_t flags; uint32_t reserved1; uint32_t reserved2; };
Имеем имя сегмента и самой секции, размер, смещение в файле и адрес в памяти, по которому динамический загрузчик ее разместил. Кроме того, присутствует и другая, специфическая для конкретной секции информация. Например:
Безусловно, стоит упомянуть, что, в следствии неоднократной плавной смены компанией Apple своих целевых архитектур (Motorola -> IBM -> Intel), исполняемые файлы и библиотеки "научились" хранить сразу несколько вариантов исполняемого кода. В общем случае, такие файлы называют fat binary. По сути, это несколько Mach-O, собранных в одном файле, но заголовок у него особый. Он содержит информацию о количестве и типе поддерживаемых архитектур и смещения к каждой из них. По такому смещению находятся обычные Mach-O со структурой, описанной выше. Вот как это выглядит на языке С:
struct fat_header { uint32_t magic; uint32_t nfat_arch; };
Где под magic скрывается 0xCAFEBABE (или наоборот - помним про разный порядок байт в машинных словах на разных процессорах). А после, незамедлительно следует ровно nfat_arch структур типа:
struct fat_arch { cpu_type_t cputype; cpu_subtype_t cpusubtype; uint32_t offset; uint32_t size; uint32_t align; };
Собственно, названия полей говорят сами за себя: тип процессора, смещение в файле конкретного Mach-O, размер и выравнивание.
Для исследования работы вызова импортируемой функции возьмем следующие файлы на языке С:
//file test.c void libtest(); //from libtest.dylib int main() { libtest(); //calls puts() from libSystem.B.dylib return 0; } //file libtest.c #include <stdio.h> void libtest() //just a simple library function { puts("libtest: calls the original puts()"); }
Исследуем динамическую компоновку
Ограничимся процессорами Intel. Пускай у нас Mac OS 10.5. Добавим эти файлы в новый Xcode-проект, скомпилируем (32-битную версию) и запустим в отладочном режиме, остановившись на строчке, где в функции libtest() библиотеки libtest.dylib происходит вызов функции puts(). Вот ассемблерный листинг для libtest():
Выполним еще одну инструкцию:
И посмотри на нее в памяти:
Это и есть та ячейка таблицы импорта (в данном случае - ячейка __IMPORT, __jump_table), которая служит трамплином для вызова динамического загрузчика (функция __dyld_stub_binding_helper_interface), если используется позднее связывание (lazy binding), либо прыгает сразу на целевую функцию. Что подтверждается последующим вызовом puts():
Итак, мы видим, что динамический загрузчик заменил инструкцию косвенного вызова CALL (0xE8) на инструкцию косвенного перехода JMP (0xE9). Стало быть, для перенаправления элементов __jump_table нам достаточно будет прописывать вместо их изначального содержимого инструкцию косвенного перехода на начало функции-подстановки. Еще интересный момент. Почему для перехода на динамический загрузчик (он же компоновщик) не используется JMP? Да потому, что CALL, сохраняющий адрес возврата в стеке, поможет компоновщику определить, какой элемент таблицы импорта его вызвал. А, значит, и вычислить, что это был за символ и разрешить его, поменяв CALL на себя на косвенный JMP на требуемую функцию. Теперь перенесем проект на Mac OS 10.6 и скомпилируем fat binary для 32- и 64-битных архитектур. На всякий случай, в Xcode это можно сделать так:
Компилируем, запускаем 64-битный вариант (просто для примера; таблица импорта на Snow Leopard будет одинаковая и для 32-бит) и останавливается снова на вызове puts():
И снова простой CALL. Смотрим дальше:
Вот тут уже заметно различие с обычным __IMPORT, __jump_table. Добро пожаловать в __TEXT, __symbol_stub1. Данная таблица представляет из себя набор инструкций JMP для каждой импортируемой функции. В нашем случае там только одна такая инструкция, представленная выше. Каждая такая инструкция осуществляет переход на адрес, указанный в соответствующей ячейке таблицы __DATA, __la_symbol_ptr. Последняя и является таблицей импорта для этого Mach-O. Но, продолжим исследование. Если заглянуть по адресу, на который собирается произойти переход:
Мы попадаем в секцию __TEXT, __stub_helper. По сути, это PLT (Procedure Linkage Table) для Mach-O. Первой инструкцией (в данном случае - это LEA в связке с R11, а могла быть и простая PUSH) динамический компоновщик запоминает, что за символ требует переразмещения, вторая инструкция всегда ведет на один и тот же адрес - начало функции __dyld_stub_binding_helper, которая и займется связыванием:
После того, как динамический компоновщик выполнит переразмещения для puts(), соответствующая ячейка в __DATA, __la_symbol_ptr будет иметь вид:
А это уже и есть адрес функции puts() из модуля libSystem.B.dylib. То есть, подменив его каким-то своим адресом, мы получим требуемый эффект перенаправления вызова. Итак. На данном этапе мы на конкретном примере выяснили, как происходит динамическое связывание, какие бывают таблицы импорта в Mach-O и из каких элементов они состоят. Теперь приступим к разбору Mach-O!
Поиск элемента в таблице импорта
Нужно по имени символа найти соответствующую ему ячейку в таблице импорта. Алгоритм этого действия несколько нетривиален. Во-первых, нужно найти сам символ в таблице символов. Последняя представляет из себя массив следующих структур:
struct nlist { union { int32_t n_strx; } n_un; uint8_t n_type; uint8_t n_sect; int16_t n_desc; uint32_t n_value; };
Где n_un.n_strx - смещение в байтах от начала таблицы строк имени этого символа. Остальное касается типа символа, секции, в котором он находится и так далее. Словом, вот ее несколько последних элементов для нашей подопытной библиотеки libtest.dylib (32-битная версия):
Таблица строк - это цепочка имен, каждое из которых завершается нулем. Однако, стоит обратить внимание, что к каждому имени компилятор добавляет в начало нижнее подчеркивание "_", поэтому, например имя "puts" будет выглядеть в таблице строк как "_puts". Вот пример:
Узнать место нахождения таблицы символов и строк можно из соответствующей команды загрузки (LC_SYMTAB):
Однако, таблица символов неоднородна. В ней существует несколько разделов. Один из них нам особо интересен - это неопределенные (undefined) символы, то есть те, которые компонуются динамически. Кстати, MachOView подсвечивает таковые синеватым фоном. Для того, чтобы определить какая часть таблицы символов отражает подмножество неопределенных символов, нужно заглянуть в команду загрузки динамических символов (LC_DYSYMTAB):
Вот ее представление на языке С:
struct dysymtab_command { uint32_t cmd; uint32_t cmdsize; uint32_t ilocalsym; uint32_t nlocalsym; uint32_t iextdefsym; uint32_t nextdefsym; uint32_t iundefsym; uint32_t nundefsym; uint32_t tocoff; uint32_t ntoc; uint32_t modtaboff; uint32_t nmodtab; uint32_t extrefsymoff; uint32_t nextrefsyms; uint32_t indirectsymoff; uint32_t nindirectsyms; uint32_t extreloff; uint32_t nextrel; uint32_t locreloff; uint32_t nlocrel; };
Здесь dysymtab_command.iundefsym - это индекс в таблице символов, с которого начинается подмножество неопределенных символов. dysymtab_command.nundefsym - количество неопределенных символов. Поскольку то, что мы ищем, является заведомо неопределенным символом, то и искать его в таблице символов нужно только в этом подмножестве. А теперь, очень важный момент: найдя символ по его имени, самое главное для нас - запомнить его индекс в таблице символов от ее начала. Поскольку из числовых значений этих индексов состоит другая важная таблица - таблица косвенных (indirect) символов. Найти ее можно по значению dysymtab_command.indirectsymoff, а количество индексов определяет dysymtab_command.nindirectsyms. В нашем тривиальном случае эта таблица состоит всего из одного элемента (в реальной жизни их намного больше):
И в конце концов, давайте посмотрим на секцию __IMPORT, __jump_table, некоторый элемент которой и нужно отыскать в конечном итоге. Она выглядит вот так:
Поле section.reserved1 для этой секции имеет очень важное значение (MachOView назвал его Indirect Sym Index). Оно означает индекс в таблице косвенных символов, с которого начинается взаимно однозначное соответствие с элементами __jump_table. А мы помним, что элементы в таблице косвенных символов представляют собой индексы в таблице символов. Улавливаете, к чему я клоню? Но, перед тем, как окончательно собрать все осколки знаний воедино, для полноты картины бегло посмотрим на ситуацию в Snow Leopard, где роль таблицы импорта играет __DATA, __la_symbol_ptr. На самом деле, различия не особо ощутимы. Вот команда загрузки символов:
А вот и ее последние элементы:
На синеватом фоне видны два неопределенных символа, что соответствует данным из команды загрузки динамических символов (LC_DYSYMTAB):
Да и в таблице косвенных символов уже не один элемент, а четыре:
Однако, если посмотреть на поле reserved1 заветной секции __la_symbol_ptr, можно обнаружить, что взаимно однозначное отражение ее элементов на таблицу косвенных символов начитается не с начала последней, а с четвертого элемента (индекс равен 3):
Само же содержимое таблицы импорта, что описывает секция __la_symbol_ptr, будет такое:
Узнав обо всех этих тонкостях Mach-O, можно сформулировать алгоритм поиска нужного элемента в таблице импорта, что является предметом рассмотрения следующей статьи.
Mac OS X ABI Mach-O File Format Reference
Mach-O Programming Topics
Dynamic Linking: ELF vs. Mach-O
Dynamic symbol table duel: ELF vs Mach-O, round 2
Runtime binary loading via the dynamic loader on Apple Mac OS X
Let your Mach-O fly - Black Hat
Advanced Mac OS X Physical Memory Analysis - Black Hat