手把手教你给企业微信 Mac 客户端去除水印

0x01 起因

最近因为某些原因,公司准备把用了好多年的 Slack 换成企业微信。这其实是件挺正常的事情,公司在不停地发展,什么样的变化都有可能会发生。说不定公司在做大到一定程度以后,做自己的 IM 也不一定。
但如果之前没有用过 Slack 还好,但在用过几年之后,就不免会把它与企业微信之间做一些对比。
我喜欢 Slack 的 Message and file threadsEmoji reactions 还有它便捷的 API,这几点企业微信都做的不太好,甚至没有。这还挺让人失望的。
而且它的水印真的很魔性,在聊天界面很显眼。所以我决定用我仅有的一点点逆向工程破解知识,来把它的水印从聊天界面中去除。

0x02 工具准备

  • insert_dylib 用于修改二进制,加入一个 LC_LOAD_DYLIBload command,让最终的二进制在运行的时候,加载我们自己写的 framework 中的代码
  • Xcode,创建 framework,编写代码
  • 企业微信,2.6.1 版本

0x03 寻找入口

我觉得客户端中的逆向工程或者说“破解”,最重要的是找到对应的方法,或者说入口,找到了以后,离成功也就只有一步之遥了。
在安装完企业微信之后,把 /Applications/企业微信.app/Contents/MacOS/企业微信 这个二进制文件拖进 Hopper 里,待它分析完毕后,开始搜索与水印相关的方法。
如果开发人员比较正常的话,那 99% 的概率搜索 waterMark 就可以找到我们想要的方法(那 1% 可能需要搜索 shuiyin 🙃),
notion image
There you go,有大量跟水印相关的方法。下面几个方法看起来都是通过返回的 BOOL 值来决定聊天界面是否需要显示水印,
  • [WEWConversation isConversationSupportWaterMark]
  • +[WEWConversation isWaterMarkSupportConversation:]
  • [WEWConversationService isOpenConversationWaterMark] (企业微信管理员的设置里应该有一键关闭水印之类的设置 🤔)
在逆向破解的过程中实现同一个需求往往有多种途径,发散思维,路不只一条
这里我们选择第一个方法,通过 Hopper 查看 Pseudo-code 可以得知,不同的 conversation 有不同的水印设置,比如文件传输助手还有一些机器人的聊天界面就没有水印,如果在这个方法里返回 NO,那所有的聊天界面就都应该没有水印了,
notion image
我们先在 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 中的寄存器与函数参数的对应关系如下表,arg1arg2arg3arg4arg5arg6argxRDIRSIRDXRCXR8R9R10+
(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)load 和 main 函数之间被执行。
编译项目,把产物 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 企业微信
植入成功,启动下企业微信试试,
notion image
签名校验失败,interesting......
notion image
之前我也曾逆向/破解过一些程序,校验二进制信息的还是头一回遇到。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 哦 🌟
如果你发现文章中有任何写得不对的地方,或者有任何想法,都欢迎在评论区里跟我交流 🙆‍♂️

© Xinyu 2014 - 2024