Исследуем баг iOS с помощью Hopper
Привет! Меня зовут Александр Никишин, я занимаюсь разработкой iOS-приложений в компании Badoo. В статье я расскажу о том, как мы исследовали баг в UIKit, который Apple не хотела исправлять на протяжении полугода.
Всё началось в августе 2019 года с первых бета-версий iOS 13. Тогда мы впервые столкнулись с проблемой. В приложениях Badoo и Bumble мы постоянно работаем над улучшением интерфейсов и, например, стараемся максимально оптимизировать нудный и не любимый пользователями процесс регистрации. Системные предиктивные подсказки над клавиатурой — отличный способ сокращения количества кликов пользователя при вводе данных. Однако в новой версии iOS мы с удивлением обнаружили, что подсказки при вводе номера телефона пропали.
Когда вышла GM-версия, мы поняли, что проблема так и не была устранена. Нам казалось, что такой очевидный баг просто не могут пропустить регрессионные тесты, которые в арсенале Apple наверняка есть, и мы стали ждать исправления в первом большом пакете обновлений. На это надеялись и другие разработчики. Однако с выходом версии 13.1 ничего не изменилось, и нам ничего не оставалось, кроме как открыть радар, что мы и сделали в начале октября. Время шло, вышли iOS 13.2 и 13.3, а баг всё оставался неисправленным.
В феврале у нашей команды регистрации появилось немного свободного от работы над бэклогом времени, и я решил исследовать эту проблему поглубже.
Прежде чем начинать копать, нужно было выяснить, в какую сторону это делать. Поскольку подсказки продолжали работать на некоторых типах клавиатуры, первой же мыслью стало изучение иерархии её вьюшек в разных версиях iOS.
iOS 12 vs iOS 13
Тут же стало понятно, что Apple в iOS 13 провела рефакторинг имплементации клавиатуры и выделила подсказки в отдельный контроллер (UIPredictionViewController
). Очевидно, тренд на модуляризацию и декомпозицию докатился и до неё (кстати, о нашем опыте их применения недавно рассказывал Артём Лоенко). Скорее всего, из-за этого и произошёл регресс функциональности. Круг поисков начал сужаться.
Я думаю, большинство iOS-разработчиков знает, что в открытом доступе легко найти интерфейсы приватных системных классов: для этого достаточно ввести всего один запрос в поисковике. При изучении интерфейса класса UIPredictionViewController
в глаза сразу бросается один из его методов:
Кажется, появилась зацепка, которую довольно легко проверить, используя старый добрый инструмент подмены имплементации функций (Swizzling). Воспользуемся им, чтобы функция, находящаяся «под подозрением», всегда возвращала истинное значение:
+(void)swizzleIsVisibleForInputDelegate {
SEL targetSelector = sel_getUid("isVisibleForInputDelegate:inputViews:");
Class targetClass = NSClassFromString(@”UIPredictionViewController”);
if (targetClass == nil) {
return;
}
if (![targetClass instancesRespondToSelector:targetSelector]) {
return;
}
Method method = class_getInstanceMethod(targetClass, targetSelector);
if (method == NULL) {
return;
}
IMP originalImplementation = method_getImplementation(method);
IMP newImp = imp_implementationWithBlock(^BOOL(id me, id delegate, id views) {
// Вызываем оригинальную реализацию, чтобы избежать возможной неконсистентности состояний внутри приватных классов.
BOOL result = ((bool (*)(id,SEL,id,id))originalImplementation)(me, targetSelector, delegate, views);
if ([delegate isKindOfClass:[UITextField class]] && [delegate keyboardType] == UIKeyboardTypePhonePad) {
return YES;
}
return result;
});
method_setImplementation(method, newImp);
}
Перезапустив тестовый проект, я обнаружил, что телефонные подсказки вернулись в iOS 13 и работают «в штатном режиме». На этом можно было бы закончить расследование и, возможно, даже очень осторожно использовать это опасное и запрещённое гайдлайнами Apple решение в релизной сборке с возможностью удалённого включения/отключения для части пользователей (про опцию удалённого управления фичами можно посмотреть запись доклада моей коллеги Катерины Трофименко). И всё же меня не переставало интересовать, по какой причине данная функция возвращает false при использовании телефонного типа клавиатуры.
Чтобы добраться до истины, нам нужен исходный код функции. Очевидно, что Apple не раздаёт код компонентов iOS направо и налево, так что нагуглить его не получится. Остаётся только один способ — реверсивный инжиниринг для декомпиляции бинарного кода. Ранее я неоднократно слышал о таком продукте, как Hopper, и читал несколько статей о его применении с целью поковыряться «под капотом» у системных библиотек, но сам ни разу не использовал. И сразу же я был приятно удивлён: оказалось, что для того чтобы поиграться и изучить инструментарий, даже необязательно покупать полную версию. Демоверсия включает в себя бесконечные 30-минутных сессий работы без возможностей сохранения индекса и внесения изменений в бинарные файлы, а значит, является отличной площадкой для экспериментов.
Всё из того же опубликованного приватного интерфейса можно узнать, что UIPredictionViewController
является частью фреймворка UIKitCore. Остаётся только найти его и загрузить в Hopper. Бинарные файлы фреймворков находятся в глубинах Xcode. Вот, например, полный путь до нужного нам UIKitCore:
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore
Делаем drag-and-drop файла в Hopper, подтверждаем действие в системном диалоге и ждём завершения индексации (в случае с таким большим фреймворком, как UIKit, это может занять от четырёх до шести минут).
Интерфейс программы довольно прост, я отмечу только ключевые элементы, которые потребовались для проведения моего исследования. В левой панели интерфейса можно найти поисковую строку, через которую происходит навигация по коду. Осуществив поиск по названию интересующего нас класса, быстро получаем исследуемую функцию, её ассемблерный код открывается в основном окне.
В верхней панели задач находятся кнопки переключения режимов отображения кода. Слева направо:
- ASM mode — ассемблерный код;
- CFG mode — ассемблерный код в виде блок-схемы (дерева), где куски кода объединены в блоки, а переходы показаны в виде ответвлений;
- Pseudo-code mode — cгенерированный псевдокод (подробнее о нём поговорим ниже);
- Hex mode — 16-ричная репрезентация бинарного файла, или абракадабра, плохо помогающая нам в нашем исследовании.
Теперь нужно выяснить, что же происходит внутри функции. Тело её довольно длинное, поэтому разобраться в логике, глядя на ассемблерный код, могут только настоящие ASM-гуру, коим я себя назвать не могу. Для этого поможет Pseudo-code mode, в котором Hopper максимально упрощает ассемблерные операции, подставляя там, где это возможно, реальные имена функций и используя имена регистров наподобие переменных. Выглядит это так:
Остаётся только пройтись по логике функции и понять, в какое из её ответвлений мы попадаем. В этом мне помогли Symbolic Breakpoints, которые можно устанавливать и на системные вызовы, попутно печатая в консоль Xcode все необходимые переменные и результаты вызовов функций. Пользуясь таким нехитрым способом, я обнаружил, что выполнение функции прерывается ранним выходом из-за того, что в приведённом в качестве примера блоке кода не срабатывает одно из условий. Давайте разбираться, что тут происходит.
Немного контекста: в регистр rbx чуть выше по коду (который я опустил для простоты) помещается ссылка на объект, имплементирующий протокол UITextInputTraits_Private
(это расширенная версия публичного протокола UITextInputTraits
).
Итак, первое из условий — это проверка того, что подсказки не скрыты конфигурацией поля ввода. В дебаге можно заметить, что оно выполняется: свойство hidePrediction
возвращает false. Второе условие — проверка того, что клавиатура не находится в режиме “split” (на своём iPad потяните за нижнюю правую кнопку вверх, об этой штуке знают всего 2—3% пользователей). С этим тоже всё в порядке.
Идём дальше. На следующем этапе начинаются манипуляции с keyboardType
, которые намекают нам на то, что истина где-то рядом. Сначала проверяется, что текущий keyboardType
меньше или равен 0xb (или 11 в десятичном формате). Если открыть в Xcode декларацию UIKeyboardType
, мы увидим, что существует 13 типов клавиатур, причём один из них (UIKeyboardTypeAlphabet
) устарел и объявлен как ссылка на другой тип. То есть всего в enum 12 типов: если начать с 0, последний будет иметь значение, равное 11. Иными словами, в коде производится валидация значения в виде проверки на переполнение, и она, опять же, проходит успешно.
Далее мы видим очень странное условие if (!COND)
, и я долго не мог понять, что же оно проверяет, учитывая, что нигде выше по коду переменная COND не объявлялась. Более того, мои Symbolic Breakpoints показывали, что именно невыполнение этого условия и приводит к раннему выходу из функции. И тут нам ничего не остаётся, кроме как вернуться к ASM mode и изучить данную часть кода в ассемблерном виде.
Чтобы найти данное условие в ASM-листинге, можно воспользоваться опцией “No code duplication”, которая покажет псевдокод не в виде исходного кода с условиями if-else, а в виде уникальных блоков кода и переходов в виде goto. В этом случае мы увидим позицию начала этих блоков в бинарном файле и используем этот указатель для поиска в ASM mode. Так я узнал, что интересующий нас блок находится по адресу fe79f4:
Вернувшись в ASM mode, мы легко найдём этот блок:
Мы подходим к самой сложной части, где будем разбирать три строчки ассемблерного кода.
Первую строчку я распознал по памяти (спасибо урокам ассемблера в родном МИЭТ, наконец-то настал в моей жизни тот момент, когда высшее образование пригодилось!). Тут всё довольно просто: в регистр ecx помещается константа 0x930 (100100110000 в бинарном формате).
Во второй строке мы видим инструкцию bt, производимую над регистрами exc
и eax
. Значение одного нам уже известно, значение второго можно увидеть на предыдущем скриншоте: rax = [rbx keyboardType]
— в нём находится текущий тип клавиатуры. rax
— это весь 64-битный регистр, eax
— это его 32-битная часть.
С данными определились, осталось понять логику команды. Google нас приводит к такому описанию:
Selects the bit in a bit string (specified with the first operand, called the bit base) at the bit-position designated by the bit offset operand (second operand) and stores the value of the bit in the CF flag.
Инструкция извлекает бит из первого операнда (константа 0x930) в позиции, заданной вторым операндом (тип клавиатуры), и помещает его в CF (carry flag). То есть в результате в CF будет находиться 0 или 1 в зависимости от типа клавиатуры.
Переходим к последней операции jb
, она имеет следующее описание:
Jump short if below (CF=1)
Нетрудно догадаться, что тут происходит переход выполнения функции (ранний выход), если в CF находится 1.
В этот момент пазл начинает складываться. Мы имеем битовую маску 100100110000 (в ней 12 битов — по числу доступных типов клавиатур), и именно она определяет условие раннего выхода. Теперь, если мы проверим наличие подсказок для всех типов клавиатуры в порядке возрастания rawValue
, всё будет на месте.
В iOS 12 мы не найдём такой логики — подсказки там работают для любого типа клавиатуры. Подозреваю, что в iOS 13 Apple решила отключить подсказки для цифровых клавиатур, что в принципе понятно: я не могу придумать сценарий, когда системе нужно подсказывать числа. Видимо, по ошибке «под горячую руку» попал и UIKeyboardTypePhonePad
, который очень похож на обычную цифровую клавиатуру. При этом UIKeyboardTypeNamePhonePad
, комбинирующий для поиска телефонных контактов QWERTY-клавиатуру и точно такую же отключённую цифровую, продолжал показывать подсказки.
Работать с Hopper, к моему удивлению, оказалось очень приятно и занимательно, давно я не получал столько фана. Найденными изысканиями я поделился с инженерами Apple в своем баг-репорте, а его статус со временем изменился на “Potential fix identified — For a future OS update”. Надеюсь, фикс доедет до пользователей в будущих обновлениях. При этом Hopper можно использовать не только для поиска причин ваших или Apple багов, но и для обнаружения фиксов Apple для программ сторонних разработчиков.