Pull to refresh

Перенаправление функций в Mach-O библиотеках

Reading time 13 min
Views 8.7K
В предыдущей статье был описан метод перехвата вызовов для разделяемых библиотек ELF. А сейчас мы посмотрим как сделать то же самое с библиотеками в формате Mach-O.

Вкратце напомню ситуацию. Имеем программу под Mac OS X, которая пользуется множеством сторонних динамически-компонуемых библиотек, которые, в свою очередь, также пользуются функциями друг друга.

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

Как обычно, нетерпеливые могут все скачать и попробовать прямо сейчас.

Для наглядности — воображаемый пример: у нас есть программа с названием «test» на языке С (файл test.c) и разделяемая библиотека (файл libtest.c), с неизменным содержимым, откомпилированные заранее. Эта библиотека предоставляет одну функцию: libtest(). В своей реализации каждая из них пользуется функцией puts() из стандартной библиотеки языка С (поставляется вместе с Mac OS, содержится в libSystem.B.dylib). Посмотрим на схематическое изображение описываемой ситуации:

Задача состоит в следующем:
  1. Нужно заменить вызов функции puts() для библиотеки libtest.dylib на вызов функции hooked_puts(), реализованной в главной программе (файл test.c), которая, в свою очередь, может пользоваться оригинальной puts();
  2. Отменить произведенные изменения, то есть сделать так, чтобы повторный вызов libtest() приводил к вызову оригинальной puts().
При этом менять код или перекомпилировать сами библиотеки не разрешается, только главную программу. Само перенаправление вызова должно осуществляться только для конкретной библиотеки и налету, без перезапуска программы.

Кратко о Mach-O


Лучший способ понять Mach-O – это посмотреть на картинку ниже.

Похоже, человечество еще не сумело изобразить его структуру более наглядно. В первом приближении все выглядит примерно так:
  1. Заголовок — здесь хранится информация о целевой архитектуре и различные опции дальнейшей интерпретации содержимого файла.
  2. Команды загрузки — сообщают как и куда загружать части Mach-O: сегменты (см. ниже), таблицы символов, а также — от каких библиотек зависит этот файл, чтобы сперва загрузить их
  3. Сегменты — описывают регионы памяти, куда загружать секции с кодом или данными.
Утилиты-парсеры

Для второго приближения придется познакомится с некоторыми утилитами:
  • 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;
};

Имеем имя сегмента и самой секции, размер, смещение в файле и адрес в памяти, по которому динамический загрузчик ее разместил. Кроме того, присутствует и другая, специфическая для конкретной секции информация.

Например:


Fat binary


Безусловно, стоит упомянуть, что, в следствии неоднократной плавной смены компанией 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, можно сформулировать алгоритм поиска нужного элемента в таблице импорта.

Алгоритм перенаправления


Опишем все действия словами, так как код, несмотря на обилие комментариев, может оказать не столь понятным:
  1. Отыскиваем таблицу символов и строк по данным из команды загрузки LC_SYMTAB.
  2. Узнаем из команды загрузки LC_DYSYMTAB с какого элемента таблицы символов начинается подмножество неопределенных символов (поле iundefsym).
  3. Ищем целевой символ по имени среди подмножества неопределенных символов в таблице символов.
  4. Запоминаем индекс целевого символа от начала таблицы символов.
  5. Отыскиваем таблицу косвенных символов по данным из команды загрузки LC_DYSYMTAB (поле indirectsymoff).
  6. Узнаем индекс, с которого начинается отображение таблицы импорта (содержимого секции __DATA, __la_symbol_ptr (либо __IMPORT, __jump_table — будет что-то одно)) на таблицу косвенных символов (поле reserved1).
  7. Начиная с этого индекса просматриваем таблицу косвенных символов и ищем в ней значение, соответствующее индексу целевого символа в таблице символов.
  8. Запоминаем, каким по счету с начала отображения таблицы импорта на таблицу косвенных символов попался целевой символ. Сохраненное значение — это и есть индекс нужного элемента в таблице импорта.
  9. По данным из секции __la_symbol_ptr (либо __jump_table) находим таблицу импорта (поле offset).
  10. Имея индекс целевого элемента в ней, переписываем адрес (для __la_symbol_ptr) на необходимое нам значение (либо меняем инструкцию CALL/JMP на JMP с операндом — адресом необходимой нам функции (для __jump_table)).
Замечу, что работать с таблицами символов, строк и косвенных символов необходимо только, загрузив их из файла. А читать содержимое секций, описывающих таблицы импорта, и, естественно, производить само перенаправление, уже в памяти. Это связано с тем, что таблицы символов и строк могут отсутствовать или не отображать действительное положение вещей в целевом Mach-O. Ведь до нас там поработал динамический загрузчик и благополучно сохранил себе все необходимые данные о символах, не размещая сами таблицы.

Реализация перенаправления


Настало время превратить изложенные мысли в код. Для оптимизации поиска нужных элементов Mach-O при каждом перенаправлении, разобъем всю операцию на три этапа:
  1. void *mach_hook_init(char const *library_filename, void const *library_address);
    На основании самого файла Mach-O и его отображения в памяти, эта функция возвращает некий непрозрачный описатель, за которым скрывается смещения к таблице импорта, таблица символов, строк и отображение косвенных (indirect) символов из таблицы динамических символов, а также ряд полезных индексов для этого модуля. Вот этот описатель:
    struct mach_hook_handle
    {
        void const *library_address;  //base address of a library in memory
        char const *string_table;  //buffer to read string_table table from file
        struct nlist const *symbol_table;  //buffer to read symbol table from file
        uint32_t const *indirect_table;  //buffer to read the indirect symbol table in dynamic symbol table from file
        uint32_t undefined_symbols_count;  //number of undefined symbols in the symbol table
        uint32_t undefined_symbols_index;  //position of undefined symbols in the symbol table
        uint32_t indirect_symbols_count;  //number of indirect symbols in the indirect symbol table of DYSYMTAB
        uint32_t indirect_symbols_index;  //index of the first imported symbol in the indirect symbol table of DYSYMTAB
        uint32_t import_table_offset;  //the offset of (__DATA, __la_symbol_ptr) or (__IMPORT, __jump_table)
        uint32_t jump_table_present;  //special flag to show if we work with (__IMPORT, __jump_table)
    };
    
  2. mach_substitution mach_hook(void const *handle, char const *function_name, mach_substitution substitution);
    Эта функция, по имеющемуся описателю библиотеки, имени целевого символа и адреса перехватчика осуществляет само перенаправление по описанному выше алгоритму.
  3. void mach_hook_free(void *handle);
    Так осуществляется очистка любого описателя, который вернула mach_hook_init().

С учетом этих прототипов тестовую программку придется переписать:
#include <stdio.h>
#include <dlfcn.h>

#include "mach_hook.h"

#define LIBTEST_PATH "libtest.dylib"

void libtest();  //from libtest.dylib

int hooked_puts(char const *s)
{
    puts(s);  //calls the original puts() from libSystem.B.dylib, because our main executable module called "test" remains intact

    return puts("HOOKED!");
}

int main()
{
    void *handle = 0;  //handle to store hook-related info
    mach_substitution original;  //original data for restoration
    Dl_info info;

    if (!dladdr((void const *)libtest, &info))  //gets an address of a library which contains libtest() function
    {
        fprintf(stderr, "Failed to get the base address of a library!\n", LIBTEST_PATH);

        goto end;
    }

    handle = mach_hook_init(LIBTEST_PATH, info.dli_fbase);

    if (!handle)
    {
        fprintf(stderr, "Redirection init failed!\n");

        goto end;
    }

    libtest();  //calls puts() from libSystem.B.dylib

    puts("-----------------------------");

    original = mach_hook(handle, "puts", (mach_substitution)hooked_puts);

    if (!original)
    {
        fprintf(stderr, "Redirection failed!\n");

        goto end;
    }

    libtest();  //calls hooked_puts()

    puts("-----------------------------");

    original = mach_hook(handle, "puts", original);  //restores the original relocation

    if (!original)
    {
        fprintf(stderr, "Restoration failed!\n");

        goto end;
    }

    libtest();  //again calls puts() from libSystem.B.dylib

end:

    mach_hook_free(handle);
    handle = 0;  //no effect here, but just a good advice to prevent double freeing

    return 0;
}

Полная реализация тестового примера вместе с алгоритмом перенаправления и файлом проекта доступна для скачивания.

Тестовый запуск


и опробовать примерно так:
user@mac$ arch -i386 ./test 
libtest: calls the original puts()
-----------------------------
libtest: calls the original puts()
HOOKED!
-----------------------------
libtest: calls the original puts()

user@mac$ arch -x86_64 ./test 
libtest: calls the original puts()
-----------------------------
libtest: calls the original puts()
HOOKED!
-----------------------------
libtest: calls the original puts()

Вывод программы свидетельствует о полном выполнении задачи, поставленной в самом начале.

Полезные ссылки

Удачи!
Tags:
Hubs:
+27
Comments 14
Comments Comments 14

Articles