前言
作为Mac用户,每当水群的时候免疫别人撤回消息时总是感觉非常美滋滋,然而QQ For Mac 5.5.0版本更新之后支持撤回了,对于爱水群的我可以说是非常不爽了!于是自己动手,通过逆向QQ找到撤回消息的逻辑,搞定之。
寻找UI撤回方法
初步思路是从UI入手,找到撤回消息时,界面部分用于撤回一条消息并显示提示信息被调用的方法。
尝试搜索关键词“revoke”,果然看到了很多相关的方法:
上次逆向找QQ发送消息的方法时,知道了聊天界面的ViewController类是MQAIOChatViewController
,revoke相关方法中该类仅有一个 -[MQAIOChatViewController revokeMessages:]
,故猜测这个就是UI用于撤回消息的方法,用lldb附加到QQ上,给这个方法打上断点,尝试撤回一条消息,断点命中
在调试器中尝试执行thread return
并继续程序流程,消息撤回成功地被block了,看起来,只需要编写动态库hook这个方法,直接return,反撤回就这么实现了,跟更新之前一样。
撤回提醒
如果只是这样,也太简单了点,既然QQ已经能处理消息撤回了,要是能既能block掉撤回,又能在被撤回的消息旁边显示一个标记,好知道这条消息被撤回了就好了,看起来也不是很麻烦,既然UI处理撤回都是发生在-[MQAIOChatViewController revokeMessages:]
方法里,那就先阅读以下这个方法的实现。
阅读代码并动态调试打印对象的类名,可以分析得到该方法实现的主要部分大致如下:
1 | - (void)revokeMessages:(id<NSFastEnumeration>)msgList { |
方法中topVC的类名是MQAIOTopViewController
, 打印它的view的视图层级, 通过各种show/hide view, 可以得到显示聊天记录的view是TXTopTextView
,阅读其头文件可以发现它是一个NSTextView
,并非我一开始以为的NSTableView
(后来发现这个跟UIKit
上的UITableView
差别好像还是挺大的),这样看来要额外添加view来标记有些麻烦,所以转而寻找如何防止撤回的同时还能显示撤回原来的提示的方法。
通过阅读MQAIOTopViewController
的头文件(使用class-dump导出),可以找到一个方法- (void)appendMessage:(id)arg1;
,猜想可以通过它来添加那条提示,但是不知道这个方法需要的参数是什么,所以同样需要阅读这个方法的代码。
阅读代码可以得到方法实现的逻辑是通过参数(BHMessageModel对象)构造MQAIOMessageViewModel
(实际上是它的子类)对象(包括另一个显示时间的对象,如果需要显示), 通过调用- [MQAIOTopViewController viewModelList]
将构造的对象加入其中,最后刷新textView。
结合以上两个方法的实现,可以整理出如下信息:MQAIOTopViewController
即为聊天界面中显示记录的VC,界面中每一行(除了头像),包括聊天记录、时间、提示信息等都对应一个MQAIOMessageViewModel
对象,它含有一个数据model,即BHMessageModel
对象,消息的撤回实际上是把对应ViewModel的数据model修改为已经被标记为一条撤回消息的msgModel,然后刷新显示。
于是,根据整理出来的信息,可以编写如下代码:
1 | - (void)my_revokeMessage:(id<NSFastEnumeration>)messages { |
编写动态库,hook方法-[MQAIOChatViewController revokeMessages:]
替换为自己的实现,注入到QQ中,成功实现了阻止消息撤回并保留显示原来那条撤回提示信息。
提示撤回内容
以上实现只能显示XXX 撤回了一条消息
,并不能提示消息的内容,撤回的消息跟原来的消息混在一块不好辨别,所以这里还需要实现在撤回提示中显示所撤回的具体消息。
阅读BHMessageModel
类的头文件,可以看到很多与“content”相关的文本字符串,其中有一个textContent
似乎就是消息的内容。于是,遍历一个聊天界面的viewModelList,打印查看一些消息的textContent属性,猜想得到了印证,但是对于撤回消息的textContent内容却还是“XXX 撤回了一条消息”,查看-[BHMessageModel textContent]
的实现,可以看到对于常规信息而言就是返回的实例变量_smallContent
,所以后续直接操纵这个实例变量来设置消息内容。
综上,因为UI撤回消息的实现里是一个替换msgModel的过程,所以直接取原来msgModel中的content就好了,实现如下:
1 | - (void)my_revokeMessage:(id<NSFastEnumeration>)messages { |
然而,这次并没有那么顺利,界面的提示消息仍然是XXX 撤回了一条消息
, 但是Log信息确是被撤回的那条消息的内容,所以对于被撤回的消息,显示时应该并没有用到content属性。
这次直接从显示消息的view来入手,经过一番摸索,找到了显示提示信息时调用的 -[MQAIOTipViewModel outputMessageWithInformativeText:time:chatUIStyle:background:]
方法,断点,打印其堆栈信息,从上层调用方法中可以阅读到,传入给该方法显示的text参数是通过-[MQInfoMessageViewModel getInfomativeMsgContent:]
方法获取的,于是这里hook一下这个方法的实现:
1 | - (id)my_getInfomativeMsgContent:(id)arg1 { |
其中msgType为消息类型,0x14c为撤回消息,这是之前遍历viewModelList验证得到的。
编译,再次尝试,这次成功地实现了撤回消息并提示撤回的内容的功能。
保留撤回聊天记录
前面的尝试忽略了一个重点,全都是在UI层面上进行的操作,这就意味着数据库中的聊天记录还是被标记为撤回,退出QQ后,重新加载的聊天记录果然没有了撤回的消息。
于是,初步的想法是截获撤回的Notification,阻止它调用更新数据库的方法。
打印-[MQAIOChatViewController revokeMessages:]
调用时的堆栈信息,可以看到如下调用链:
1 | * thread #1: tid = 0xaa765, 0x000000010a9dfd7c QQ`-[MQAIOChatViewController revokeMessages:], queue = 'com.apple.main-thread', stop reason = breakpoint 2.1 |
该方法上两层的调用者没有符号信息,于是直接取地址,减去QQ的ASLR偏移后可以在Hopper中跳转到,均是在-[BHMessageChatModel revokeMessageModel:]
中调用的block,对这个方法加断点,得到的堆栈信息如下:
1 | * thread #3: tid = 0xaa7dd, 0x000000010aecff5a QQ`-[BHMessageChatModel revokeMessageModel:], name = 'msfsafethread', stop reason = breakpoint 1.1 |
看到notify关键字,推测撤回的消息就是在这些上层方法中分发的,于是依次尝试,首先对-[BHMessageChatModel revokeMessageModel:]
设置断点,执行thread return
直接跳过,重启QQ,撤回的消息记录成功被保留,所以直接阅读这个方法的实现即可找到办法。
方法很长,还包括有punpcklqdq
这样奇怪的指令,暂且跳过,注意到一连串的selector被压栈用作后续调用,如下:
1 | var_A0 = @selector(unsignedLongLongValue); |
凭这些方法名以及后续出现的MsgDbService
看似跟数据库相关的类,可以推测应该是通过@selector(getMessageWithUin:sessType:identityUin:msgSeq:time:random:)
拿到了msgModel,处理后保存到数据库。
继续阅读,找到了更改msgModel的关键部分,实现逻辑如下:
1 | - (void)solveRecallNotify:(struct RecallModel *)arg1 isOnline:(BOOL)arg2 { |
逻辑很明了,根据第一个参数取出msgModel,如果消息类型不是撤回则更改消息的内容和type并更新数据库,为了验证猜想,在上述逻辑中的判断消息类型是否为0x14c的跳转指令处加断点,直接跳到后续指令执行,验证了猜想。
可是,如果直接更改上述指令,把je修改为jmp,这样实现就几乎没有了后续版本的兼容性,每次更新版本都要更改可执行文件,所以还是寻求更干净一些的方法,既然已经知道了更新数据库的方法,那就hook这个方法,先把撤回消息的msgType改为0x14c,再执行后更改为原来的msgType即可。
这样,最棘手的问题便是找到传递给那几个关键方法的参数。
阅读指令,可以看到-[MsgDbService getMessageWithUin:sessType:identityUin:msgSeq:time:random:];
的参数基本上是从结构体指针arg1获取的,结合代码并动态调试读取内存,过程比较繁琐,不在此赘述,可以还原出这个结构体需要的部分:
1 | struct Info { |
剩下的就是-[MsgDbService updateQQMessageModel:keyArray:]
参数,这就不得不往前看了,从手册中找到punpcklqdq xmm1, xmm2
指令的作用是把xmm2寄存器的低64位移到xmm1寄存器的高64位中,方法中是把指向”type”和”content”两个字符串的指针放到一个128位寄存器后入栈,后续取出以后构造一个NSArray作为参数传给前述方法,结合该方法的实现,可以知道这是把传入的第一个msgModel参数中要更新的属性对应数据库表结构的键值传入。
(Orz这一部分各种读指令还包括了中间两个隐藏的过程,简直累趴)
整理前述思路,代码实现如下:
1 | - (void)my_solveRecallNotify:(struct RecallModel *)arg1 isOnline:(BOOL)arg2 { |
其他
到这里已经把需要的功能实现了,还需要的细节是撤回显示内容的优化,包括图文混合消息处理、撤回消息类型显示等,这里主要通过解析smallContent中的JSON字符串实现。
还有一个小细节是为了避免撤回已经撤回过的消息,需要做一下判断,查看BHMessageModel
的头文件可以看到有一个exInfo属性的类包含有dictionary,可以存放额外信息,由上个部分可以知道要在数据库中保存,需要找到这个属性对应的key,从代码中找到了SQLite查询语句的构造字符串,可以得到keyexInfo
。
具体的查找和验证过程也不在此做过多说明,随后,只需要在+[RevokeHelper supportRevokeMessage:]
做一下判断即可,具体代码如下:
1 | + (BOOL)my_supportRevokeMessage:(id)arg1 { |
总结
编写好代码后,编译成动态库,放到QQ的Bundle下,使用insert_dylib修改可执行文件即可,最终实现效果如图:
后续版本只要撤回逻辑不变,也只需要执行上述步骤即可,最终代码实现详见我的GitHub。