写在 wechat-dump 项目的第十年

在过年的这几天, 为了从焦虑的工作中换一个心情, 我给我的 wechat-dump 项目添加了几个当年没做出来的功能, 解决了一些遗留问题. 意外的发现这个项目始于 2014 年末, 到今天已经超过十年了. 有多少人会有给自己十年前的代码补充新 feature 的经历呢? 突然有了一些感触想要写下来.

简单介绍一下, wechat-dump 项目就是从一个越狱安卓手机上, 把微信聊天记录尽可能完整的结构化导出 - 包括语音 / 图像 / 视频 / 表情包等 (用时髦话说叫多模态?). 主要的技术难点是逆向: 搞清楚如何获取, 解析微信的各种类型的聊天记录.

这个项目本身倒没什么值得骄傲的: 它不是一个多么有用的东西 -- 有这种需求和设备的人本来就不多; 它的代码也没有写的多好 -- 很多地方是 "能用就行" 的, 毕竟也用不了几次; 我并不擅长逆向, 为它花费的精力也远远不如其它我用心做的开源项目. 然而它是唯一一个我开发了超过十年的项目 -- 这里的开发不仅仅指维护, 而是确实有添加新功能, 甚至解决自己曾经没有解决的技术问题.

三件小事

这个项目的最初两年, 主要内容还是趁着有空的时间添加对各类消息的支持, 包括:

  • 解析各种类型的消息 (文字, 链接, 文件, 多媒体, 红包, 名片, 等等..) 并做前端渲染
  • 解码音频 (微信先后使用了 AMR 和 SILK 两种格式), 头像 (存储在一个奇怪的格式里), 图片等.

这里大部分功能的实现比较 straightforward. 解码音频用到了两个第三方音频库; 头像的存储格式我一开始没做, 是由社区用户贡献的. 实现了这些之后, 项目就进入了比较长期的维护状态. 后面十年里只做过两次大的更新, 其中有一些有趣的技术进步.

SQLCipher 参数

微信使用带加密的 sqlite database, 也就是 sqlcipher. 解密的秘钥获取方式早已被破解, 但网上对于解密使用的具体参数一直说法不一. 我在项目中起初使用这样一组参数:

PRAGMA cipher_use_hmac = OFF;
PRAGMA cipher_page_size = 1024;
PRAGMA kdf_iter = 4000;

但几年中时不时看到有人报告说解密不了或者加了其他参数才能成功解密.

2020 年我想解决这个问题, 于是去翻看了 sqlcipher 的源码. 研究一番之后不难发现它处理兼容性的相关原理: 原来 sqlcipher 不同的版本有不同的默认参数 (不止上面的这三个), 微信使用的是最早的 "版本 1". 在有些后续版本里, 更改上面三个参数就能回到版本 1 的行为; 但在有些版本的默认参数下, 更改上面三个参数并不能回到版本 1 的行为.

研究这个问题并没有花多久, 但我觉得从某种程度上体现了解决问题的习惯的变化:

  • 在十年前, 我看到别人在网上给出的这些参数就会去用, 如果遇到问题就换参数试试 -- 这些都是不求甚解的做事方式.

  • 而到了 2020 年, 那时候的我已经习惯遇事不决看源码, 看看这些参数的设置流程到底是怎么回事. 已经习惯了 "自己遇到的问题很可能没人遇到过", 从底层和第一性原理去分析技术问题: 如果解决不了, 就走到更底层. 这种习惯其实也来源于对自己能力的越来越自信: 相信能很快看懂一个陌生的项目, 从源码中找到一个问题最本质的原因, 并且自己找到的答案大概率会比网上的更好

    同样是 2020 年, 我从一个 OpenCV 的崩溃 一路查到了 glibc 里的 bug; 从 PyTorch, TensorFlow 里发现 / 解决了好几个 silent correctness bug. 从那之后, 觉得好像已经没有什么 bug 能难倒自己了.

  • 自己的这种成长, 很大程度上归功于在大厂的 monorepo 里开发项目: monorepo 提供了一个方便的环境, 让工程师可以自由的 navigate 所有的代码并且调试所有的依赖, 非常适合锻炼 "快速调试陌生代码" 的能力. 在 Google 时这种感觉尤为明显, 因为: (1) Google 的工程规范在公司范围内一致性更好, 陌生代码更容易理解. 用我一个 manager 的话说, "Others' code feels like yours"; (2) Google 工程师文化中的领地意识弱一些, 对于陌生人的合理改动是欢迎的. 我估计在自己所有的代码提交里, 有 1/3 的 reviewer 都不是自己项目的人.

这个问题最后的解法很简单, 直接使用新版 sqlcipher 的兼容设置PRAGMA cipher_compatibility = 1; 就能彻底解决问题. 但是, 这里多说一句. 如果 sqlcipher 不同版本有不同的默认行为, 但 sqlcipher 自己没有提供 "兼容旧版" 的功能, 那我会去提出这个 feature request. 因为要认识到, 这个问题在用户侧是几乎不可能本质解决的: 即使用户可以手动设置所有已知参数到旧版的值, 也无法避免未来出现新的参数影响程序行为.

要本质的 (而不是糊弄的) 解决一个技术问题, 往往会牵扯到上下游的依赖: 一个调用没有得到预期的结果, 可能需要上下游共同把这个调用的语义和用法完善. 如果只在一侧做改动, 可能只能临时性的解决问题. 好的工程师应该能够打穿上下游. 习惯于穿梭在上下游的源码中, 就是一个必要条件.

Emoji 解密

2016 年时我发现新版微信将所有存储的 emoji 的前 1KB 做了加密, 就在 README 里写了一个求助性质的 TODO. 一部分 emoji 在数据库中存了下载链接, 可以绕过加密, 但是这不能处理所有情况. 我还设想过在不看前 1KB 的情况下强行解码图片 (因为长宽在数据库中已知), 并且自己确实成功的手动解码了一张图, 但是显然也不是一个本质的方案.

2020 年我逆向了微信这部分逻辑, 得到了解密方式:

  • 先从数据库里用 magic number query 一个特殊的 md5: SELECT md5 FROM EmojiInfo where catalog == 153
  • 虽然一个 md5 的大小是 128 bit, 但是这里有个奇妙 / 奇怪的逻辑: 我们要把它的前一半拿出来, 用 hex 解码再用 ascii 编码, 这样仍然是 128bit.
  • 用这 128bit 的秘钥对文件的前 1KB 做 ECB 解码

这些奇妙的逻辑显然是猜不出来的, 只能靠逆向得到. 在项目最早期, 我看过一次别人反汇编的微信代码就放弃了, 然而这次却能耐心看下去并解决问题. 我仍然是个逆向新手, 这期间没有任何逆向技术的提升, 但是有一些心态上的变化:

  • 开始学习 jadx, ghidra 等逆向工具, 有一种 "即使做不出来也学到了东西" 的感觉, 能推动自己做下去. 虽然现在已经忘了 ghidra 怎么用..
  • 有一个明确的问题要解决, 就可以朝着它逆向, 一步步把相关的变量名, 函数名识别出来. 一直能有 progress 不卡住, 也是一个推动力.
  • 逆向其实很大程度是个模式识别的过程: 要面对丢失了变量名甚至抽象结构的反汇编代码, 识别出它原始的功能. 可以感觉的到, 在自己做了几年工程后, 模式识别的能力是更强的: 即使没有变量名, 看到一些熟悉的结构还是能够猜到原始代码大概是怎么设计的. 当时我想到, 机器学习未来可能会很适合做逆向.

WXGF 解码

2020 年时, 完成了解密之后还发现, 有部分 emoji 的格式并不是标准图片, 而是微信私有的 "WXGF" 格式, 体现为解密后文件前四个字节是wxgf. 当时的我解决不了这个问题, 一个图片格式 (ARM 汇编) 的逆向超出了我这个新手的能力, 于是留了一个报错. 项目又停滞了几年后, 到了 2025 年我发现微信连聊天中的图片也用 WXGF 格式存储了, 因此解决它的优先级更高了.

由于这部分工作刚刚完成, 所以记录一下新鲜的体验:

  • 通过 Java 部分的逆向, 很容易找到关键函数com.tencent.mm.plugin.gif.MMWXGFJNI.nativeWxam2PicBuf, 将 WXGF 格式转为普通图片格式.
  • 我从来没有接触过 JNI, 于是把反汇编的接口文件送给 ChatGPT, 学习了一下 JNI 相关知识. 对话链接.
    • ChatGPT 告诉了我符号的名字; 我据此找到了正确的 .so 文件: libwechatcommon.so.
    • ChatGPT 让我意识到想从 C++ 调用解码函数的风险不小, 因为并不确定这个函数是否依赖 Java 环境.
  • .so 中拿出了对应函数的汇编, 让 ChatGPT 逆向了一下. 对话链接.
    • 逆向结果表明解码核心逻辑在别的函数里, 并且这个函数地址是从一个 global pointer 拿的. 这说明有另外的初始化代码来设置这些 global pointer. 这一点很重要.
    • 从这里仍然不好判断是否可以不依赖 Java. 因此我判断还是写一个 android app 调用这个 JNI 成功率最高. 于是我开始写人生中第一个 android app.
  • 学习了 android studio, 创建了一个基本 app 模板. 全过程主要依赖 android studio 内的 Gemini 来做各种无聊的 setup, 测试图片读取, 和 UI 工作. Gemini 虽然有帮助, 但是还是会犯不少错误, 而且和 IDE 整合几乎没有, 回答问题似乎也只看得见单文件的 context.
  • 试图System.LoadLibrary(libwechatcommon.so) 并且把依赖的其它 so 文件都加入了 app, 在最后阶段报错:
    Native registration unable to find class 'com.tencent.mm.plugin.fts.jni.FTSJNIUtils'
    看来是这个 so 里还会反向依赖 Java 代码. 和 ChatGPT 学习了一下, 得到了使用一个 stub file 的方案. 最后这样确实解决了问题.
  • 成功 load so 之后, 给 app 添加了测试用的 WXGF 图片, 尝试调用nativeWxam2PicBuf 函数, 发现返回 null. 回想前面 ChatGPT 对汇编的分析, 很可能是缺少了必要的初始化.
    • Java 反汇编出的这一段代码看似会比较重要: 很可能关键解码函数的地址在这个libvoipCodec.so 中, 需要初始化. 但是我调用nativeInit("path/to/libvoipCodec.so") 之后, 仍然无法解码图片.
    • 发现nativeInit 返回 -1. 直接把这个函数的汇编丢给 ChatGPT, 让它分析行为, 告诉我为什么会返回-1. 它一开始说因为输入是无效字符串, 但这并不是我的情况, 再问一次之后给出了正确答案: dlopen 失败, 可能是依赖其他.so.
    • 通过System.LoadLibrary(libvoipCodec.so) 的报错发现它确实依赖其他.so. 依赖添加完整之后nativeInit 成功, 解码函数能返回一串数据了.
    • 解码的数据打在屏幕上我第一时间也不知道是不是垃圾, 于是问 ChatGPT 它直接告诉我是 GIF header. 说明解码成功了.
  • 最后添加 UI 和网络交互, 把它变成一个可以从电脑上调用的 android service. 一开始想开一个 http server, 但是 Gemini 写不出能编译通过的代码. 让它改用 websocket 之后能跑了. 这个 android app 最后的代码在这里.

可以看到, LLM 还是发挥了非常大的作用. 尽管我从来没写过 android app, 在 Gemini 的帮助下可以快速把一些低智能的 boilerplate 填上, 得到能跑的 app. ChatGPT 的逆向和分析更是节省了我大量的时间: 如果没有 ChatGPT 我不会有时间和耐心去自己解决这些问题.

感想

说来惭愧, WXGF 解码是我第一次在实际项目中, 感受到 LLM 对我的工作效率的成倍数的提升:

  • 日常工作中, 简单的 boilerplate 代码我也会让 LLM 写. 但是由于它们还是在我熟悉的环境和语言下, LLM 对我的效率提升有限.
  • 我参与的复杂技术问题, 大部分是系统设计 / 美学问题, 跨系统的整体优化问题等, LLM 还不能提供很好的帮助. 而且解决这些问题的目的之一就是减少 boilerplate 代码.

而这一次使用 LLM, 冗长且我不熟悉的 Java boilerplate 可以迅速完成; 复杂的汇编分析, 跨系统调用, LLM 也能在思考后给出有价值的答案 -- 这种对底层逻辑的执着和灵光一闪, 本来是工程师特有的浪漫, 但 LLM 似乎将会比人类更擅长反汇编.

在这个十年的项目里, 我感受到了自己的技术进步, 掌握了更多的工具和工程思想并为此得意的时候, AI 一眨眼便已经从旁边追上. 它进步的速度无人可及, 而我们帮助它进步的更快, 似乎是当下最值得做的事情.

熟悉我工作的朋友可能知道, 我十分看重系统的设计和美学, 执着于用正确的抽象和精确的语义, 在系统中最合适的位置解决问题, 就像上文提到的 sqlcipher 兼容性的设计智慧一样. 然而有人说, 系统设计的差一点, 让用户用起来难受一点, 写写丑陋的 boilerplate 没关系, 因为 LLM 来写就好了. 这句话肯定有部分是对的, 但我对此还不能十分认可: 一个设计丑陋繁琐的系统, LLM 理解起来也会更困难, 更容易写错. 提供来自更抽象的层次的视角, 这可能是当 AI 取代了简单的编程任务之后我还能做的吧.

可是, 当代码的艺术成为被批量复制的积木, 美学的价值是会被一同复制, 还是沦为数据中的统计噪声? 下一代的工程师, 他们将如何架构一个工程? 对设计的坚持, 是否像是手工艺人面对工业流水线时最后的抗争?

在 AGI 来临前, 我们能不能把人类最闪耀的思想留在它们身上?

人类会在下一个十年里给出答案.

Comments