0x01 起因

最近因为某些原因,公司准备把用了好多年的 Slack 换成企业微信。这其实是件挺正常的事情,公司在不停地发展,什么样的变化都有可能会发生。说不定公司在做大到一定程度以后,做自己的 IM 也不一定。

但如果之前没有用过 Slack 还好,但在用过几年之后,就不免会把它与企业微信之间做一些对比。

我喜欢 Slack 的 Message and file threadsEmoji reactions 还有它便捷的 API,这几点企业微信都做的不太好,甚至没有。这还挺让人失望的。

而且它的水印真的很魔性,在聊天界面很显眼。所以我决定用我仅有的一点点逆向工程破解知识,来把它的水印从聊天界面中去除。

0x02 工具准备

  • Hopper Disassembler demo version 的就可以,我们并不需要用到它的付费版功能
  • insert_dylib 用于修改二进制,加入一个 LC_LOAD_DYLIBload command,让最终的二进制在运行的时候,加载我们自己写的 framework 中的代码
  • Xcode,创建 framework,编写代码
  • 企业微信,2.6.1 版本

0x03 寻找入口

我觉得客户端中的逆向工程或者说“破解”,最重要的是找到对应的方法,或者说入口,找到了以后,离成功也就只有一步之遥了。

在安装完企业微信之后,把 /Applications/企业微信.app/Contents/MacOS/企业微信 这个二进制文件拖进 Hopper 里,待它分析完毕后,开始搜索与水印相关的方法。

如果开发人员比较正常的话,那 99% 的概率搜索 waterMark 就可以找到我们想要的方法(那 1% 可能需要搜索 shuiyin 🙃),

There you go,有大量跟水印相关的方法。下面几个方法看起来都是通过返回的 BOOL 值来决定聊天界面是否需要显示水印,

  • -[WEWConversation isConversationSupportWaterMark]
  • +[WEWConversation isWaterMarkSupportConversation:]
  • -[WEWConversationService isOpenConversationWaterMark] (企业微信管理员的设置里应该有一键关闭水印之类的设置 🤔)

逆向破解的过程中实现同一个需求往往有多种途径,发散思维,路不只一条

这里我们选择第一个方法,通过 Hopper 查看 Pseudo-code 可以得知,不同的 conversation 有不同的水印设置,比如文件传输助手还有一些机器人的聊天界面就没有水印,如果在这个方法里返回 NO,那所有的聊天界面就都应该没有水印了,

我们先在 lldb 中验证一下猜想,

# 创建企业微信的调试 targer,准备调试,这一步并不会启动企业微信
lldb -w /Applications/企业微信.app/Contents/MacOS/企业微信

进入 lldb 的调试模式,试试在这个方法下一个断点,结果 lldb 抱怨说找不到对应的方法,

(lldb) b -[WEWConversation isConversationSupportWaterMark]
Breakpoint 1: no locations (pending).
WARNING: Unable to resolve breakpoint to any actual locations.

难道是我下断点的方式不对?断 -[NSObject init] 试一下,

(lldb) b -[NSObject init]
Breakpoint 2: where = libobjc.A.dylib`-[NSObject init], address = 0x000000000000b702

根据 log 得知,是能断成功的,这说明下断点的姿势没问题,出问题的可能是 lldb,也有可能是企业微信对最终的二进制做了手脚(这就触及我的知识盲区了,希望能有懂行的人出现给我解解惑 🤩

所以我们只能通过断函数地址的方法来进行调试了,这需要先让程序启动起来。在此之前,把上面下的断点都删掉,

(lldb) breakpoint delete
About to delete all breakpoints, do you want to do that?: [Y/n] Y
All breakpoints removed. (2 breakpoints)

先启动应用程序,待应用运行起来后,在 lldb 调试界面,按 CTRL + C 让应用暂停下来,

(lldb) run
...
CTRL + C

通过 Hopper/class-dump/MachOView 可知,-[WEWConversation isConversationSupportWaterMark] 方法的地址是 0x00000001017fb7e6,所以我们在这个地址上下断点即可断到这个函数,

(lldb) b 0x00000001017fb7e6
Breakpoint 3: where = 企业微信`___lldb_unnamed_symbol145659$$企业微信, address = 0x00000001017fb7e6

⚠️ 在应用启动之前是不可以通过地址下断点的,因为在应用程序启动后,所有的地址会产生一个随机的 offset,在这之后 lldb 才能确定这个 offset 是多少,从而在正确的地址 break

接着我们让应用程序继续运行,

(lldb) c

选中一个会出现水印的对话,这时应用程序会停下,看 lldb 的调试界面可以发现,应用成功断在某个方法上了,

* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1
frame #0: 0x00000001017fb7e6 企业微信` ___lldb_unnamed_symbol145659$$企业微信
企业微信`___lldb_unnamed_symbol145659$$企业微信:
-> 0x1017fb7e6 <+0>: push rbp
0x1017fb7e7 <+1>: mov rbp, rsp
0x1017fb7ea <+4>: push r15
0x1017fb7ec <+6>: push r14
0x1017fb7ee <+8>: push rbx
0x1017fb7ef <+9>: push rax
0x1017fb7f0 <+10>: mov r15, rdi
0x1017fb7f3 <+13>: mov r14, qword ptr [rip + 0x16364a6] ; "conversationType"
Target 0: (企业微信) stopped.

由于每一个 Objective-C 的方法调用都会变为一个 C 的方法调用 objc_msgSend(obj, SEL, p1, ...) 所以通过打印 rdi 还有 rsi 寄存器中的值就可以确定我们断的方法是不是我们想要的,

x86_64 中的寄存器与函数参数的对应关系如下表,

arg1 arg2 arg3 arg4 arg5 arg6 argx
RDI RSI RDX RCX R8 R9 R10+
(lldb) po $rdi
<WEWConversation: 0x11f195a40>
(lldb) po (char *)$rsi
"isConversationSupportWaterMark"

没错,就是 -[WEWConversation isConversationSupportWaterMark]

现在我们让这个函数直接返回 NO,然后继续让程序运行,

(lldb) thread return 0
(lldb) c

现在再跳回企业微信,发现之前会出现水印的对话没有水印了,这说明我们的猜想是正确的 🥰

0x04 制做 Framework

虽然可以通过 lldb 调试的方法去掉水印,可是这样太麻烦了,不可能以后每次都这么搞吧?如果能让应用程序每一次启动的时候,都执行我们加入的代码就好了。这一步可以通过 insert_dylib 工具实现。它可以修改二进制文件,在其 dyld load commands 列表的最后加入我们需要 load 的 dylib/Framework 的 command。

使用 Xcode 创建一个名为 WEWTweak 的 Cocoa Framework。

因为需要使用 Method Swizzling 换掉上面函数的实现,但还懒得裸写这部分逻辑 ,那就使用 Pod 引入 JRSwizzle 吧 😌(注意 Podfile 中不要加 use_framework!)。

加了 use_framework! 以后,每一个 pod target 都是一个 framework,这样需要处理很多的 framework,比较麻烦,我们希望所有 pod target 最终的符号都链接到 WEWTweak.framework 中,一个 framework 解决问题

然后在 framework 中创建一个 NSObject 的 category (WEWTweak),加入以下 ugly 的代码,

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <JRSwizzle/JRSwizzle.h>

@implementation NSObject (WEWTweak)

+ (void)load {
[objc_getClass("WEWConversation")
jr_swizzleMethod:NSSelectorFromString(@"isConversationSupportWaterMark")
withMethod:@selector(wew_isConversationSupportWaterMark)
error:nil];
}

- (BOOL)wew_isConversationSupportWaterMark {
return NO;
}

@end

上面的方法交换的代码写在 load__attribute__((constructor)) 都可以

__attribute__((constructor)) 是一个 clang 的 attribute,被标记的 C 方法会在 +(void)loadmain 函数之间被执行。

编译项目,把产物 WEWTweak.framework 还有 insert_dylib 都拖到企业微信二进制的同级目录,然后进行下面的操作,

$ cd /Applications/企业微信.app/Contents/MacOS
$ ls
WEWTweak.framework insert_dylib 企业微信
# 给 insert_dylib 加一下可执行的权限
$ chmod +x insert_dylib
# 备份一下二进制文件
$ cp 企业微信 企业微信.bak
# 把制作好的 framework 通过 insert_dylib 植入二进制文件中
$ ./insert_dylib /Applications/企业微信.app/Contents/MacOS/WEWTweak.framework/WEWTweak 企业微信 企业微信 --all-yes

企业微信 already exists. Overwrite it? [y/n] y
LC_CODE_SIGNATURE load command found. Remove it? [y/n] y
Added LC_LOAD_DYLIB to 企业微信

植入成功,启动下企业微信试试,

签名校验失败,interesting……

之前我也曾逆向/破解过一些程序,校验二进制信息的还是头一回遇到。Good Job 企业微信团队!

点击 OK,发现程序退出了。这说明程序中有 exit 调用,这是线索啊同学们,线索就是断点啊!

退出之前的 lldb session,再开一个,

$ lldb -w 企业微信

# 在 exit 上下断点
(lldb) b exit
Breakpoint 1: 3 locations.
# 启动程序
(lldb) run

再次点击 OK 按钮,lldb 暂停了程序,通过 bt 打印调用栈,

(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.3
* frame #0: 0x00007fff5d4781a7 libsystem_c.dylib` exit
frame #1: 0x00007fff2d9498bb AppKit` -[NSApplication terminate:] + 1755
frame #2: 0x000000010055a82f 企业微信` ___lldb_unnamed_symbol29576$$企业微信 + 150
frame #3: 0x00000001004eb8ce 企业微信` ___lldb_unnamed_symbol26847$$企业微信 + 1035
frame #4: 0x000000010054f77b 企业微信` ___lldb_unnamed_symbol29391$$企业微信 + 54

通过调用栈中的函数地址信息配合 Hooper 的 Navigate -> Go to Address or Symbol,可以确认调用栈长这样,

#0: -[NSApplication terminate:]
#1: +[WEWApplicationUtils terminate]
#2: -[WEWApplicationLifeCricleObserver applicationDidFinishLaunching]
#3: -[WEWApplicationDelegateWindowController applicationDidFinishLaunching:]

#2 中的方法最有问题,通过 Hooper 的 Pseudo-code 对该方法的分析如下,

void -[WEWApplicationLifeCricleObserver applicationDidFinishLaunching](void * self, void * _cmd) {
// ...
rbx = [[NSBundle mainBundle] retain];
r14 = [[rbx executablePath] retain];
[rbx release];
r15 = [NSTemporaryDirectory() retain];
rbx = [[r15 stringByAppendingPathComponent:^ { /* block implemented at sub_1004ebb58 */ }] retain];
[r15 release];
rax = objc_retainAutorelease(r14);
var_50 = rax;
r15 = [rax UTF8String];
rax = objc_retainAutorelease(rbx);
var_48 = rax;
rbx = sub_100b16d72(r15, [rax UTF8String], @"tmp_exe");
r14 = sub_100b170c4();
// some check...
return;
}

大意就是,通过 [NSBundle mainBundle] executablePath] 来获取企业微信的二进制路径,然后把它复制一份到一个临时目录中,命名为 tmp_exe 然后对此文件进行一些 check 来确认这份二进制是否被修改过。

解决这个问题的方法还是有很多种,可以选择 hook 下面那些 check 的 C 方法,或者让这个方法运行一半就返回。我选择的方式是修改 [NSBundle mainBundle] executablePath] 方法的返回值,反正我们都要备份最终的二进制,那干脆让这个方法指向备份的文件好了 😌

修改 framework 中的分类文件,如下所示,

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <JRSwizzle/JRSwizzle.h>

@implementation NSObject (WEWTweak)

+ (void)load {
[objc_getClass("WEWConversation")
jr_swizzleMethod:NSSelectorFromString(@"isConversationSupportWaterMark")
withMethod:@selector(wew_isConversationSupportWaterMark)
error:nil];

[NSBundle jr_swizzleMethod:@selector(executablePath)
withMethod:@selector(wew_executablePath)
error:nil];
}

- (NSString *)wew_executablePath {
return @"/Applications/企业微信.app/Contents/MacOS/企业微信.bak";
}

- (BOOL)wew_isConversationSupportWaterMark {
return NO;
}

@end

再编译一次程序,把 WEWTweak.framework 拖入 /Applications/企业微信.app/Contents/MacOS/ 覆盖,然后再执行一次下面的代码,

$ ./insert_dylib /Applications/企业微信.app/Contents/MacOS/WEWTweak.framework/WEWTweak 企业微信 企业微信 --all-yes

启动企业微信,水印不见了 🎉

0x05 结尾

感谢 Sunnyyoung/WeChatTweak-macOS 项目,我在其中抄了好几段代码 🌸

本文的完整代码见这里 X140Yu/WEWTweak,如果本文或者这个库对你有帮助,欢迎 star 哦 🌟

如果你发现文章中有任何写得不对的地方,或者有任何想法,都欢迎在评论区里跟我交流 🙆‍♂️