На хабре есть много статей о том, как работает рантайм Swift/Objective-C, но для еще более полного понимания того, что происходит под капотом, полезно залезть на самый низкий уровень и посмотреть, как код iOS приложений укладывается в бинарные файлы. Кроме того, безусловно, под капот приходится залезать при решении реверс-инжиниринговых задач. В этой статье мы обсудим самые простые конструкции Objective-C, а о Swift и более сложных примерах поговорим в последующих статьях.
В первых двух секциях я постарался максимально подробно осветить практические подробности, чтобы читателю, желающему пройти этот тьюториал, не нужно было каждые две минуты гуглить вещи в стиле "где найти бинарный файл приложения в Xcode". Дальше все максимально информативно.
Мы будем изучать файлы с 64-битной архитектурой arm64. Интересующие нас объекты в бинарном файле представляют собой записанные подряд 16-, 32- и 64-битные слова и null-terminated строки, так что мне будет удобно говорить о них на С. Например, я буду говорить, что описание метода в бинарном файле выглядит так:
struct objc_method { // по адресу описания метода подряд лежат:
uint64 name_addr; // адрес, по которому лежит null-terminated строка, имя переменной,
uint64 type; // адрес, по которому лежит null-terminated строка, сигнатура метода,
uint64 imp_addr; // адрес реализации метода
}
Кроме uint64 также встречаются 32-битные параметры uint32, 16-битные uint16, и для относительных указателей используются int64 и int32.
Macho-O и Hopper
Бинарные файлы iOS-приложений имеют формат Mach-O. Получить представление об этом формате можно здесь (глава “Кратко о Mach-O”). Один из удобных способов просматривать Mach-O-файлы — это дизассемблер Hopper. Скачать триальную версию можно здесь.
Для навигации в хоппере удобно знать пару шорткатов:
Shift+S — список секций
G — перейти по адресу
На любой адрес, встретившийся в ассемблере, можно щелкнуть два раза и перейти по нему. Кроме того, хоппер парсит много названий разных сущностей, и по ним можно производить поиск (строка поиска слева ближе к верху).
Иногда бывает полезно посмотреть на нераспарсенный бинарный код. Для этого можно выбрать Hexadimal Mode на вот таком свиче вверху:
Подготовка в Xcode
Будем писать изучаемое приложение сами. Создадим для этого в Xcode Single View Application на Objective-C. Для удобства можно оставить в Build Settings только архитектуру arm64. Мы будем билдить (cmd+B) под всегда доступное устройство Generic iOS Device:
В принципе, можно билдить и под настоящее устройство, но не стоит под симулятор, потому что бинарный файл получится сильно другой (другая архитектура). Итак, пусть наше приложение называется InspectedObjc. Для компактности не будем пользоваться .h-файлами и будем все писать в .m-файлах. Создаем файл InspectedObject.m и заводим в нем класс со всякими разнообразностями (код в следующей секции).
Не забываем добавить его к цели и билдим. Видим в папке Products готовое приложение:
Далее Show in Finder и Show Package Contents на InspectedObjc.app. Ок, теперь можно скормить бинарный файл InspecteObjc хопперу.
Лезем под капот
Эта часть, в некоторой мере, является объяснением кода отсюда. Итак, будем изучать, как выкладывается в бинарный файл класс InspectedObject из такого InspectedObject.m:
#import <Foundation/Foundation.h>
@protocol InspectedProtocol <NSObject>
- (int)instanceMethod:(NSString *)string;
+ (NSNumber *)classMethod:(NSNumber *)number;
@end
@interface InspectedObject : NSObject<InspectedProtocol> {
int intIvar;
NSString __weak *weakStringIvar;
NSNumber *strongNumberIvar;
}
@property(nonatomic, strong) NSString *strongStringProperty;
@property(weak) NSNumber *weakNumberProperty;
- (int)instanceMethod:(NSString *)string;
+ (NSNumber *)classMethod:(NSNumber *)number;
@end
@implementation InspectedObject
- (int)instanceMethod:(NSString *)string {
return intIvar;
}
+ (NSNumber *)classMethod:(NSNumber *)number {
return @234;
}
@end
Смотрим в бинарный файл. Секция objc_classlist — это список адресов классов:
struct objc_classlist {
uint64 classes[num_classes];
}
В нашем бинарном файле num_classes = 3. Хоппер парсит имена, и поэтому видно, что наш класс — третий:
Остальные два сгенерировались при создании Single View Application. Идентифицировать нужный класс можно и без распарсенных имен, для этого нужно достать имена самостоятельно. Как это делать, понятно из дальнейшего.
Итак, переходим к _OBJCCLASS$_InspectedObject. В хоппере он выглядит так:
что соответствует следующей структуре:
struct objc_class {
uint64 metaclass_addr; // адрес метакласса; здесь хранятся class methods
uint64 superclass_addr; // адрес класса родителя, в нашем случае -- NSObject
uint64 cache_addr; // в бинарных файлах это адрес __objc_empty_cache, настоящий кэш селекторов заполняется в рантайме
uint64 vtable_addr; // в бинарных файлах это 0, заполняется в рантайме
uint64 raw_data_addr; // здесь лежит объект типа raw_data (см. ниже)
}
Здесь можно посмотреть, какие примерно методы лежат в таблице виртуальных функций.
Будем называть переменные экземпляра класса калькой с английского — иварами (ivar). Данные класса:
struct raw_data {
uint32 flags; // некоторые флаги, используемые в рантайме
uint32 instance_start; // куда, относительно начала экземпляра класса, указывают указатели на экземпляр
uint32 instance_size; // размер объекта нашего класса
uint32 reserved; // пустое поле, что-то может записываться сюда в рантайме
uint64 strong_ivar_layout_addr; // раскладка strong иваров
uint64 name_addr; // адрес имени класса
uint64 method_list_addr; // список методов
uint64 protocol_list_addr; // список протоколов
uint64 ivar_list_addr; // список иваров
uint64 weak_ivar_layout_addr; // раскладка weak иваров
uint64 properties_list_addr; // список свойств
}
Списки методов, протоколов, иваров и свойств состоят каждый из 64-битного хедера и последовательности некоторых одинаковых структур.
Начнем со списка методов. Он начинается 64-битным хедером:
struct objc_list_header {
uint32 flags; // некоторые рантайм флаги
uint32 size; // размер списка
}
Дальше подряд идут следующего вида структуры:
struct objc_method {
uint64 name;
uint64 signature; // сигнатура метода
uint64 implementation; // адрес реализации метода
}
Сигнатура может выглядеть, например, вот так: i24@0:8@16. Цифры тут смысловой нагрузки не несут, а остальное расшифровывается следующим образом:
i -> int — возвращаемое значение
@ -> Objective-C объект — 1й аргумент, self
: -> селектор — 2й аргумент
@ -> Objective-C объект — 3й аргумент (1й аргумент после “:”)
Точные типы объектов Objective-C по сигнатуре восстановить нельзя.
Заметим, что в этом списке будут методы, которые мы не определяли, а именно сеттеры и геттеры свойств и метод -[InspectedObject .cxx_destruct], использующийся в ARC (и Objective-C++).
Хедер списка протоколов состоит просто из 64-битного размера списка. Далее идут 64-битные адреса протоколов, которым удовлетворяет класс. Протокол в памяти выглядит так:
struct objc_protocol {
uint64 isa_addr; // адрес класса Протокол, в бинарном файле не заполнен, равен 0
uint64 name_addr; // адрес имени
uint64 protocols_addr; // адрес списка протоколов, которым удовлетворяет протокол
uint64 instance_methods_addr;
uint64 class_methods_addr;
uint64 optional_instance_methods_addr;
uint64 optional_class_methods_addr;
uint64 instance_properties_addr;
}
Неоткомментированные поля соответствуют спискам, устроенным аналогично спискам в raw_data класса.
Все ивары записаны в objc_ivar_list, начинающийся хедером типа objc_list_header, и выглядят следующим образом:
struct objc_ivar {
uint64 offset_addr; // адрес, по которому лежит отступ ивара
uint64 name_addr; // адрес имени
uint64 type_addr; // адрес строки с типом
uint32 alignment; // минимальная память в байтах, кратная 8, в которую помещается переменная
uint32 size; // размер переменной
}
Стоит отметить, что в этом списке будут также синтезированные переменные (в нашем случае — NSString _strongStringProperty и NSNumber _weakNumberProperty). С помощью полей "раскладок" сильных и слабых переменных из raw_data можно понять, на какие из членов класса объект хранит сильные ссылки и на какие — слабые. Остальные переменные будут присваиваться по значению. Раскладка — это последовательность чисел от 1 до 15, заканчивающаяся нулевым байтом. Каждое второе число — это количество последовательных (в objc_ivar_list) переменных одного типа и каждое второе другое число — это промежутки между блоками последовательных переменных одного типа. В нашем случае переменные идут в порядке assign-weak-strong-strong-weak. Сначала идут 2 не-strong переменные, а потом — 2 strong. Поэтому раскладка сильных переменных — 0x22. Раскладка слабых переменных — 0x1121. Хоппер парсит раскладки как строки, и поэтому показывает, например, во втором случае "\x11!". Чтобы увидеть исходную последовательность байтов, можно перейти в Hexadimal Mode.
Список свойств начинается хедером типа objc_list_header и состоит из таких структур:
struct objc_property {
uint64 name_addr; // адрес имени
uint64 attributes_addr; // адрес строки атрибутов
}
Опишем устройство строки атрибутов. Строка начинается с “T”, за которым следует тип свойства, затем через запятую идут свойства:
R — readonly
C — copy
& — retain (strong, если используется ARC)
N — nonatomic
G — кастомный геттер
S — кастомный сеттер
D — dynamic
W — weak
P — свойство подходит для автоматической сборки мусора
t — типа в старой кодировке
Заканчивается строка атрибутов именем ивара свойства с префиксом V. Для нашего свойства
@property(nonatomic, strong) NSString *strongStringProperty;
получается строка атрибутов "T@\"NSString\",&,N,V_strongStringProperty".
Заметим, что в списке свойств, как и в списке методов, есть некоторые, сгенерированные автоматически:
@property(readonly) NSUInteger hash;
@property(readonly, copy) NSString *description;
@property(readonly, copy) NSString *debug_description;
@property(readonly) id superclass; // здесь, вообще говоря, непонятный "T#"
Стоит заметить, что ивары для этих свойств не синтезируются.
Для полной картины осталось сказать еще о методах класса (class methods). Это обычные методы, но экземпляр в них — класс и, следовательно, хранятся они в классе класса, то есть в метаклассе. У метакласса так же есть raw_data и список методов. Собирая все вместе, получаем следующий восстановленный интерфейс класса:
@interface InspectedObject : NSObject<InspectedProtocol> {
int intIvar;
NSString __weak *weakStringIvar;
NSNumber *strongNumberIvar;
NSString *_strongStringProperty;
NSNumber __weak *_weakNumberProperty;
}
@property(nonatomic, strong) NSString *strongStringProperty;
@property(weak) NSNumber *weakNumberProperty;
@property(readonly) NSUInteger hash;
@property(readonly, copy) NSString *description;
@property(readonly, copy) NSString *debug_description;
@property(readonly) id superclass;
- (int)instanceMethod:(id)arg;
- (void).cxx_destruct;
- (id)strongStringProperty;
- (id)setStrongStringProperty:(id)arg;
- (id)weakNumberProperty;
- (id)setWeakNumberProperty:(id)arg;
+ (id)classMethod:(id)arg;
@end
Убирая автоматически сгенерированное, получаем:
@interface InspectedObject : NSObject<InspectedProtocol> {
int intIvar;
NSString __weak *weakStringIvar;
NSNumber *strongNumberIvar;
}
@property(nonatomic, strong) NSString *strongStringProperty;
@property(weak) NSNumber *weakNumberProperty;
- (int)instanceMethod:(id)arg;
+ (id)classMethod:(id)arg;
@end
Итого, не считая потери информации о точных типах Objective-C объектов в аргументах, интерфейс восстанавливается полностью.
В заключение хочу сделать небольшой анонс: если вам интересно проверить свой код с помощью автоматического анализатора, сейчас как раз тот момент, когда это можно сделать абсолютно бесплатно. Вот тут можно прочитать про Solar inCode и получить триальный доступ на одно бесплатное сканирование.