Читаем бинарные файлы iOS-приложений

в 8:38, , рубрики: iOS, mach-o, objective-c, Блог компании Solar Security, разработка под iOS, реверс-инжиниринг

На хабре есть много статей о том, как работает рантайм Swift/Objective-C, но для еще более полного понимания того, что происходит под капотом, полезно залезть на самый низкий уровень и посмотреть, как код iOS приложений укладывается в бинарные файлы. Кроме того, безусловно, под капот приходится залезать при решении реверс-инжиниринговых задач. В этой статье мы обсудим самые простые конструкции Objective-C, а о Swift и более сложных примерах поговорим в последующих статьях.

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

Читаем бинарные файлы iOS-приложений - 1

Мы будем изучать файлы с 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 на вот таком свиче вверху:

Читаем бинарные файлы iOS-приложений - 2

Подготовка в Xcode

Будем писать изучаемое приложение сами. Создадим для этого в Xcode Single View Application на Objective-C. Для удобства можно оставить в Build Settings только архитектуру arm64. Мы будем билдить (cmd+B) под всегда доступное устройство Generic iOS Device:

Читаем бинарные файлы iOS-приложений - 3

В принципе, можно билдить и под настоящее устройство, но не стоит под симулятор, потому что бинарный файл получится сильно другой (другая архитектура). Итак, пусть наше приложение называется InspectedObjc. Для компактности не будем пользоваться .h-файлами и будем все писать в .m-файлах. Создаем файл InspectedObject.m и заводим в нем класс со всякими разнообразностями (код в следующей секции).
Не забываем добавить его к цели и билдим. Видим в папке Products готовое приложение:

Читаем бинарные файлы iOS-приложений - 4

Далее 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. Хоппер парсит имена, и поэтому видно, что наш класс — третий:

Читаем бинарные файлы iOS-приложений - 5

Остальные два сгенерировались при создании Single View Application. Идентифицировать нужный класс можно и без распарсенных имен, для этого нужно достать имена самостоятельно. Как это делать, понятно из дальнейшего.

Итак, переходим к _OBJCCLASS$_InspectedObject. В хоппере он выглядит так:

Читаем бинарные файлы iOS-приложений - 6

что соответствует следующей структуре:

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 и получить триальный доступ на одно бесплатное сканирование.

Автор: Solar Security

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js