逆向一个每次升级都会漂移的「发送路由」攻克

一次 macOS 桌面 IM 客户端的跨版本(minor 升级)函数重定位记录 + 可复用方法论 · 不含具体地址坐标,只讲原理与方法
hijack 原理示意
原理一图:客户端把收件人存在一块对象内存里。我们在它被读取之前把里面的名字涂改成别人 —— 下游照着改后的名字投递,消息就去了别处。整套手法只动这一块字符串内存。

1. 为什么这次难(背景)

这个客户端平时的「热更新」只搬函数位置、函数体本身不变,所以靠「函数大小指纹 / 按位移整体重定位」就能把上一个版本的锚点搬到新版本。但这次是小版本升级(如 4.1.9 → 4.1.10),它把发送路径上的关键函数函数体重写了:

锚点整体重定位结果
网络序列化入口(@提醒注入用的那个点)✓ 干净搬过去了(函数体没变)
路由读取函数(决定发给谁)✗ 0 匹配(函数体被重写)
发送槽函数✗ 0 匹配(函数体被重写)

所以路由点必须动态重新找。这正是上一个版本曾经花了 9 天、试了十来个锚点才解决的同级难度。

2. 这次有效的方法(可复用的核心)

两路并行、交叉验证:静态调用点拓扑差分(找函数)+动态全线程栈回溯(确认在发送路径上)+哨兵断点(确认这次发送确实落在采样窗口内)+内存改写(实测改道做终判)。

2.1 调用点拓扑差分 —— 跨版本认出同一个函数

核心思想:函数体虽然被重写,但它调用了哪些子函数、每个调用点落在函数内的相对位置,跨版本高度保守。于是把上一个(已解)版本里那个路由函数的「调用点位置序列」做成指纹,拿到新版本里逐个函数比对。

参照 = 上一版本里已确认的路由读取函数
目标 = 新版本的整块代码
比对维度 = 每个子调用 (bl) 在函数内的相对偏移序列
命中 = 某个新函数的几十个调用点,位置与参照逐一对齐

→ 这比「按函数大小/位置整体重定位」强:对「函数体被重写、但调用结构保留」的小版本升级仍然有效。这一步可以丢给一个独立的静态分析 agent 批量跑。

跨版本调用点拓扑差分
差分一图:左右是同一个函数在新旧两版里的样子。内部花花绿绿填充完全不同(函数体被重写,所以按大小整体重定位全失败),但两侧边缘的凸起(每个子调用的位置)高度一一对齐。靠这套「调用点拓扑指纹」就能在新版里认出同一个函数。

2.2 动态全线程栈回溯 —— 抓住「主线程正在发消息」的那一刻

在那个必定会触发的网络序列化点下断点,命中时打印所有线程的调用栈。一个意外但关键的发现:序列化发生的那一刻,主线程仍然同步停在发送调用链里(说明这条发送是同步的,决定收件人的那一步还在栈上)。于是主线程那一截栈帧,就是新版本路由函数的候选集合,正好和上一步静态差分的候选交叉印证。

全线程栈回溯抓主线程
抓栈一图:那摞歪歪的彩色盒子就是调用栈(一层调一层)。在「必定会触发」的序列化点停下来一看,主线程这小人还卡在发送链中间没走完(发送是同步的,决定收件人那一步还在栈上)。这摞盒子里的每一层,都是要排查的路由候选;右边那个小人是另一条线程在干自己的活。

2.3 哨兵断点 —— 区分「候选没触发」和「这次发送没落进窗口」

验证候选断点时,把那个「必定触发」的序列化点也一起挂上当哨兵。一条消息发出来:哨兵必响(证明这次发送确实落在我们布点的窗口内)。这样当候选断点没响时,就能确定是候选选错了,而不是时序上错过了这次发送。本次正是靠它确认了「函数入口触发了,但工具给的那条具体读取指令处在一条没走到的分支上」。

2.4 内存改写 —— 终判(改内存,不改寄存器)

真正的 hijack 写的是对象内存,不是某个寄存器。所以只要在某一刻拿到了收件人对象,改它内存就改了所有下游读取,跟那条读取指令具体在函数哪一行无关。终判实验这么做:

在发送分发函数入口下断点 → 命中(主线程,那块内存里确实是 "filehelper")
  → 把它原地覆写成同样长度的【无效收件人】 "zzznoroute"
  → 客户端发送一条内容为 "routetest1" 的消息到文件助手
结果:文件助手里【没有】 routetest1(同期其它消息都正常入库 = 读取没延迟、库没坏)
      → 收件人被改道了 = 这就是真正的路由 hijack 点 ✓

安全做法:改道目标用一个不存在的收件人,不给任何真实对象发消息;同长度覆写,省去处理字符串长度字段。

内存改写改道终判
终判一图:给文件助手发信,但在入口处把信封上的收件人 "filehelper" 划掉、改写成无效的 "BLORPFAX"。结果信飞过了 inbox 邮箱(没进去)掉进角落 —— 对应实测:那条消息没出现在文件助手里(同期其它消息都正常入库)。证明改这块内存真的改了投递目标。

3. 关键结果

结论怎么确认的
路由函数发送分发函数(主线程同步路径上)静态调用点差分高置信命中 + 动态实测函数入口在发送时触发
收件人字段会话对象里的一块内存(位置随版本漂移)函数入口处该内存实测就是当前收件人
函数性质一个发送分发 / 迭代器内部按条件分流,文件助手走的是其中一条分支,真正读收件人在它的子函数里 —— 但读的是同一块对象内存
终判改道成功入口改内存 → 消息离开文件助手

4. 工程化集成

集成方式与上一个版本完全同一套回调,只是把「断点位置」和「收件人字段在对象内的偏移」换成本版本的值(这些具体数值留在内部)。要点:

5. 工具形态(都已沉淀成脚本)

脚本作用
全线程栈回溯在序列化点打印所有线程栈,抓主线程发送链
候选验证(带哨兵)入口扫寄存器找收件人 + 读取点无过滤 dump + 哨兵确认发送落窗口
改道终判入口覆写收件人内存 → 实测改道
pty 包裹 lldb让后台 attach 的 lldb 有真正的伪终端,不被 EOF 提前断开

6. 下次小版本升级的重定位 runbook

  1. 先按位移整体重定位试一把。 序列化类锚点一般能搬;路由/发送类若 0 匹配 → 是小版本升级,进下面流程。
  2. side-by-side clone 起来 + 小号登录(不影响主账号;调试只发文件助手)。
  3. 调用点拓扑差分找函数:用上一已解版本的路由函数,让静态分析 agent 在新版本找调用点拓扑 twin。
  4. 动态全线程栈回溯交叉:序列化点打印全部线程栈,抓主线程发送链,与静态候选取交集。
  5. 带哨兵验证候选:候选断点 + 哨兵,确认候选在发送时触发,并定位收件人字段在对象内的偏移(留意它会随版本漂移)。
  6. 内存改写终判:把收件人字段覆写成无效串,确认文件助手收不到 = 改道成功。
  7. 集成 + 正向复验 + 发布。