Основой мобильных приложений является пользовательский интерфейс. По этой причине, при анализе приложения без доступа к исходным кодам, точку входа в определенный блок функциональности кажется логичным искать в этом самом пользовательском интерфейсе прямо во время работы приложения и уже собрав некоторую информацию о логике работы переходить к реверсу.
В данной статье будет рассказано как узнать какой callback будет вызван при нажатие кнопки в интерфейсе iOS приложения с использованием фреймворка frida.
Также я думаю эта статья будет полезна тем разработчикам на iOS кто хочет знать как работает внурянка cllaback-ов графических элементов.
Для нетерпеливых конечный скрипт тут.
В чем собственно проблема
Если мы имеем на входе простое одноэкранное приложение то найти нужную кнопку не составит особого труда — достаточно выполнить команду:
ObjC.classes.UIWindow.keyWindow().recursiveDescription().toString()
И получить список всех имеющихся графических элементов:
"<UIWindow: 0x10110b250; frame = (0 0; 667 375); gestureRecognizers = <NSArray: 0x2832b58c0>; layer = <UIWindowLayer: 0x283c83dc0>>
| <UITransitionView: 0x10110d150; frame = (0 0; 667 375); autoresize = W+H; layer = <CALayer: 0x283c83220>>
| | <UIDropShadowView: 0x10110e780; frame = (0 0; 667 375); autoresize = W+H; layer = <CALayer: 0x283c812a0>>
| | | <UIView: 0x10110b050; frame = (0 0; 667 375); autoresize = W+H; layer = <CALayer: 0x283c816e0>>
| | | | <UIButton: 0x10110b540; frame = (250 171; 62 33); opaque = NO; autoresize = RM+BM; layer = <CALayer: 0x283c83fa0>>
| | | | | <UIButtonLabel: 0x101117e00; frame = (0 6; 62 21); text = 'Test'; opaque = NO; userInteractionEnabled = NO; layer = <_UILabelLayer: 0x281f8c0a0>>"
Откуда посредствам ручного анализа достать адрес нужной кнопки (0x10110b540) и посмотреть зарегистрированные обработчики:
var button = new ObjC.Object(new NativePointer("0x10110b540"))
button._allTargetActions().toString()
В результате получим что-то вроде:
"(
\"<UIControlTargetAction: 0x2832b6fd0> actionHandler=<_UIImmutableAction: 0x281891860; title = > events=0x40\",
\"<UIControlTargetAction: 0x2832b6f70> target=0x10110a9d0 action=click1 events=0x40\",
\"<UIControlTargetAction: 0x2832b6f40> target=0x0 action=click2 events=0x40\"
)"
Как минимум у нас есть имена двух селекторов — click1
и click2
, а для одного из них есть даже объект который его реализует — поле target
— из которого можно достать имя класса, но об этом позже. В любом случае есть что поискать в IDA для понимания дальнейшей логики работы.
Однако ситуация меняется кардинальным образом когда мы переходим к реальным приложениям где может быть больше одной перекрывающейся сцены и в выводе keyWindow().recursiveDescription()
появляется больше 30 кнопок. Разбираться со всем этим богатством руками нет никакого желания, а для автоматизации придется немного разобраться в том как происходит обработка нажатий в iOS.
UIEvent
Основой обработки взаимодействия пользователя и приложения составляет UIEvent
. В целом на хабре есть материалы неплохо раскрывающие данный аспект (тыц и тыц), кроме того рекомендую посмотреть еще эту стаью. Поэтому эту тему разберм кратко.
Обработка событий генерируемых пользователем происходит следующим образом:
- При нажатии на экран или другой активности пользователя
UIKit
, на основании данных от ОС генерируетUIEvent
и кладет его в очередь событийUIApplication
. События для нашего случая можно разделить на касания экрана и все остальные (пользователь трясет устройство/пользователь нажал аппаратную кнопку/пользователь сгенерил команду от наушников). Далее будем разбирать алгоритм для касания экрана. -
UIApplication
достает событие из очереди и если это касание экрана — отправляет его в текущее окно (UIWindow
) -
UIWindow
с помощью метода hitTest(_:with:) определяет самое верхнееUIView
в пределах которого находиться касание - После чего данная вьюшка выбирается первым обработчиком события в цепочке и у нее вызывается один из четырех методов в зависимости от жизненного цикла касания.
Тут надо сделать небольшое лирическое отступление и сказать что все UIView
равно как и UIWindow
и UIApplication
реализуют интерфейс UIResponder что позволяет им обрабатывать различные UIEvent
— ты.
Так вот в случай касания экрана будет вызван один из четырех методов UIResponder-а:
func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)
func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?)
Далее у первого UIResponder
есть три варианта действия:
1) Вызвать реализацию по-умолчанию и передать событие следующему обработчику в цепочке.
2) Провести обработку события и передать его следующему обработчику в цепочке.
3) Провести обработку события и не передавать его дальше.
Для полноты картины осталось разобраться с цепочкой обработчиков:
1) Как было сказано ранее первый обработчик определяется с помощью метода hitTest(_:with:)
и является самой верхней вьюшкой в пределах которой произошло касание.
2) От первого обработчика событие будет отправлено его родительской UIView
— superview.
3) Шаг 2 повторяется пока очередь не дойдет до ViewController
-а.
4) Дальше перебираются ViewController
-ы пока очередь не дойдет до root-ового контроллера.
5) После него следующим обработчиком становиться UIWindow
.
6) И последним в данной цепочке является AppDelegate
.
7) После этого событие просто удаляется.
UIControl
И так UIEvent
это конечно хорошо и очень интересно однако callback на кнопки обычно вешаются либо через метод addTarget(_:action:for:) либо addAction(_:for:) оба из которых относяться к класу UIControl
и на вход помимо функции принимают событие типа UIControl.Event а не UIEvent
.
addTarget
кроме UIControl.Event
принимает еще собственно сам target
— экземпляр класса реализующего callback который может быть еще и null
и селектор.
На основании полученных данныъ addTarget
создает объект типа UIControlTargetAction
заполняет его поля следующим образом
UIControlTargetAction._target = target
UIControlTargetAction._action = action
-
UIControlTargetAction._eventMask = controlEvents
Затем addTarget
вызывает метод UIControl._addControlTargetAction
который проходит по массиву UIControl._targetActions
и если находит UIControlTargetAction
с такими же полями target
и _action
то добавляет в его поле _eventMask
значения из маски нового UIControlTargetAction
(чтобы один callback мог запускаться разными событиями), а если не находит то добавляет новый UIControlTargetAction
в массив.
addAction(_:for:)
является нововведением начиная с 14 iOS и имеет схожу логику работу с addTarget
за исключением того что вместо полей _target
и _action
у обьекта UIControlTargetAction
заполяет поле _actionHandler
значением типа UIAction.
Так вот от UIKit
у нас приходят UIEvent
-ы, а обработчики мы вешаем уже на UIControl.Event
. Нестыковочка какая-то. Логика конечно подсказывает, что UIControl
скорее всего тоже является UIResponder
-ом и таки предпринимает некоторую обработку UIEvent
-ов. Осталось выяснить какую.
Чтож открываем дизасм UIControl.touchesEnded
. Почему именно ее? Да потому что обычно обработчик кнопки вешается обычно на событие UIControl.Event.touchUpInside
— то есть пользователь поднял палец после нажатия, что хорошо вяжется с обработчиком окончания нажатия.
Как видно из дизасма, функция, после обработки поступивших данных о нажатии вызывает UIControl._sendActionsForEvents(_:UIEvent, withEvent: UIControl.Event)
со следующими возможными UIControl.Event
-ми:
touchDragEnter = 1 << 4 = 16
touchDragExit = 1 << 5 = 32
touchUpInside = 1 << 6 = 64
touchUpOutside = 1 << 7 = 128
_sendActionsForEvents
в свою очередь проходит по массиву сохраненных UIControlTargetAction
и выбирает тот у которого маска совпадает с переданным UIControl.Event
-ом, а дальше логика зависит от того установлен ли _actionHandler
или поле _action
.
_action
В случай если у нас установлен _action
вызывается UIControl.sendAction(_:to:for:) который достает синглтон UIApplication и вызывает документированный UIApplication.sendAction(_:to:from:for:), куда в качестве sender
передает себя.
UIApplication.sendAction
первым делом проверяет не null
ли таргет и если null
запускает UIApplication._targetInChainForAction(_:Selector, sender: Any) -> Any?
которая находит первого (сюрприз) UIResponder
, только алгоритм обнаружения первого — обратный — первым выбирается самый нижний активный слой.
После этого UIApplication.sendAction
вызывает уже задокументированный UIResponder.target(forAction:withSender:) который проходится вверх по цепочке UIResponder
— ов и с помощью метода UIResponder.canPerformAction(_:withSender:) проверяет может ли данный UIResponder
выполнить переданый селектор и если может — возвращает обьект кторый отозвался. Если ни один объект в цепочке обработчиков не может выполнить данный селектор то возвращается null.
После поиска target
UIApplication.sendAction
вне зависимости от результата вызовет либо класический objc_msgSend
или perform(_:with:with:), благо objc_msgSend
успешно игнорирует null
в качестве id
.
И на этом цепочка поиска и вызова callback-а завершается.
UIAction aka _actionHandler
В случай с UIAction
, установленным в поле UIControlTargetAction._actionHandler
, UIControl._sendActionsForEvents
вызывает UIControl.sendAction(_:UIAction)
который в свою очередь вызывает UIAction._performActionWithSender
.
UIAction._performActionWithSender
достает значение лежащее в поле UIAction.handler
которое является класическим блоком. Однако в поле invoke
у нас лежит не указатель на переданное при создании UIAction
замыкание, а указатель на врапер который потом достает указатель на замыкани по оффсету 0x20 и передает управление ему.
Реализация
После такой достаточно большой теоретической части можно перейти к реализации.
На входе имеем случайное приложение и frida подключенную к нему.
Как мы знаем из теоретической части все callback-и хранятся в массиве UIControl._targetActions
. Собственно для начала получим все UIControl
благо для этого есть специальная команда choose
:
uiControls = ObjC.chooseSync(ObjC.classes.UIControl)
Теперь для каждого UIControl
получим массив _targetActions
и выделим основные кейсы которые следует обработать:
var targetActions = uiControl.$ivars._targetActions
// да массив UIControlTargetAction имеет ленивую инициализацию
if (targetActions == null) {
console.log("\tNo callbacks found")
return
}
var count = targetActions.count().valueOf()
for (let i = 0; i !== count; i++) {
var action = targetActions.objectAtIndex_(i)
// Тут мы разбираем три рассмотренных ранее случая
// 1. Когда в качестве обработчика установлен UIAction
if (action.$ivars._actionHandler != null) {
var uiAction = action.$ivars._actionHandler
interceptUIAction(uiAction)
// 2. Когда в качестве обработчика задан селектор и обьект у которого он реализован
} else if (action.$ivars._action != null &&
action.$ivars._action != "0x0" &&
action.$ivars._target != null) {
var actionSelector = action.$ivars._action
var actionTarget = action.$ivars._target
interceptActionWithTarget(actionSelector, actionTarget)
}
// 3. Когда задан только селектор
else if (action.$ivars._action != null &&
action.$ivars._action != "0x0"){
var actionSelector = action.$ivars._action
interceptActionWithoutTarget(actionSelector, uiControl)
}
else {
console.error("Invalid UIControlTargetAction with actionHandler and action seted to null")
continue
}
}
В случай если в качестве callback задан UIAction
я смог достать не так много информации — только дебаг символы если они есть:
function interceptUIAction(uiAction) {
var blockAddr = uiAction.$ivars._handler.handle
// достаем адрес замыкания которое будет вызвано ибо invoke() указывает на хелпер
var closurePtr = blockAddr.add(0x20).readPointer()
// и дебаг символы
var closureName = DebugSymbol.fromAddress(closurePtr).name
// по хорошему можно достать еще и sender - UIControl но вопрос на сколько он интересен?
console.log("\tSet hook on: " + closureName)
Interceptor.attach(closurePtr, {
onEnter: function(a) {
this.log = []
this.log.push("Called " + closureName)
},
onLeave: function(r) {
console.log(this.log.join('\n') + '\n')
}})
}
Второй случай довольно простой так как у нас есть вся нужная информация — просто вешаем хук.
function interceptActionWithTarget(actionSelector, target) {
console.log("\tSet hook on: " + target.$className + "." + actionSelector.readUtf8String() + "()")
var impl = target.methodForSelector_(actionSelector)
Interceptor.attach(impl, {
onEnter: function(a) {
this.log = []
this.log.push("(" + a[0] + ") " + target.$className + "." + actionSelector.readUtf8String() + "()")
},
onLeave: function(r) {
console.log(this.log.join('\n') + '\n')
}})
}
А вот в третьем кейсе заболела frida и метод _targetInChainForAction
почему то отказался находиться в режиме скрипта, при этом в консольном режиме все работало прекрасно. Но никто не мешает нам вызвать метод через objc_msgSend
:
function interceptActionWithoutTarget(actionSelector, uiControl) {
var uiApp = ObjC.classes.UIApplication.sharedApplication()
// создаем функцию из указателя на objc_msgSend
var targetInChainForActionPrototype = new NativeFunction(ObjC.api.objc_msgSend, "pointer", ["pointer","pointer","pointer", "pointer"])
// и вызываем))
var actionTargetPtr = targetInChainForActionPrototype(uiApp, ObjC.selector("_targetInChainForAction:sender:"), actionSelector, uiControl)
var actionTarget = new ObjC.Object(actionTargetPtr)
if (actionTarget != null) {
interceptActionWithTarget(actionSelector, actionTarget)
} else {
console.warn("Can't get target for selector: " + actionSelector.readUtf8String())
}
}
Собственно на этом и все! Готовы скрипт лежит тут. Надеюсь эта статья поможет кому-то в осознании того как работают callback-и графических элементов в iOS.