Как разобрать macOS на части

Всем привет! Меня зовут Игорь Белов, я инженер в команде Мобильной Архитектуры Авито. Больше 6 лет занимаюсь iOS-разработкой, а ещё мне нравится изучать фундаментальные темы Computer Science и разбираться, как те или иные вещи работают под капотом.
В статье я расскажу, как исследовать произвольные приложения macOS с помощью отладчика LLDB и сопутствующих инструментов, и тем самым прокачать свой повседневный инструментарий разработчика. Сначала исследуем произвольно запущенные процессы в macOS с помощью LLDB. Затем на основе собранных знаний сделаем инъекцию своего кода, чтобы изменить их поведение. Ещё немного затронем теорию, подготовим и настроим окружение. В конце я посоветую, что почитать, чтобы углубиться в тему.
Статья будет полезна инженерам любого уровня, которым интересно узнать, как устроены привычные вещи. И да, это образовательная статья, а примеры из неё не предназначены для обхода защиты и несанкционированного вмешательства в чужие сервисы или данные.
На создание этого материала меня вдохновило выступление Дерека Селандера — ему уже около десяти лет, но оно всё ещё актуально. Мне захотелось взять идею оттуда в более прикладном виде и спроецировать на современную повседневную разработку.
Содержание
• Почему система не пускает в чужой код
• Как LLDB вскрывает запущенный процесс
• Как внедрить свой код без исходников
• Заключение, или почему LLDB больше чем отладчик
• Что дальше

Почему система не пускает в чужой код
Представим ситуацию: есть программа, которую написали мы или кто-то другой, и она ведёт себя неправильно. Здесь может быть несколько вариантов.
→ Либо это наше приложение, у нас есть его исходники, и можно открыть их в условном Xcode, поставить breakpoint, добавить логирование, пересобрать проект, запустить, чтобы исследовать поведение, сделать выводы и, возможно, внести правки.
→ Но есть обратная сторона. Это может быть чужой чёрный ящик, который поставляется откуда-то снаружи. Прямого доступа к исходникам нет, есть только процесс или бинарь, и наблюдать можно лишь поведение и какие-то сайд-эффекты.
Многие неприятные инженерные ситуации возникают как раз когда выходишь за границы своего репозитория и начинаешь работать с этим самым чёрным ящиком, будь то чужим приложением, фреймворком, симулятором или сторонним SDK. Поэтому статья про то, как разбираться с такими ситуациями, когда причина проблемы живёт где-то снаружи.
Обычно, когда нужно исследовать поведение, точка входа — это отладчик. В нашем случае LLDB — отладчик из семейства LLVM, хорошо знакомый каждому пользователю Xcode. Он позволяет подключиться к живому приложению, которое работает прямо сейчас, посмотреть текущее состояние, узнать стэктрейс вызовов, значение регистров и переменных и даже получить более детальную информацию о состоянии программы с точки зрения рантайма, на котором она работает, будь то Objective-C, Swift или что-то ещё.

Отладчик необязательно использовать только для работы с собственным кодом. Можно подключиться к уже запущенному стороннему процессу, исследовать его состояние и даже что-нибудь в нём изменить. В качестве примера возьмём знакомый всем пользователям macOS Finder — аналог Проводника в Windows для работы с файлами. Подключимся к его процессу и попробуем сделать что-нибудь наглядное: например, спрятать иконку калькулятора.

Если в терминале выполнить lldb -n Finder и попытаться подключиться к процессу Finder, окажется, что macOS не очень-то хочет это разрешать. LLDB вернет ошибку, но полезной информации в ней будет немного. Он предложит обратиться к системному логу, однако и там причина отказа описана довольно скупо. Дело в том, что такое подключение ограничивают механизмы безопасности macOS, и для операционной системы это совершенно нормальное поведение.

Этот механизм безопасности называется System Integrity Protection, или SIP. Это базовая защита macOS, которая ограничивает доступ к критически важным компонентам системы и ядра, а ещё предотвращает отладку и инъекцию своего кода в чужие процессы. Было бы странно, если бы в современной операционной системе такого механизма не было.
Но есть хорошая новость.
Этот механизм можно отключить командой csrutil disable, запустив её из среды восстановления macOS (Recovery Mode). Но не рекомендую делать это в основной системе, потому что, что очевидно, нельзя отключать защиту там, где обычно работаешь, держишь данные и запускаешь произвольные программы. Лучше использовать виртуализацию и запустить гостевую macOS в виртуальной машине на основной системе. Так создаётся отдельное окружение для исследования.
Это позволяет ставить безопасные эксперименты, потому что нет прямого доступа к основной системе. Ещё такой подход даёт воспроизводимость, чтобы тестировать гипотезы в заранее известных условиях, например на определённой версии macOS с определённым Xcode.

Дальше все действия будут проходить внутри виртуальной машины. При желании её можно поднять на нативном фреймворке Virtualization, но это требует больше ручной настройки. Обычно используют инструменты вроде UTM, VMware, Parallels. Я использую UTM, потому что это простая тулза и она быстро разворачивает нужные виртуалки без лишних приседаний.

Как LLDB вскрывает запущенный процесс
Вернёмся к основной проблеме. Есть Finder, есть иконка, с которой хочется что-то сделать. Теперь, после того как SIP отключён и работа идёт внутри виртуальной машины, можно снова подключиться к процессу запущенного приложения, обратившись просто по его имени. Сделать это можно и по ID процесса, но с LLDB гораздо проще, если точно знать имя процесса, с которым запущено приложение.
Раз SIP отключён, теперь операционная система даст это сделать. В терминале, как и в Xcode, LLDB покажет текущее состояние регистров, стека и загруженных образов, а также сообщит, что процесс был остановлен сигналом SIGSTOP. Это важный момент, потому что после получения этого сигнала окно приложения, которое мы привыкли видеть, перестаёт обрабатывать какие-либо пользовательские действия. В это время через LLDB можно исследовать его текущее состояние и выполнять те же отладочные команды, которые доступны через Xcode. Но если изменения требуют взаимодействия с самим приложением, выполнение процесса придётся сначала возобновить.

Вернёмся к исследованию. В качестве основного инструмента можно использовать самую базовую единицу отладки — breakpoint (точка останова). Как и в Xcode, LLDB поддерживает разные виды breakpoints, однако прямое обращение к отладчику из терминала даёт больше гибкости и позволяет быстрее выполнять типовые и нестандартные отладочные действия.
Чтобы найти интересующий объект в иерархии компонентов, можно поставить breakpoint на функцию или селектор, участвующий в обработке нажатия. Например, для этого подходит hitTest: — при нажатии этот метод проходит по иерархии, по цепочке опрашивая объекты, и определяет, на какой из них оно пришлось. После срабатывания breakpoint можно посмотреть информацию о найденном объекте.

Здесь я бы хотел подробнее остановиться на выражении breakpoint, которое я передал в LLDB. На первый взгляд оно выглядит довольно громоздко, но на самом деле состоит из нескольких простых частей:

Первая часть команды просто создаёт breakpoint, после чего через -n по имени указывается Objective-C селектор, на который он устанавливается. С помощью -C к breakpoint добавляется команда, которая будет выполняться при каждом его срабатывании. В данном случае это po $arg1: команда po выводит через debugDescription полезную информацию об объекте, а $arg1 указывает на сам объект. Наконец, флаг -G1 позволяет после каждого срабатывания сразу продолжать выполнение программы, не останавливаясь в отладчике. В результате hitTest: проходит по иерархии, а LLDB на каждом срабатывании breakpoint печатает информацию об очередном объекте и продолжает выполнение.
Как я писал выше, процесс остановлен системным сигналом, и его необходимо возобновить. Сделать это можно командой continue или сокращённо c, а затем вернуться к окну и нажать на интересующую иконку.
Поскольку иерархия обходится по цепочке, нужный объект, скорее всего, окажется среди последних. Исключением могут быть случаи со сложными оверлеями или переопределённой логикой обработки нажатий, из-за которых порядок будет не таким очевидным. Но в большинстве случаев достаточно посмотреть на несколько последних объектов и определить среди них нужный. В нашем случае мы работаем с иконкой, поэтому в первую очередь стоит обратить внимание на NSImageView, у которого также можно получить адрес и работать с ним дальше.

Получив адрес нужного объекта, к нему можно обратиться напрямую из LLDB и попробовать изменить его состояние. Для этого адрес можно привести к указателю нужного типа и использовать его в выражении. LLDB позволяет выполнять такие выражения на Objective-C, Swift и других поддерживаемых языках, а конкретные варианты зависят от контекста отладки.

Команда expr выполняет выражение на выбранном языке — в данном случае флаг -l objc указывает Objective-C. Дополнительный флаг -O нужен только для удобства: он выводит результат через описание объекта и на само выполнение выражения не влияет. После начинается уже само Objective-C выражение: известный адрес приводится к указателю на NSView, что позволяет обращаться к находящемуся по этому адресу объекту как к обычному экземпляру этого класса. Затем через сеттер setHidden: свойству hidden передаётся 1 (то есть true или YES), чтобы скрыть визуальный объект.

Если сейчас вернуться к окну и возобновить процесс, можно увидеть, что иконка скорее всего не скрылась после этих манипуляций. Связано это с тем, что зачастую нужно дождаться следующей итерации RunLoop, чтобы увидеть изменения. Но точно так же можно ускорить перерисовку с помощью селектора display, если просто добавить его в конец выражения. Это поможет увидеть результат намного быстрее. После этих манипуляций иконка скрылась, и сделано это без непосредственного доступа к исходникам приложения, а просто за счёт подключения к процессу с помощью отладчика.

Попробую сделать более интересное выражение. Например, не просто скрыть иконку, а обвести её красной рамкой.
Здесь выражение на Objective-C становится уже немного сложнее. При работе с более объёмным кодом может потребоваться явно импортировать нужные фреймворки и заголовки: LLDB компилирует выражение отдельно в контексте текущего процесса, поэтому не все используемые в программе типы и объявления обязательно будут ему доступны. В данном случае нам понадобятся типы из AppKit, поэтому дополнительно импортируем этот фреймворк:
@import AppKit;
NSView *view = (NSView *)0x1413f7aa0;
[view setWantsLayer:1];
CALayer *layer = (CALayer *)[view layer];
[layer setBorderWidth:2.0];
[layer setBorderColor:[[NSColor redColor] CGColor]];
[view display];Сначала, как и раньше, по известному адресу получаем нужный NSView. Затем включаем для него layer-backed rendering и получаем связанный с представлением CALayer. У слоя задаём толщину и красный цвет рамки, после чего вызываем display, чтобы обновить содержимое представления. В результате интересующий объект можно визуально выделить прямо в интерфейсе приложения.
Такое выражение необязательно умещать в одну строку: его можно вводить в LLDB интерактивно как многострочное выражение или, наоборот, записать целиком и передать команде expr. После выполнения кода достаточно вернуться к окну приложения и возобновить процесс. Нужное представление будет выделено красной рамкой.

В итоге получился довольно простой инструмент, который можно использовать в повседневных задачах — например, для отладки иерархии произвольных приложений. Причём не только на macOS: то же самое можно делать и в симуляторе.
Для удобства осталось научиться включать и выключать рамку. Это можно сделать с помощью чуть более сложного Objective-C выражения. К самому NSView обращаемся по адресу точно так же, как и раньше, а вот рамку теперь выносим в отдельный CALayer и задаём ему имя, чтобы потом его можно было найти:
@import AppKit;
NSView *view = (NSView *)0x115064bb0;
[view setWantsLayer:YES];
CALayer *layer = [view layer];
BOOL isRemoved = NO;
for (CALayer *s in [layer sublayers]) {
if ([[s name] isEqualToString:@"lldb_highlight"]) {
[s removeFromSuperlayer];
isRemoved = YES;
[view display];
break;
}
}
if (!isRemoved) {
CALayer *highlight = [CALayer layer];
[highlight setName:@"lldb_highlight"];
[highlight setBorderWidth:2.0];
[highlight setBorderColor:[[NSColor redColor] CGColor]];
[highlight setMasksToBounds:NO];
[highlight setZPosition:9999.0];
[highlight setValue:[view valueForKey:@"bounds"] forKey:@"frame"];
[layer addSublayer:highlight];
[view display];
}При каждом запуске сначала пытаемся найти этот слой и удалить его.
Если слоя нет, значит рамка сейчас выключена — создаём его и добавляем обратно. В результате одно и то же выражение работает как переключатель: один вызов показывает рамку, следующий её убирает.
Каждый раз скармливать эту огромную программу в LLDB, построчно вводить, даже с копированием, не хочется. Хочется делать это более быстрым способом, и LLDB позволяет кастомизировать стандартный набор инструментов.
Во-первых, можно писать полноценные команды-плагины для LLDB на языке Python, но эта возможность выходит за рамки статьи, с ней можно ознакомиться подробнее самостоятельно. Остановимся на более примитивных вариантах, а именно на алиасах (псевдонимах).
По сути, можно создать alias для последовательности команд и использовать его, чтобы выполнять их намного быстрее. Но именно alias не подойдёт, потому что он буквально вводит новое имя для определённой последовательности команд. Поэтому все аргументы, которые захочется туда передавать, а именно адрес интересующего объекта, у которого нужно включать и отключать рамку, будут тоже передаваться в конец. А если посмотреть на ту большую программу, адрес объекта фигурирует где-то в середине.

Для этого подойдёт другая вариация alias, а именно регулярное выражение, которое позволяет задать текстовое выражение точно так же, как в alias, но при этом сделать подстановку аргументов на определённую позицию. Важно отметить, что все эти кастомные настройки объявляются в специальном файле .lldbinit, этот файл подгружается на старте LLDB, и всё, что там описано, можно будет использовать во время любой сессии.

И как раз аргумент, о котором я упоминал ранее, находится здесь, в середине. Указывается он просто как безымянный параметр. И теперь, если перезапустить сессию LLDB, то по имени, которое здесь объявлено, а именно tb (я так сократил toggle border), можно выполнять эту команду и включать-отключать рамку. Так получился удобный инструмент для повседневной разработки.

Таких инструментов, расширяющих стандартный функционал LLDB, в open source много. Есть целая коллекция таких инструментов, которые можно к себе скачать и использовать для более быстрого решения проблем, для проверки своих гипотез. Хочу остановиться на таких коллекциях.
⚡ Самая популярная коллекция — это команды от Дерека Селандера, автора книги Advanced Apple Debugging & Reverse Engineering (раньше она выходила под издательством raywenderlich, сейчас это Kodeco). Там собраны команды с фокусом в основном на исследованиях и базовом reverse engineering. С их помощью можно исследовать загружаемые вместе с нашим бинарем образы. В частности, мне очень нравится команда lookup. Она позволяет буквально делать что-то вроде fuzzy search внутри запущенного процесса и всех подгруженных образов в поисках интересующих символов.

⚡Вторая популярная коллекция команд — Chisel от разработчиков Facebook. Компания Meta признана экстремистской организацией и запрещена на территории РФ, но никто не запрещает использовать инструменты, которые она делает. В отличие от предыдущей коллекции, здесь фокус больше на повседневной разработке. Если посмотреть список представленных команд, можно увидеть команду, похожую на ту, что только что делалась, а именно включение-отключение рамки и скрытие-показ конкретного объекта. И что мне здесь очень нравится, есть команда
visualize, которая позволяет обратиться из LLDB к какому-то буферу и с помощью macOS Preview показать его содержимое. То есть можно буквально посмотреть картинку, цвет или изображение, обратившись просто к объекту из рантайма.

Как внедрить свой код без исходников
Итак, можно исследовать любой процесс, подключиться к нему через LLDB, даже если это произвольный системный процесс, и изменять его поведение с помощью LLDB. Но делать это через отладчик не хочется, ведь его нужно каждый раз вызывать, останавливать процесс и вводить команды для изменения поведения. Вместо этого можно собрать некоторые знания об исследуемом процессе и на их основе написать инкапсулированную логику, которая позднее будет подселяться в произвольный процесс.
Возьмём вот такую простую программу на языке C. Она каждый раз печатает псевдослучайное число с помощью функции rand.
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main() {
srand(time(NULL));
printf("Random number: %d\n", rand());
return 0;
}Чтобы как-то повлиять на эту программу, можно использовать разные техники, но в данном случае задача — сделать так, чтобы число каждый раз было не случайное, а заранее определённое, то есть внести некоторый детерминизм в рандом. Сделать это можно, например, с помощью техники интерпозинга (interposing). Это нативный механизм загрузчика dyld, который позволяет подменить реализацию одной функции на другую. Чем-то похоже на свизлинг (swizzling) в Objective-C, но происходит на другом этапе — при загрузке образа загрузчиком.
Этот механизм, несмотря на то что выглядит как официальный hook-механизм для приложений, изначально задумывался, согласно официальной документации, как инструмент для разработки средств отладки и профилирования. То есть это вполне легальный способ менять одну реализацию на другую. Здесь можно собрать динамическую библиотеку, сделать в ней записи интерпозинга и подселить её в запускаемый процесс.
Немного остановимся на самой библиотеке, где будет делаться интерпозинг. Синтаксис из-за специфики C и самих сигнатур dyld и Mach-O специфичный, но в целом здесь всё просто, с небольшим boilerplate:
#define _DARWIN_C_SOURCE
#include <stdlib.h>
#include <dlfcn.h>
#include <stdatomic.h>
typedef int (*rand_fn_t)(void);
static _Atomic(rand_fn_t) orig_rand_atomic = NULL;
__attribute__((constructor))
static void init_orig_rand(void) {
rand_fn_t pt = (rand_fn_t)dlsym(RTLD_NEXT, "rand");
if (pt) {
atomic_store_explicit(
&orig_rand_atomic,
pt,
memory_order_release
);
}
}
static int custom_rand(void) {
rand_fn_t orig_pt = atomic_load_explicit(
&orig_rand_atomic,
memory_order_acquire
);
if (!orig_pt) {
orig_pt = (rand_fn_t)dlsym(RTLD_NEXT, "rand");
atomic_store_explicit(
&orig_rand_atomic,
orig_pt,
memory_order_release
);
}
return 1337;
}
__attribute__((used))
static struct {
const void *replacement;
const void *original;
} interposers[]
__attribute__((section("__DATA,__interpose"))) = {
{ (const void *)custom_rand, (const void *)rand }
};Сначала происходит поиск исходной реализации функции, а затем объявляется её кастомная реализация с указанием конкретного числа, которое будет возвращаться каждый раз при вызове функции rand. В данном случае число 1337.
И далее происходит интерпозинг: в специальную секцию для dyld кладётся запись в виде пары двух указателей, за счёт чего при загрузке программы одна реализация подменится на другую.
Чтобы подселить эту динамическую библиотеку в процесс, можно использовать очень мощную переменную окружения DYLD_INSERT_LIBRARIES. Она буквально говорит загрузчику, что нужно подселить дополнительные библиотеки в запускаемый процесс. С её помощью можно указать путь к динамической библиотеке и при вызове предкомпилированной программы изменить её поведение.
Для проверки сначала компилируется основная программа, чтобы убедиться, что она каждый раз возвращает разное число. Затем собирается динамическая библиотека и делается своеобразная инъекция с помощью переменной окружения с указанием абсолютного пути к скомпилированной библиотеке. После запуска изначальной программы видно, что теперь благодаря интерпозингу каждый раз возвращается одно и то же число:

Всё то же самое можно делать, например, на Objective-C. Про Swift упоминаю мало, потому что у него из-за особенностей диспатчеризации нет тех возможностей, которые даёт Objective-C за счёт динамического рантайма на сообщениях.
В частности можно использовать свизлинг (swizzling), о котором уже говорилось в сравнении с интерпозингом. Например, не меняя исходный код произвольной программы, которая запускается на симуляторе, свизлингом логировать сетевые запросы, которые идут через URLSession. В Objective-C этот же Foundation API доступен как NSURLSession, поэтому его методы можно подменять через Objective-C рантайм:
typedef void (^URLCompletion)(NSData *, NSURLResponse *, NSError *);
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
static inline void Swizzle(Class c, SEL o, SEL r) {
method_exchangeImplementations(
class_getInstanceMethod(c, o),
class_getInstanceMethod(c, r)
);
}
static inline NSString *HTTPMethodOrGET(NSURLRequest *r) {
return r.HTTPMethod ?: @"GET";
}
@implementation NSURLSession (URLLoggerSwizzle)
+ (void)load {
static dispatch_once_t once;
dispatch_once(&once, ^{
Class c = self;
Swizzle(
c,
@selector(dataTaskWithRequest:completionHandler:),
@selector(urllogger_dataTaskWithRequest:completionHandler:)
);
Swizzle(
c,
@selector(dataTaskWithURL:completionHandler:),
@selector(urllogger_dataTaskWithURL:completionHandler:)
);
Swizzle(
c,
@selector(dataTaskWithRequest:),
@selector(urllogger_dataTaskWithRequest:)
);
Swizzle(
c,
@selector(dataTaskWithURL:),
@selector(urllogger_dataTaskWithURL:)
);
});
}
- (NSURLSessionDataTask *)urllogger_dataTaskWithRequest:(NSURLRequest *)request
completionHandler:(URLCompletion)completionHandler
{
NSURL *u = request.URL;
if (u) {
NSLog(@"[URLSwizzle] %@ %@", HTTPMethodOrGET(request), u.absoluteString);
}
return [self urllogger_dataTaskWithRequest:request
completionHandler:completionHandler];
}
- (NSURLSessionDataTask *)urllogger_dataTaskWithURL:(NSURL *)url
completionHandler:(URLCompletion)completionHandler
{
if (url) {
NSLog(@"[URLSwizzle] GET %@", url.absoluteString);
}
return [self urllogger_dataTaskWithURL:url
completionHandler:completionHandler];
}
- (NSURLSessionDataTask *)urllogger_dataTaskWithRequest:(NSURLRequest *)request
{
NSURL *u = request.URL;
if (u) {
NSLog(@"[URLSwizzle] %@ %@", HTTPMethodOrGET(request), u.absoluteString);
}
return [self urllogger_dataTaskWithRequest:request];
}
- (NSURLSessionDataTask *)urllogger_dataTaskWithURL:(NSURL *)url
{
if (url) {
NSLog(@"[URLSwizzle] GET %@", url.absoluteString);
}
return [self urllogger_dataTaskWithURL:url];
}
@endИ здесь тоже всё просто. Вспомогательные функции свиззлинга выполняются в методе специального назначения load, который вызывается в момент, когда категория или какой-то класс добавляется в рантайм Objective-C, то есть на этапе загрузки кода. Указанные четыре основных метода URLSession покрывают большую часть сетевых запросов, поэтому в них можно залогировать адрес, по которому идёт запрос, и дополнительную информацию.
Теперь запускаем произвольное приложение на симуляторе, чтобы подселить эту динамическую библиотеку. Используем всю ту же переменную DYLD_INSERT_LIBRARIES, и объявить её можно несколькими способами. Во-первых, через Xcode: зайти в настройки текущей схемы, которая запускается, и в переменных окружения явно прописать DYLD_INSERT_LIBRARIES с абсолютным путём к библиотеке.

Либо запустить процесс на симуляторе вручную через xcrun simctl и так же передать переменные окружения. Но важно отметить, что здесь нужен специальный префикс SIMCTL_CHILD_, чтобы переменная попала внутрь запускаемого дочернего процесса, а не в сам процесс simctl.

Есть ещё более продвинутая версия запуска через xcrun — указание переменной через launchctl, которая позволяет выставить её, по сути, для всего пользовательского пространства симулятора.

Так можно очень удобно менять даже сам симулятор, а именно SpringBoard, который всегда перед глазами: рабочий стол, иконки и всё прочее. В таком случае переменная будет распространяться на любой запускаемый процесс внутри этого пользовательского окружения.
Любой из этих вариантов подойдёт, чтобы выставить переменную. И запустив даже не обязательно своё приложение, а любой закинутый на симулятор .app, можно обратиться к логам и получить результат, не меняя исходники.

Такой подход применяется часто. Самый яркий, на мой взгляд, представитель таких инструментов — утилита Simulator Status Magic, которая использует эту технологию и как раз запуск через xcrun simctl. Её часто используют, чтобы стандартизировать внешний вид симулятора и, например, гарантировать стабильность окружения для снапшот-тестов. Если посмотреть исходники, там тоже собирается динамическая библиотека и подселяется в процесс симулятора скриптом за счёт переменной DYLD_INSERT_LIBRARIES.

Этим возможности подхода не ограничиваются. Помимо настройки статус-бара симулятора, его можно использовать и для более сложных сценариев — например, для имитации камеры с заранее подготовленными сценами или ассетами. В новых версиях Xcode уже появились похожие возможности, но при желании этот механизм можно развить дальше, приблизив его к тому, что доступно в Android-эмуляторах. Ещё один практический сценарий — подмена источников времени и случайных значений, чтобы сделать окружение детерминированным и повысить воспроизводимость тестов на симуляторе.
У этого подхода есть ограничения. В macOS приложение может использовать Hardened Runtime — набор защитных механизмов, который, помимо прочего, ограничивает внедрение стороннего кода в процесс. По умолчанию такие приложения не учитывают переменные окружения DYLD_*, включая используемую нами DYLD_INSERT_LIBRARIES, а Library Validation дополнительно ограничивает загрузку сторонних библиотек. Для собственного приложения эти ограничения можно частично ослабить специальными entitlements, но для произвольного уже подписанного приложения такой возможности обычно нет.
Нужны ли дебажные символы
Необязательно. С полноценной отладочной информацией LLDB знает гораздо больше: например,
DWARFпозволяет восстановить имена локальных переменных, типы, соответствие машинного кода строкам исходников и другие сведения. В Apple-платформах такая информация часто выносится в отдельныйdSYM.Но даже без
dSYMбинарь не становится полностью непрозрачным. В самом Mach-O могут оставаться локальные и экспортируемые символы, а языковые рантаймы хранят собственные метаданные, необходимые программе для работы. Особенно хорошо это заметно на Objective-C: в бинаре остаются имена классов и категорий, селекторы и другая информация Objective-C рантайма. Поэтому даже stripped-приложение часто сохраняет довольно много осмысленных имён, которые можно увидеть в LLDB или дизассемблере.Похожая ситуация и со Swift. Для работы Swift Runtime и его ABI тоже требуется определённый объём метаданных о типах и протоколах, хотя их состав и доступность сильно зависят от того, как собран и оптимизирован бинарь.
Наконец, сам формат Mach-O содержит служебную информацию для динамического загрузчика — например, данные о линковке и экспортируемых символах.

Заключение, или Почему LLDB больше чем отладчик
Если свести всё к одному выводу, для меня LLDB — не просто отладчик, а полноценный инструмент исследования. Это способ легально и осмысленно исследовать платформу глубже обычного: заглядывать в системные компоненты, куда мы обычно не заглядываем, и убирать ту магию, которая мешает понять, почему код ведёт себя так, а не иначе. Ценность здесь не только в том, чтобы точнее находить причины багов, но и чтобы аккуратно менять поведение программ, проверять свои гипотезы и при необходимости дописывать под задачу собственные инструменты.
И чем лучше это получается, тем сильнее мы как инженеры, потому что уже не просто чиним симптомы, а начинаем лучше понимать причины.
Что дальше
Чтобы углубиться в тему, я бы хотел поделиться литературой, которая вдохновила меня на создание этой статьи. Эти источники хорошо показывают, как в macOS, и в частности в iOS, работают некоторые вещи.
1️⃣ Книга Advanced Apple Debugging & Reverse Engineering Дерека Селандера. Сильный инженер, который специализируется как раз в open source на этих LLDB-хаках.
2️⃣ Выступление Reverse Engineering iOS Simulator SpringBoard Дерека Селандера. Там Дерек делает интересные вещи с симулятором: подключается к процессу, всячески его крутит, меняет поведение и расширяет функционал.
3️⃣ Книга Swift Secrets Джона Холдсворта. Он втор инструментов вроде Injection, SwiftTrace и Fortify. В книге — особенности запуска процессов на iOS и macOS, как влиять на динамический рантайм и много внутрянки о том, как устроен загрузчик. Эти знания и приводят в итоге к таким интересным инструментам вроде Injection и Fortify.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.