流光容易把人抛,红了樱桃,绿了芭蕉
一个月前的某一天,百无聊赖的我在整理房间的时候,偶然翻开了一本积灰的“小黄书”,看到首页作者的赠语,眼前不禁浮现两年前,几经波折辗转到上海的我兴致勃勃勃勃地拜托小锅让他的直属老大狗神 (本书作者之一) ,给刚买的这本《iOS应用逆向工程》签名的场景,百味杂陈。说来惭愧,我一直都没有好好地看过这本书,等回过头来,不知不觉已经过去两年了。这篇文章致那个曾经那个能够静心看书的少年。
灵感
钉钉是一款针对企业日常工作交流的 App ,因为是针对企业协作,所以不同于一般的 IM 软件,比如微信,钉钉增加了对消息读取状态的监控。简单来说就是同事发送一条消息后,如果我打开 App ,进入聊天界面查看了这条消息,那么该同事就会在这条消息旁边看到“已读”字样,如下图:
这个已读功能在工作的时候还是挺有作用的,能够知道同事是否已经知晓的相关事务。不过总会存在某些时候,我们不希望让对方知道消息已经被读取了。这就需要对 App 进行一些处理了。
准备工作
因为逆向基本工具在《iOS应用逆向工程》中罗列地非常清楚,所以对于一些基本环境的配置,这里就略过了,只简单介绍下这次逆向需要的工具。
工具 | 本次逆向作用 |
---|---|
dumpdecrypted Clutch |
对 App 进行砸壳,使之可进行反汇编及 dump |
class-dump | 获取 App 的 class 信息 ( Xcode 打开方便查看) |
Hopper Disassembler | 反汇编器,查看 App 的汇编代码 |
usbmuxd | 映射使用 USB 连接的逆向设备端口到本地 |
OpenSSH | 让越狱设备上具备 ssh 服务 |
cycript | 在目标 App 进程中测试函数,也可用来定位感兴趣对象 |
debugserver | 调试服务器,可让 lldb 连接 iOS 进行远程调试 |
lldb chisel |
lldb 调试器,不多说 FB 出品的 lldb 调试插件,不仅在 Xcode 正向开发中很有用,逆向也酸爽至极 |
theos | 编写Tweak,可对逆向代码进行编译打包,并以 dylib 的形式安装到越狱设备中 |
接下来记录下整个逆向工作流程。
对 App 进行砸壳
本次砸壳采用的工具是书中演示的 dumpdecrypted。
1、首先通过 ssh 连接到 iOS 设备
1 | ➜ dumpdecrypted git:(master) ✗ iproxy 2223 22 & |
2、使用 ps 和 grep 找出目标 App 可执行文件路径
1 | iPhone:~ root |
这里由于先前并不知道钉钉的可执行文件名,所以直接用 Application 关键字对所有进程进行过滤,知道后就可以直接用 DingTalk 进行过滤了 。包含钉钉可执行文件路径的一行为
1 | 4147 ?? 1:04.34 /var/containers/Bundle/Application/60B29D5D-2360-4C01-A49F-8CA87C752EFE/DingTalk.app/DingTalk |
3、使用 cycript 获取钉钉 Document 文件夹路径
1 | iPhone:~ root |
使用 cycript -p + pid / 可执行文件名
就可以在目标 App 的进程下运行方法了。在这一步顺便把钉钉的 Bundle ID 给打印出来,后面 thoes 会用到
1 | cy# [[NSBundle mainBundle] bundleIdentifier] |
4、将 dumpdecrypted.dylib 拷贝到 Documents 目录下
1 | ➜ dumpdecrypted git:(master) ✗ scp -P 2223 dumpdecrypted.dylib root@localhost:/var/mobile/Containers/Data/Application/93BEEF52-2DE2-4687-9986-E50CBD961BB2/Documents |
5、开始砸壳
1 | iPhone:~ root |
砸壳后,当前目录下会有一个 DingTalk.decrypted 文件,将这个砸壳之后的文件拷贝到电脑上进行反汇编及 class-dump 。
进行 class-dump 及反汇编
1、进入 DingTalk.decrypted 所在目录,执行 class-dump
1 | ➜ DingTalk class-dump -S -s -H -o DTClassDump DingTalk.decrypted |
可以看到目录下多了 DTClassDump 文件夹,打开这个文件夹,全选里面的头文件,用 Xcode 打开。
2、打开 Hopper Disassembler 并将 DingTalk.decrypted 拖进面板中,Hopper Disassembler 会自动识别 DingTalk.decrypted 对应的 CPU 体系结构。确定进行后续操作后,Hopper Disassembler就开始进行反汇编了。反汇编的时间可能会有点长,所以最好把反汇编之后的 hop 文件保存一下,这样下次就可以直接打开了(不过即使这样打开也要挺久的,8G 内存也有点吃紧,老是转菊花 (°:з」∠) )。
逆向过程
定位消息控制器
如果是从视图切入的话,使用 FLEXLoader ( Cydia 商店可下载 ) 是个不错的选择,它可以很方便地调试当前界面上的元素。当然,Revel 这种利器就不用多介绍了,用起来也是很舒畅。不过这里我使用了另外两种方式:
1、首先是使用 cycript。进入钉钉的聊天界面后,执行下面代码:
1 | cy |
由于大体知道钉钉界面的层级关系,使用代码获取当前界面的信息还是比较容易的。
2、第二种是使用 lldb 来打印控制器层级列表。
这里涉及到 lldb 的远程调试,首先映射越狱设备 1234 端口到 Mac 本地 1234 端口:
1 | ➜ DTClassDump iproxy 1234 1234 & |
然后在越狱设备上开启 debugserver:
1 | iPhone:~ root |
接着执行一下命令,让 lldb 连接上 debugserver:
1 | ➜ DTClassDump lldb |
最后我们就可以进行调试了。注:下面的 lldb 操作会用到 chisel 插件的一些命令。
首先,导入 chisel 部分命令需要的 UIKit 框架:
1 | (lldb) process interrupt |
接着还是进入到聊天界面:
1 | (lldb) pvc |
可以看到 chisel 不仅把目标控制器 <DTMessageOTOViewController 0x13c830400>
打印出来了,还顺带把当前整个控制器层级都给拉了出来。
定位接受消息方法
将消息标为已读的前提是钉钉接收到了该消息,进而可以推测是不是在接收消息后,钉钉发送了已读标志,所以我们先找出接收消息的回调方法。在不知道确切方法的情况下,浏览下下 class-dump 出来的 DTMessageOTOViewController
类信息多少会有收获的:
1 | // |
结合方法命名,我只关注了上方的三个方法。接下来可以逐个测试上面的方法,找出处理消息的回调了。
在 Hopper Disassembler 中, DTMessageOTOViewController
的 handleNotificationMessage:
的方法如下:
1 | ; ================ B E G I N N I N G O F P R O C E D U R E ================ |
如上所示,我们可以知道 handleNotificationMessage:
的相对地址是 0x0000000100466820,打开 lldb 进行调试:
1、获取 DingTalk 的 ASLR (地址空间配置随机加载) 偏移量
1 | (lldb) image list -o -f | grep DingTalk |
2、设置 handleNotificationMessage:
断点信息。不过在设置前,得先明确下参数和返回值的传递规则。
参数传递规则:前四个参数存放在 R0 - R3 中,剩余的通过栈进行传递。
返回值传递规则:通过 R0 传递给调用者。
众所周知,Objective-C 的方法调用是通过 objc_msgSend
函数实现的,objc_msgSend
定义如下:
1 | id objc_msgSend(id self, SEL _cmd,...); |
所以我们可以通过 R0 / arg0 获取消息接受者, R1 / arg1 获取发送的方法名。综上,可添加断点信息如下:
1 | (lldb) br set -a 0x0000000000004000+0x0000000100466820 |
3、向越狱设备的钉钉发送消息,触发断点
1 | (lldb) po $x0 |
可以看到接收消息后,的确进入了 handleNotificationMessage:
方法。并且通过 LR (保存函数返回地址寄存器),我们可以定位到是 receivedMessageListNotification:
方法调用了 handleNotificationMessage:
:
1 | (lldb) p/x $lr - 0x0000000000004000 |
Hopper Disassembler :
1 | ; ================ B E G I N N I N G O F P R O C E D U R E ================ |
进而,我们可以知道新消息到来时,钉钉会广播 DTPushReceivedMessageListNotification
通知:
1 | NSConcreteNotification 0x170854430 {name = DTPushReceivedMessageListNotification; object = <DTReconnectedHandler: 0x170015990>; userInfo = { |
4、从反汇编代码中寻找线索
虽然定位到了 handleNotificationMessage:
方法,但最终发现这个方法并没有给我想要的信息,不过它的调用者 receivedMessageListNotification:
方法却提供了一些有用的线索:
1 | ; ================ B E G I N N I N G O F P R O C E D U R E ================ |
DTMessageOTOViewController
对象的 receivedMessageListNotification:
方法有效信息并不多,但它在调用 handleNotificationMessage:
方法前,调用了父类的 receivedMessageListNotification:
方法:
1 | ; ================ B E G I N N I N G O F P R O C E D U R E ================ |
通过反汇编代码可以看出,这个 dataSource
先后调用了 noRepeatSortMessagesWithNotificationMessageList:
和 dealMessageListWithNoRepeatSortArray:finishBlock:
方法,会不会在后一个方法发送已读标志呢?在确认之前,我们先看下 dataSource
这个方法:
1 | @property(retain, nonatomic) DTMessageControllerDataSource *dataSource; // @synthesize dataSource=_dataSource; |
数据源被剥离到一个独立的对象了,这种做法在界面比较复杂的情况中很常见,能有效减少控制器中的代码。那么 DTMessageControllerDataSource
里面会有什么有用的信息么?
定位发送已读标志方法
上文说到了 DTMessageControllerDataSource 这个类,这个类定义如下:
1 | @interface DTMessageControllerDataSource : NSObject <UIViewControllerPreviewingDelegate, DTMessageCollectionViewCellDataSource, EGORefreshTableHeaderDelegate, DTMessageConllectionViewDataSource, DTMessageControllerDataSourceProtocal> |
结合命名方法,我注意到了上面两个方法。和上文步骤一样,我先使用 lldb 调试了 needSendReadStatusInCellForRowWithMessage:
方法:
1 | ; ================ B E G I N N I N G O F P R O C E D U R E ================ |
不过这次因为要改变返回值,我直接把断点打在了 0x00000001001a070c
这个地方:
1 | Process 4961 stopped |
改写 R0 (返回值) 为 0 并让进程继续运行后,消息发送方的已读标志果然没有出现,始终是处于未读状态,而接收方也看到了这条消息。那么 needSendUnreadStatusWithMessage:
方法是如何判断该返回YES或NO呢?除去了部分对结果不产生影响的分支后,其汇编代码如下:
1 | 00000001001a05c8 stp x22, x21, [sp, #-0x30]! ; Objective C Implementation defined at 0x103088d78 (instance method), DATA XREF=0x103088d78 |
整理一下,可以用以下 Objective-C 代码进行表示:
1 | - (BOOL)needSendUnreadStatusWithMessage:(DTBizMessage *)message { |
既然知道了这块的代码逻辑,改变其行为也就不在话下了。
编写安装 Tweak
1、创建一个 Tweak 工程模版:
1 | ➜ DingTalk nic.pl |
2、编写 Tweak.m 和 Makefile 文件了:
Tweak.m:
1 | %hook DTMessageControllerDataSource |
Makefile:
1 | ARCHS = arm64 |
这里的 needSendReadStatusInCellForRowWithMessage:
直接返回 NO ,因为即使不执行原来的方法,也不会影响 App 的正常运行。
3、将 Tweak 编译打包安装到越狱设备上:
1 |
|
小结
第一次逆向实践,虽说编写的 Tweak 代码才几行,但其定位过程却是一波三折,可能因为经验不足导致定位不准确吧,不过逆向需要很好的耐心倒不假。因为以前做过即时通讯,所以对钉钉消息收发流程多少还是会有点自己的理解,这无形中也推进了我逆向的进度。
最后,这次逆向让我时隔三年之后,再一次有机会利用终端调试程序,还记得以前是做 Linux 应用程序时在嵌入式设备中使用 gdb 进行调试。决定以后在 Xcode 中也要多用命令行进行调试了,实在是舒畅。
参考
iOS应用逆向工程
iOS 逆向实战 - 钉钉签到远程“打卡”
ARM 64 常用汇编指令
The LLDB Debugger Tutorial