Как стать автором
Обновить

Внедрение кода в Mach-O файл своими руками для iOS

Уровень сложностиСредний
Время на прочтение17 мин
Количество просмотров343

Вводная часть

Эта статья может быть полезна тем кто занимается reverse engineering'ом, компьютерной безопасностью, тем, кто хочет немного ознакомиться с Mach-O файлами и с исполнением программ в iOS. Сразу скажу, что я не являюсь экспертом в области анализа бинарных файлов и тем более не разбираюсь в устройстве работы процессов в iOS, но имею достаточные знания чтобы подробно объяснить как добавить собственный код в готовый Mach-O файл и показать как это работает. Также в примерах кода могут быть неточности и ошибки.

Предупреждение: Данный материал предназначен исключительно для образовательных и исследовательских целей. Автор не поддерживает, не поощряет и не пропагандирует несанкционированный доступ к информационным системам или другие незаконные действия. Описанные методы и обнаруженные уязвимости представлены в рамках анализа для повышения осведомленности о проблемах безопасности. Любое применение представленной информации в реальных условиях без явного разрешения является противозаконным и осуществляется исключительно на страх и риск пользователя. Все совпадения с реальными лицами, организациями или событиями являются случайными

Думаю многие, кто увлекается компьютерной безопасностью, слышали о таком термине как hooking или используют готовые инструменты и создают свои для хукинга - замены или дополнения функционала кода программы. Когда у меня была iOS jailbreak, я тоже использовал хукинг для изменения поведения программ, Cydia Substrate в jailbreak умеет изменять, уже загруженные в процесс, сегменты кода. Но сейчас jailbreak уже не имеет полного контроля над ядром начиная примерно с 15 версии iOS. Это заставило меня искать новые способы хукинга в rootless jailbreak iOS или в обычном, никак не взломанном. И именно в этот момент я подумал, что ничего не мешает попробовать сделать свой инструмент. Необходимо сделать простой (на сколько это возможно), хоть сколько-то удобный в использовании инструмент, позволяющий перехватывать вызовы функций, их аргументы и возвращаемое значение, а также внутри своего кода иметь возможность вызвать любую доступную оригинальную функцию самостоятельно. (Под словом "оригинальная функция" я имею ввиду любую функцию находящуюся сегментах кода изменяемого mach-o файла, реализацию которой сделал разработчик файла). Это позволило быть искать уязвимости программы, манипулировать ими и целом достаточно удобно изменять поведение программы в своих интересах.

Мотивация ясна теперь разберемся более контретно с реализацией. Есть некоторый файл загружаемый в процесс, файл еще до момента загрузки в память уже на жестком диске будет полностью изменен нужным образом, на него остается как обычно передавать управление и только этого достаточно чтобы видеть изменение в его поведении, именно поэтому я назвал это статическим хукингом. Общий план: добавить в файл формата macho новый исполняемый сегмент, который особым образом будет связан с основным исполняемым сегментом __TEXT. Одни функции которые мы будем перехватывать будут из своего сегмента делать прыжок на наш и обратно, другие оригинальные функции смогут быть вызваны из нашего кода.

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

1) Пишем полностью цельный код (без неразрешенных адресов функций и всего подобного) на языке С/С++/Objective-C++ или хоть сразу на arm64 инструкциях. Мы должны получить простой macho file с символами функций, чтобы потом связать реализации хуков с оригинальными функциями (об этом подробнее позже)

2) Наша команда в терминале будет выглядеть так в общем виде:

static-hook /path/to/modifying_file /path/to/inject_code_file N addr1 symbol1 addr2 symbol2 ... addrN symbolN

Думаю объяснить стоит только то что такое N, addr и symbol:
N : говорит о количестве хуков после этого аргумента. addr и symbol : Каждый хук будет описывается смещением до оригинальной функции в модифицируемом файле и именем функции в инжектирумом файле. (Имя вместо смещения потому что у нас нет в удобном виде информации о том, где точно будут располагаться функции-хуки. Эту проблему и решит таблица символов (SYMTAB) в инжектируемом файле)

Вот уже приближенный к реальности вид команды для инъекции:

statichook /user/gnot/desktop/modifying_file /user/gnot/desktop/inject_code_file 3 0x1fe4544 my_hook_func1 0x1be3434 my_hook_func2 0x120b8a4 CALL_ONLY

3) На выходе получаем новый измененный macho файл который мы подписываем, помещаем обратно в приложение для запуска и тестируем.

Начало реализации

Суть всего проекта:

Почему такой, полностью статический, перехват функции будет работать?
С самого начала мы находимся в области где вызывается функция, которую нужно подменить. При вызове функции в регистр lr (link register, регистр возврата) сохраняется адрес возврата, далее вызванная функция обычно обязана создать свой фрейм стека, она сохраняет lr и fp (frame pointer указатель кадра) регистры на стек, а по завершении, восстанавливает из стека и делает возврат. При появлении хука на функцию, создание фрейма уже произойдет на нашей стороне сразу после прыжка до функции замены. Значит адрес возврата успешно сохранен и когда наша функция будет уничтожать свой фрейм, вернется в то же место, что и без хука. А как происходит вызов функции-оригинала? Уже после нашего фрейма, где сохранены те lr и fp, создается фрейм функции оригинала за счет вызова нужного элемента CPT. Заметьте, теперь у вызванной функции-оригинала будет lr именно обратно к нам, а не к месту от куда была вызвана. И только когда функция-оригинал вернется к нам, мы уже сможем вернуться к месту вызова. Видите что выходит? Мы имеем полный контроль над функцией. Из места вызова аргументы передавались, думая что они уйдут к оригиналу, а они у нас. Мы сами вызываем оригинал, если захотим передадим те же аргументы или поменяем их. Функция-оригинал вернется к нам, следовательно и возвращаемое ей значение тоже под нашим контролем до возврата к месту вызова.

Изменения в библиотеке, загруженной в процесс, после применения statichook и путь pc при работе хука на функцию-оригинал (стрелочки на таблице справа)
Изменения в библиотеке, загруженной в процесс, после применения statichook и путь pc при работе хука на функцию-оригинал (стрелочки на таблице справа)

И так, если мы делаем перехват функции - значит после инструкции вызова bl должен располагаться прыжок b сразу на наш код, который вместо оригинальной функции создаст arm'овский пролог, выполнит код и вернется по адресу возврата, минуя тем самым оригинальный вызов (уже внутри нашей оболочки вызов оригинала может существовать).

А как вызвать оригинальную функцию? Компиляторы не предоставили нам nop'ы сразу в самом начале функции, там начинается сохранение регистров на стек. В итоге, если мы просто поместим на самом верху jmp мы сломаем фундамент пролога оригинальной функции из-за чего та не сможет вызваться. Эту проблему будет решать специальная таблица которая хранит для каждой, измененной jmp'ом, функции ее первую уничтоженную инструкцию пролога и далее прыжок на продолжение оригинала. То есть получается, что эта таблица хранит элементы, в каждом из которых есть по две исполняемых инструкции - кусок пролога и прыжок на его продолжение. Адреса которые указывают на эти инструкции и будем называть началом вызова оригинала. А саму такую структуру я назову таблицей коммутируемых указателей (commutate pointer table - cpt, буду очень часто использовать эту аббревиатуру в объяснениях)

cpt является массивом структур такого вида
typedef struct 
{
    uint32_t replased_instr; // здесь располагается та инструкция, которая была уничтожена прыжком до функции-замены
    uint32_t jmp_instr; // прыжок на продолжение функции-оригинала
    uint64_t orig_func_off; // место для смещения функции оригинала
}__attribute__((packed)) commutate_pointer;

commutate_pointer CPT[1024];

Исходя из этого мы должны не просто поставить наверху jmp. Для тех функций, чей функционал мы дополняем, но и для каждой перехватываемой и вызываемой функции создать соответствующий ей элемент cpt для вызова внутри нашего добавленного сегмента.

Высокоуровневый код хука может выглядеть примерно так:
int func_replace(int arg_original) // это по сути будет точка входа в наш код
{
	void* addr = get_communate_pointer(0x1fe4545); // функция определит виртуальный адрес элемента cpt для связи с оригиналом по ее смещению
	int(*func_original)(int) = addr; // создание указателя на оригинал

	return func_original(9999); // перехваченный аргумент
}

По addr будет находится что-то очень похожее на это:
stp x20, x19, [sp, -0x20]!
jmp 0x1fe4545 + 4
0x45 0x45 0xfe 0x01 0x00 0x00 0x00 0x00 ; qword смещения функции оригинала (будет обьяснено) 

Продолжаю дальше уточнять алгоритм добавления своего кода:

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

Затем физически добавляем новый сегмент между сегментами DATA и __LINKEDIT, тем самым сместив вперед сегмент __LINKEDIT. Далее исправим все команды которые относятся к сегменту __LINKEDIT: LC_SYMTAB, LC_DYSYMTAB, LC_DYLD_INFO_ONLY, LC_FUNCTION_STARTS, LC_DATA_IN_CODE. Далее рассчитываем относительные прыжки из оригинальный функций к хукам и наоборот - из хука до оригинала.

Теперь подробнее про добавление нового сегмента в изменяемый mach-o: Нужно с чего-то начать. У mach-o файла есть, так называемые load commands, типичная команда загрузки говорит о расположении и размере на диске, и в памяти процесса некоторых данных, имеющих определенные свойства, которые тоже часто описываются в командах загрузки. Эти команды анализируются для работы с данными на диске и для загрузки их в процессы. Все команды расположены в ряд под mach-o заголовком, в котором есть записи о суммарном размере команд и их количестве. Мы хотим добавить команду, которая описывает сегменты в файле - LC_SEGMENT_64, ее минимальный размер 72 байта, если указано что он больше, значит команда описывает еще секции этого сегмента. Мы будет создавать сегмент без секций.

Добавление нового сегмента: отражение некоторых изменных и добавленных частей файла
Добавление нового сегмента: отражение некоторых изменных и добавленных частей файла

Есть одна проблема: свободных 72 байт после команд может не оказаться, то есть сразу после команд пойдет код сегмента __TEXT, тогда придется выкручиться: пытаться заменить/изменить какую-то существующую команду, или пытаться как-то изменить код что может привести к нестабильности. В идеале наш инструмент statichook должен был сместить абсолютно все данные на 72 байта вперед, но это привело бы к большому количеству правок в самых разных командах и областях данных на которые эти команды ссылаются. К счастью в большом количестве файлов между данными команд и началом кода есть нулевые байты. Я не углублялся в тему почему так происходит, но сразу использовал это в инструменте. Statichook проверяет файл на наличие 72 нулевых байтов и размещает новую команду после команды загрузки сегмента __DATA, а все остальные команды смещается вперед на 72 байта.

Сами данные сегмента размещаются следующим образом: копируются все данные после сегмента __DATA и после него же размещаются данные нашего сегмента, затем скопированные раннее данные. После этого исправляются все команды, которые потенциально могли быть сломаны перемещением данных вперед: Я ищу команды LC_SYMTAB, LC_DYSYMTAB, LC_DYLD_INFO_ONLY (или LC_DYLD_INFO), LC_FUNCTION_STARTS, LC_DATA_IN_CODE и для каждой из увеличиваю поля их смещений если они указывали на перемещаемую область.

Строение добавляемого сегмента: назовем его __INJT, первые 16 килобайт этого сегмента выделим для cpt, всё остальное - это все байты файла с инжектируемым кодов, который (файл) скомпилирован как dylib c таблицей символов (нас будут интересовать имена некоторых функций в инжектируемом коде). Это "всё остальное" после cpt будет выровнено по 16 килобайтам.

Теперь добавленный сегмент надо "сшить" с оригинальным сегментом кода, то есть там где нужно добавить прыжки на хуки и организовать для каждого хука и функции-вызова их персональные элементы в cpt, содержащие по две инструкции для прыжка на оригинал и затем 8-байтовое смещение оригинала функции, в общем случае, относительно начала сегмента __TEXT. Так как инструкции в arm размером 4 байта, то каждый элемент cpt будет размером 4 + 4 + 8 = 16 байт, а так как мы выделили на cpt 16 килобайт, то всего возможно будет 16*1024 / 16 = 1024 записей в cpt. (Думаю такого количества хуков или вызовов должно хватить для многого)

Продолжаю про сшивание сегментов:

Небольшая схема для понимания смысла аргументов командной строки и назначения поля смещения в каждом элементе cpt
Небольшая схема для понимания смысла аргументов командной строки и назначения поля смещения в каждом элементе cpt

Сейчас важно понять связь между всеми этими смещениями, символами и реализациями: Почему для каждого хука/вызова в команде есть смещение до оригинала, а потом символ, существующей в инжектируемом коде функции?

Смещения размещаются в последние поля элементов cpt по порядку, то есть для первого по счету хука в аргументах, смещение попадет в cpt[0] (и так далее, см. таблицу выше). Символ функции нужен только для генерации jmp инструкции на хук. То есть теперь оригинал будет прыгать на ту реализацию которая определяется символом. Да, для вызовов тоже указывается символ, но один и тот же, специальный, говорящий statichook'у только создать cpt элемент для этого смещения. Назовем этот специальный символ "CALL_ONLY". Значит указали мы символ функции, по которому находится код куда прилетит исполнение вместо оригинала. Логично, что мы знаем какое смещение передавать в get_commutate_pointer для вызова оригинала, так как мы сами указали нужное смещение перед символом хука в командной строке.

Начнем с создания cpt: Для каждого хука/вызова из командной строки по порядку генерируем:

  1. Их стёртые jmp'ом инструкции пролога (первое поле uint32_t replased_instr)

  2. Прыжок на продолжение пролога

  3. Разместить смещение оригинала (самое простое - просто из текущего аргумента переместить значение)

С первым всё просто, копируем со смещения функции ее первые 4 байта и размещаем в соответствующем элементе. Второе тут самое сложное, смещение назад от элемента cpt до продолжения пролога рассчитывается так: Смещение = отрицательное значение от: Виртуальное смещение начала сегмента __INJT - смещение оригинала + индекса элемента cpt * 16 Без "+ 4" потому что мы от второго dword'а cpt элемента прыгаем до второго dword'а оригинальной инструкции, следовательно, смещение такое же как и в случае прыжка от начала элемента cpt до начала функции оригинала.

Цикл инициализирующий элементы в cpt
for (int i = 0; i < strtoul(argv[3], NULL, 0); i++)
{
  uint32_t repl_instr = 0;
  if(fseek(resulting_file, CPT[i].orig_func_off, SEEK_SET)){printf("%s%d\n", "error in line: ", __LINE__); goto end;} // перемещаемся до функции оригинала
  if(fread((char*)&repl_instr, 1, 4, resulting_file) != 4){printf("%s%d\n", "error in line: ", __LINE__); goto end;} // копируем в переменную инструкцию 
  CPT[i].replased_instr = repl_instr; // размещаем в поле текущего элемента

  uint32_t curr_jmp = get_arm64_rel_jmp(((injseg_cmd.vmaddr - CPT[i].orig_func_off + i*16) - 1) ^ 0xffffffff); // считаем по формуле смещение и передаем в генератор инструкции
  CPT[i].jmp_instr = curr_jmp; // сохраняем инструкцию в поле
            
  CPT[i].orig_func_off = strtoll(argv[4 + i*2], NULL, 0); // переносим смещение функции оригинала в последнее поле
}

Теперь, для хуков чей символ не CALL_ONLY мы генерируем прыжок. Как его рассчитать? Смещение, которое нужно прибавить к регистру pc для перехода на начало нужного кода перехвата, рассчитывается так: Смещение = Виртуальное смещение начала сегмента __INJT - смещение оригинала + 16 килобайт + локальное смещение до функции в скомпилированном как dylib файле (второй кусок сегмента __INJT). Используя полученное смещение далее генерируется инструкция прыжка относительно pc из архитектуры arm64.

Цикл создающий прыжки
for (int i = 0; i < strtoul(argv[3], NULL, 0); i++) // "strtoul(argv[3], NULL, 0)" это численное значение хуков/вызывов передаваемое перед ними (см. таблицу           выше)
{
    if(strcmp(hook_functions[i].hook_func_name, "CALL_ONLY") == 0){continue;} // пропускаем операцию для вызовов, сравнивая сохраненный символ из командной строки с придуманной меткой

    uint32_t offset = injseg_cmd.vmaddr - CPT[i].orig_func_off + 0x4000 + hook_functions[i].hook_fulc_local_off; // расчитываем смещение
  
    uint32_t trpln_instrl = get_arm64_rel_jmp(offset); // создаем инструкцию
  
    if(fseek(resulting_file, CPT[i].orig_func_off, SEEK_SET)){printf("%s%d\n", "error in line: ", __LINE__); goto end;} // доходим до кода функции оригинала
    if(fwrite((char*)&trpln_instrl, 1, 4, resulting_file) != 4){printf("%s%d\n", "error in line: ", __LINE__); goto end;} // ставим инструкцию прыжка
}

Если необходимо, прикладываю реализацию генератора инструкций
uint32_t get_arm64_rel_jmp(int32_t offset)
{
    uint32_t result = 0x14000000; // опкод прыжка
    if(offset % 4 != 0){return 0;} // проверяем корректность смещения (все инструкции размером 4 байта, значит если смещение в байтах некратно 4, то инструкция выйдет неверной)
    offset /= 4; // так как все смещения кратны 4, в инструкции хранится смещение деленное на 4
    if((offset > 128*(2 << 20) - 1) || (offset < -128*(2 << 20))){return 0;} // хранящемуся значению выделено 26 бит, поэтому проверяем умешается ли число в 26 битах

    offset = (uint32_t)offset & 0x03ffffff; // срезаем знаковые биты (будет эффект для отрицательных чисел)
    result |= offset; // располагаем смещение в инструкции
    return result;
}

Осталось только разобрать работу функции get_commutate_pointer а именно, каким образом она находит виртуальный адрес по которому располагается загруженная в процесс cpt?

Я уверен, что это можно сделать по разному. Я для себя нашел самый простой в реализации, но далеко не самый надежный способ - попытаться
расположить код функции get_commutate_pointer в странице сразу после cpt. Поясняю, весь инжектируемый сегмент выровнен по страницам 16 килобайт (как и любой другой сегмент файла в процессе), мы знаем, что на самой первой странице располагается cpt и сразу после весь файл с кодом целиком (то есть с заголовками и таблицами символов). Следовательно мы хотим, чтобы код функции был на второй странице или на 3, 4 и так далее, главное - чтобы мы заранее знали на какой странице будет расположена функция относительно инжектируемого сегмента. Самый простой вариант - попытаться расположить код на 2 по счету странице. Как я это сделал? Я почти ничего и не делал, просто разместил реализацию функции в самом начале, еще не скомпилированного Си файла с инжектируемым кодом, и этого было достаточно. Опять же мне просто повезло что компилятор расположил код моей функции на первой странице (относительно компилируемого файла), он мог этого спокойно не делать. Здесь много тонкостей и возможностей сделать лучше, но я остановился на этом варианте просто
потому что он работает.

Итак, функция get_commutate_pointer располагается где-то на 2 странице всего инжектируемого сегмента __INJT. Мы получим виртуальный адрес нашей страницы (там где код функции) и вычтем из него 16 килобайт чтобы спуститься к виртуальному адресу нашей cpt. Далее алгоритм совсем простой - проходимся по записям и ищем совпадение аргумента функции и поля orig_func_off одного из элементов cpt. Далее возвращаем виртуальный адрес нужного элемента cpt как: Возвращаемого значение функции get_commutate_pointer = Виртуальный адрес начала cpt + индекс * 16 (размер одного элемента).

Функция get_commutate_pointer находится на 2 странице относительно сегмента и, получая адрес своей страницы, находит виртуальный адрес cpt
Функция get_commutate_pointer находится на 2 странице относительно сегмента и, получая адрес своей страницы, находит виртуальный адрес cpt

Я думаю, что мне удалось раскрыть суть проекта и, в достаточно подробной степени, детали его реализации. Естественно я приложу ссылку на исходный код инструмента, чтобы каждый при желании мог узнать любые подробности. (Только комментариев там пока нет, извините). В следующем разделе я буду применять инструмент на практике, но я не буду детально объяснять принцип работы уязвимости которая была использована, так как это не имеет отношения к статье.

Применение в полевых условиях

Я применяю свой инструмент на библиотеке одной unity игры. Для начала нужно найти функцию, при успешном изменении поведения которой, сразу будет заметно что-то.
Помню, как раньше для этой игры я писал эскплойт для удаления с сервера любого игрока по желанию. Автор этой игры оставил огромный пласт неиспользуемого кода
в одной из самых главных библиотек всего процесса. Это означает что наш инструмент позволит вызывать такие неиспользуемые функции. Среди них особое внимание занимает функция под названием SetMasterClient которая устанавливает "мастера" комнаты. На мастере держится большая часть сетевого взаимодействия в комнате. (Углубляться в то, как устроено там сетевое взаимодействие я не буду да и не хочется). И самое главное, функция CloseConnection, позволяющая отключить любого игрока из комнаты передав качестве параметра объект игрока, функция работает при условии, что вызывающий является мастером, то есть вызвали SetMasterClient для своего объекта и всё, защиты по сути нет, далее вызовем CloseConnection, найдя объект нужного игрока (Вот такие приложения встречаются на просторах AppStore).

Делаем инъекцию:

Я вызываю функцию оригинал по такому шаблону
int some_func(int arg) // удобная оболочка для вызова функции
{
  int(*Call)(int) = (int(*)(int))get_commutate_pointer(some_known_offset); // создаю указатель на функцию через cpt
  Call(arg); // вызываю функцию оригинал через cpt
}

void some_hooked_function()
{
  some_func(12345); // вызов обязательно происходит внутри какого-либо хука, так как надо перехватить поток выполнение прежде вызова
  
  void(*Call)() = (void(*)())get_commutate_pointer(some_known_offset);
  Call(); // по шаблону вызов оригинал для хука
}

1) Пишем немного специфический код на Си для statichook: Пример эксплуатации той смешной уязвимости:

inject-code.c
#include "get_commutate_pointer.c" 

#define get_LocalPlayer_GLOBAL_OFFSET 0x1B52318
#define SetMasterClient_GLOBAL_OFFSET 0x1B567E8
#define getIsMasterClient_GLOBAL_OFFSET 0x1B34008
#define get_PlayerList_GLOBAL_OFFSET 0x1b52608
#define Interceptor_GLOBAL_OFFSET 0x1177940
#define CloseConnection_GLOBAL_OFFSET 0x1B5660C

typedef struct 
{
    void* k1;
    void* k2;
    void* k3;
    int length;
    int k4;
    void* data;     
}mono_arr;

void* get_LocalPlayer()
{
    void*(*Call)() = (void*(*)())get_commutate_pointer(get_LocalPlayer_GLOBAL_OFFSET);
    return Call();
}
char SetMasterClient(void* player)
{
    char(*Call)(void*) = (char(*)(void*))get_commutate_pointer(SetMasterClient_GLOBAL_OFFSET);
    return Call(player);
}
char getIsMasterClient(void* player)
{
    char(*Call)(void*) = (char(*)(void*))get_commutate_pointer(getIsMasterClient_GLOBAL_OFFSET);
    return Call(player);
}
void* get_PlayerList()
{
    void*(*Call)() = (void*(*)())get_commutate_pointer(get_PlayerList_GLOBAL_OFFSET);
    return Call();
}
char CloseConnection(void* player)
{
    char(*Call)(void*) = (char(*)(void*))get_commutate_pointer(CloseConnection_GLOBAL_OFFSET);
    return Call(player);
}

void Interceptor(void* panelplrop)
{
    if((long long)panelplrop == 0){goto end;}

    void* local_plr = get_LocalPlayer();
    if(getIsMasterClient(local_plr) == 0)
    {
        SetMasterClient(local_plr);
        wait:;
        void* update_plr = get_LocalPlayer();
        if(getIsMasterClient(update_plr) == 0){goto wait;}
    }

    mono_arr* player_list = (mono_arr*)get_PlayerList(); 
    void* attacked_rem_plr_obj = *(void**)((long long)panelplrop + 0x88);
    char* attacked_plr_user_id = *(char**)((long long)attacked_rem_plr_obj + 0x38); 
    
    void* checked_player = (void*)0x141516;
    char* checked_plr_user_id = (void*)0x141516;
  
    for (int i = 0; i < player_list->length; i++)
    {
        checked_player = *(void**)((long long)player_list + 0x20 + i*8);
        checked_plr_user_id = *(char**)((long long)checked_player + 0x28);
        
        for (int j = 0; j < 56; j++)
        {
            if(attacked_plr_user_id[j] == checked_plr_user_id[j]){continue;}
            goto next_plr;
        }
        CloseConnection(checked_player);
        goto end;
        next_plr:;
    }
    end:;
    void(*Call)(void*) = (void(*)(void*))get_commutate_pointer(Interceptor_GLOBAL_OFFSET);
    Call(panelplrop);
}

Я нахожусь в папке где расположен statichook:

Компиляция code_statichook.c

clang code_statichook.c -o statichook

Компиляция inject-code.c

clang -arch arm64 -dynamiclib -fPIC inject-code.c -o inject-segment

2) Используем наш инструмент statichook для добавления этого кода в macho файл:

В терминале вызываю statichook вот таким образом без обобщений:

./statichook /Users/bogdanmandzuk/Desktop/IOS/test/Payload/some_app.app/Frameworks/UnityFramework/UnityFramework /Users/bogdanmandzuk/Desktop/IOS/inject-segment 6 0x1B52318 CALL_ONLY 0x1B567E8 CALL_ONLY 0x1b52608 CALL_ONLY 0x1B5660C CALL_ONLY 0x1177940 _Interceptor 0x1B34008 CALL_ON

3) Подпись файла. Оказалось, что даже codesign подписывает такую библиотеку. Это означает что на никак не взломанном iOS эта библиотека сможет запуститься.

Пишу следующее в терминале для подписи:

codesign -s "Apple Development: .............@yandex.ru (.............)" --timestamp -v --deep --options=runtime /Users/bogdanmandzuk/Desktop/IOS/statichook_result/UnityFramework

(точками скрыл свои данные)

4) Переношу файл на устройство: Есть разные способы, но в первый раз я собрал новый ipa, который является полной копией изменяемой программы за исключением замены библиотеки на изменную версию. Далее я переподписываю и скачиваю ipa с помощью любого ipa установщика. Готово

Тестируем

Я подключаюсь к удаленному процессу на через lldb.

На iOS устройстве, после запуска программы, пишу в терминале

debugserver 0.0.0.0:4545 --attach some_app

После этого в lldb на macOS пишу:

lldb

platform select remote-ios

process connect connect://192.168.0.101:4545

Чтобы найти куда загружена измененная библиотека пишу в lldb:

image list | grep UnityFramework

Я поставлю breakpoint на начало кода функции, которую перехватывает хук Interceptor. Эта функция на которую я сделал хук, в программе изначально представляла кнопку жалобы на игрока, чью реализацию разработчик забыл сделать, но ничего, мы сами добавили для неё реализацию по интереснее. Она расположена в памяти процесса по адресу: Виртуальный адрес загрузки измененной библиотеки + 0x1177940 (адрес найден путем дизассемблирования и парсинга движка Unity).

И так, после срабатывания точки останова, мы находимся в коде оригинальной функции, но отчетливо видно как вместо пролога располагается прыжок b 0x10dcf7e40. После прыжка мы попадаем на свой пролог, это начало кода функции Interceptor.

Отладка изменной библиотеки: Вход в хук Interceptor
Отладка изменной библиотеки: Вход в хук Interceptor

Я не думаю что есть смысл, находить и рассматривать каждый вызов внутри Interceptor'а, хочу показать уже вызов оригинала Interceptor'а через cpt. Значит вызвалась функция get_commutate_poiner и ее результат был сохранен в регистр x8 для вызова, на его месте я и поставил следующий брейкпоинт.

На первом брейкпоинте мы видим вызов через регистр x8, он прыгает к соответствующему функции Interceptor элементу cpt. На втором брейкпоинте мы уже в cpt (судя по смещению 0х40, выходит нам достался пятый элемент cpt, что логично, в аргументах командной строки мы указали Interceptor пятым по счету). Всё о чем мы говорили ранее здесь есть:

1) Часть пролога: stp x20, x19, [sp, #-0x20]!

2) Прыжок на продолжение: b 0x10be97944

Вычтем из этого значения адрес по которому загружена библиотека:

0x10be97944 - 0x10ad20000 = 0x1177940 + 4 Это смещение второй инструкции в оригинальной функции, поэтому всё верно

3) Сохраненное смещение функции оригинала, благодаря которому мы смогли найти нужный элемент cpt: .long 0x01177940

Вызов оригинала функции, которую перехватывал Interceptor
Вызов оригинала функции, которую перехватывал Interceptor

И последнее, оригинал вызовется и вернется обратно к нам в Interceptor, после чего мы уничтожим свой пролог, восстановив самые первые регистры fp и lr. Давайте посмотрим на это.

Регистры восстановлены и мы делаем возврат. Всё хорошо, выполнение продолжилось после вызова перехваченной функции.

Возврат из Interceptor обратно на место возврата перехваченной функции
Возврат из Interceptor обратно на место возврата перехваченной функции

Заключение

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

Плюсы инструмента:

Полностью автономный

Простой в реализации

Почти не замедляет работу программы

Минусы:

Не самый удобный в использовании

Нет гарантии успешного добавления сегмента в любой mach-o

Нет возможности удобно обращаться и создавать свой сегмент __DATA: Весь сегмент, добавляемый в процесс, в текущей версии statichook, имеет права на чтение и исполнение. (Можно это обойти, если получится найти в существующем сегменте __DATA неиспользуемую область, но это будет крайне рискованно)

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

Ссылка на исходный код

Ссылка на youtube видео, где я прохожусь с 0 до тестирования получившейся программы, измененной с помощью statichook

Теги:
Хабы:
0
Комментарии0

Публикации

Истории

Работа

Ближайшие события

4 – 5 апреля
Геймтон «DatsCity»
Онлайн
8 апреля
Конференция TEAMLY WORK MANAGEMENT 2025
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань
20 – 22 июня
Летняя айти-тусовка Summer Merge
Ульяновская область