MacQQ逆向之反撤回实现

前言

作为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
2
3
4
5
6
7
8
9
10
11
12
13
- (void)revokeMessages:(id<NSFastEnumeration>)msgList {
// ...
for (BHMessageModel *msg in msgList) {
MQAIOTopViewController *topVC = [self topMsgListViewController];
MQAIOMessageViewModel *viewModel = [topVC getViewModelByMsgModel:msg];
if (viewModel) {
[viewModel setMsgModel:msg];
[self refreshViewModel:viewModel];
}
}
// ...
}

方法中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
2
3
4
5
6
- (void)my_revokeMessage:(id<NSFastEnumeration>)messages {
id topVC = [self topMsgListViewController];
for (BHMessageModel *msg in messages) {
[topVC appendMessage:msg];
}
}

编写动态库,hook方法-[MQAIOChatViewController revokeMessages:]替换为自己的实现,注入到QQ中,成功实现了阻止消息撤回并保留显示原来那条撤回提示信息。

提示撤回内容

以上实现只能显示XXX 撤回了一条消息,并不能提示消息的内容,撤回的消息跟原来的消息混在一块不好辨别,所以这里还需要实现在撤回提示中显示所撤回的具体消息。
阅读BHMessageModel类的头文件,可以看到很多与“content”相关的文本字符串,其中有一个textContent似乎就是消息的内容。于是,遍历一个聊天界面的viewModelList,打印查看一些消息的textContent属性,猜想得到了印证,但是对于撤回消息的textContent内容却还是“XXX 撤回了一条消息”,查看-[BHMessageModel textContent]的实现,可以看到对于常规信息而言就是返回的实例变量_smallContent,所以后续直接操纵这个实例变量来设置消息内容。

综上,因为UI撤回消息的实现里是一个替换msgModel的过程,所以直接取原来msgModel中的content就好了,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)my_revokeMessage:(id<NSFastEnumeration>)messages {
id topVC = [self topMsgListViewController];
for (BHMessageModel *msg in messages) {
MQAIOMessageViewModel *viewModel = [topVC getViewModelByMsgModel:msg];
if (viewModel) {
NSString *content = [[viewModel msgModel] smallContent];
NSLog(@"--- Revoke content: %@", content);
NSString *revokePrompt = [NSStirng stringWithFormat:@"%@ 撤回了: %@", [msg nickname] , content];
[msg setSmallContent:content];
}
[topVC appendMessage:msg];
}
}

然而,这次并没有那么顺利,界面的提示消息仍然是XXX 撤回了一条消息, 但是Log信息确是被撤回的那条消息的内容,所以对于被撤回的消息,显示时应该并没有用到content属性。

这次直接从显示消息的view来入手,经过一番摸索,找到了显示提示信息时调用的 -[MQAIOTipViewModel outputMessageWithInformativeText:time:chatUIStyle:background:]方法,断点,打印其堆栈信息,从上层调用方法中可以阅读到,传入给该方法显示的text参数是通过-[MQInfoMessageViewModel getInfomativeMsgContent:]方法获取的,于是这里hook一下这个方法的实现:

1
2
3
4
5
6
7
8
- (id)my_getInfomativeMsgContent:(id)arg1 {
int type = [arg1 msgType];
if(type == 0x14c) {
return [arg1 smallContent];
} else {
return [self my_getInfomativeMsgContent:arg1];
}
}

其中msgType为消息类型,0x14c为撤回消息,这是之前遍历viewModelList验证得到的。
编译,再次尝试,这次成功地实现了撤回消息并提示撤回的内容的功能。

保留撤回聊天记录

前面的尝试忽略了一个重点,全都是在UI层面上进行的操作,这就意味着数据库中的聊天记录还是被标记为撤回,退出QQ后,重新加载的聊天记录果然没有了撤回的消息。
于是,初步的想法是截获撤回的Notification,阻止它调用更新数据库的方法。
打印-[MQAIOChatViewController revokeMessages:]调用时的堆栈信息,可以看到如下调用链:

1
2
3
4
5
* thread #1: tid = 0xaa765, 0x000000010a9dfd7c QQ`-[MQAIOChatViewController revokeMessages:], queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
* frame #0: 0x000000010a9dfd7c QQ`-[MQAIOChatViewController revokeMessages:]
frame #1: 0x000000010a9dc6ce QQ`___lldb_unnamed_symbol1762$$QQ + 59
frame #2: 0x000000010aed060a QQ`___lldb_unnamed_symbol17209$$QQ + 75
// ......

该方法上两层的调用者没有符号信息,于是直接取地址,减去QQ的ASLR偏移后可以在Hopper中跳转到,均是在-[BHMessageChatModel revokeMessageModel:]中调用的block,对这个方法加断点,得到的堆栈信息如下:

1
2
3
4
5
6
7
8
9
* thread #3: tid = 0xaa7dd, 0x000000010aecff5a QQ`-[BHMessageChatModel revokeMessageModel:], name = 'msfsafethread', stop reason = breakpoint 1.1
* frame #0: 0x000000010aecff5a QQ`-[BHMessageChatModel revokeMessageModel:]
frame #1: 0x000000010af27911 QQ`-[RecallProcessor solveRecallNotify:isOnline:] + 1451
frame #2: 0x000000010abd4d13 QQ`-[QQMessageRevokeEngine handleRecallNotify:isOnline:] + 99
frame #3: 0x000000010ae857dc QQ`+[RevokePushHandler handleOnlinePushMsgType0x210:DataLen:msgSubType:] + 684
frame #4: 0x000000010afcb7de QQ`-[QQOnlineMsgHandle handle0x210MsgInfo:msgType0x210:msg:] + 1039
frame #5: 0x000000010afcaf53 QQ`-[QQOnlineMsgHandle didReqPushMsg:msg:seqId:requestId:toUin:] + 664
frame #6: 0x000000010afcabe9 QQ`-[QQOnlineMsgHandle didRecievedMsg:] + 464
// ......

看到notify关键字,推测撤回的消息就是在这些上层方法中分发的,于是依次尝试,首先对-[BHMessageChatModel revokeMessageModel:]设置断点,执行thread return直接跳过,重启QQ,撤回的消息记录成功被保留,所以直接阅读这个方法的实现即可找到办法。

方法很长,还包括有punpcklqdq这样奇怪的指令,暂且跳过,注意到一连串的selector被压栈用作后续调用,如下:

1
2
3
4
5
6
7
8
9
10
var_A0 = @selector(unsignedLongLongValue);
var_A8 = @selector(getMessageWithUin:sessType:identityUin:msgSeq:time:random:);
var_C8 = @selector(insertRecallMsg:item:msgType:);
var_C0 = @selector(msgType);
var_D0 = @selector(getRecallMessageContent:);
var_D8 = @selector(setSmallContent:);
var_E0 = @selector(setMsgType:);
var_E8 = @selector(arrayWithObjects:count:);
var_108 = @selector(updateQQMessageModel:keyArray:);
var_B0 = @selector(deleteMessage:containLargeMsg:);

凭这些方法名以及后续出现的MsgDbService看似跟数据库相关的类,可以推测应该是通过@selector(getMessageWithUin:sessType:identityUin:msgSeq:time:random:)拿到了msgModel,处理后保存到数据库。

继续阅读,找到了更改msgModel的关键部分,实现逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
- (void)solveRecallNotify:(struct RecallModel *)arg1 isOnline:(BOOL)arg2 {
// ......
id msgModel = [[MsgDbService sharedInstance]
getMessageWithUin:sessType:identityUin:msgSeq:time:random:]; // 参数未知
if (msgModel && [msgModel msgType] != 0x14c) {
[msgModel setSmallContent: [self getRecallMessageContent:arg1]];
[msgModel setMsgType: 0x14c];
[[MsgDbService sharedInstance] updateQQMessageModel:msgModel keyArray:]; // 参数未知
}
// ......
}

逻辑很明了,根据第一个参数取出msgModel,如果消息类型不是撤回则更改消息的内容和type并更新数据库,为了验证猜想,在上述逻辑中的判断消息类型是否为0x14c的跳转指令处加断点,直接跳到后续指令执行,验证了猜想。

可是,如果直接更改上述指令,把je修改为jmp,这样实现就几乎没有了后续版本的兼容性,每次更新版本都要更改可执行文件,所以还是寻求更干净一些的方法,既然已经知道了更新数据库的方法,那就hook这个方法,先把撤回消息的msgType改为0x14c,再执行后更改为原来的msgType即可。

这样,最棘手的问题便是找到传递给那几个关键方法的参数。
阅读指令,可以看到-[MsgDbService getMessageWithUin:sessType:identityUin:msgSeq:time:random:];的参数基本上是从结构体指针arg1获取的,结合代码并动态调试读取内存,过程比较繁琐,不在此赘述,可以还原出这个结构体需要的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Info {
struct {
int32_t time, msgSeq, random;
} *info;
};

struct RecallModel {
uint64_t unknown1;
uint32_t sesstype, unknown2;
struct Info *addr, *addr2;
uint64_t unknown3, unknown4;
long long uin, groupUin;
// maybe more
};

剩下的就是-[MsgDbService updateQQMessageModel:keyArray:]参数,这就不得不往前看了,从手册中找到punpcklqdq xmm1, xmm2指令的作用是把xmm2寄存器的低64位移到xmm1寄存器的高64位中,方法中是把指向”type”和”content”两个字符串的指针放到一个128位寄存器后入栈,后续取出以后构造一个NSArray作为参数传给前述方法,结合该方法的实现,可以知道这是把传入的第一个msgModel参数中要更新的属性对应数据库表结构的键值传入。

(Orz这一部分各种读指令还包括了中间两个隐藏的过程,简直累趴)

整理前述思路,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
- (void)my_solveRecallNotify:(struct RecallModel *)arg1 isOnline:(BOOL)arg2 {
int original_type = 0x400;
if (arg1->addr != arg1->addr2) {
MsgDbService *db = [objc_getClass("MsgDbService") sharedInstance];
NSString *identityUin = [[objc_getClass("QQDataCenter") GetInstance] uin];
int sesstype = (uint32_t)arg1->sesstype;
id model = [db getMessageWithUin:(sesstype == 0x65 || sesstype == 0xc9) ? arg1->groupUin : arg1->uin
sessType:sesstype
identityUin:[identityUin unsignedLongLongValue]
msgSeq:arg1->addr->info->msgSeq
time:arg1->addr->info->time
random:arg1->addr->info->random];
original_type = [model msgType];
[model setMsgType:0x14c];
NSString *keys[] = {@"type"};
NSArray *keyArray = [NSArray arrayWithObjects:keys count:1];
[db updateQQMessageModel:model keyArray:keyArray];
}
[self my_solveRecallNotify:arg1 isOnline:arg2];

if (arg1->addr != arg1->addr2) {
MsgDbService *db = [objc_getClass("MsgDbService") sharedInstance];
NSString *uin = [[objc_getClass("QQDataCenter") GetInstance] uin];
int sesstype = (uint32_t)arg1->sesstype;
id model = [db getMessageWithUin:(sesstype == 0x65 || sesstype == 0xc9) ? arg1->groupUin : arg1->uin
sessType:sesstype
identityUin:[uin unsignedLongLongValue]
msgSeq:arg1->addr->info->msgSeq
time:arg1->addr->info->time
random:arg1->addr->info->random];
[model setMsgType:original_type];
NSString *keys[] = {@"type"};
NSArray *keyArray = [NSArray arrayWithObjects:keys count:1];
[db updateQQMessageModel:model keyArray:keyArray];
}
}

其他

到这里已经把需要的功能实现了,还需要的细节是撤回显示内容的优化,包括图文混合消息处理、撤回消息类型显示等,这里主要通过解析smallContent中的JSON字符串实现。
还有一个小细节是为了避免撤回已经撤回过的消息,需要做一下判断,查看BHMessageModel的头文件可以看到有一个exInfo属性的类包含有dictionary,可以存放额外信息,由上个部分可以知道要在数据库中保存,需要找到这个属性对应的key,从代码中找到了SQLite查询语句的构造字符串,可以得到keyexInfo
具体的查找和验证过程也不在此做过多说明,随后,只需要在+[RevokeHelper supportRevokeMessage:]做一下判断即可,具体代码如下:

1
2
3
4
5
6
+ (BOOL)my_supportRevokeMessage:(id)arg1 {
if ([[arg1 exInfo] stringValueForKey:kMessageHasRevoked]) {
return NO;
}
return [objc_getClass("RevokeHelper") my_supportRevokeMessage:arg1];
}

总结

编写好代码后,编译成动态库,放到QQ的Bundle下,使用insert_dylib修改可执行文件即可,最终实现效果如图:

后续版本只要撤回逻辑不变,也只需要执行上述步骤即可,最终代码实现详见我的GitHub。