文章总结: 本文详细介绍了MacOS企业微信5.0.7中定位protobuf序列化与反序列化Hook点的逆向分析方法。核心在于区分protobuf消息层、网络帧层与CGI外层,精准HookInternalSerialize和Parse函数。作者提供了从搜索类型名、XREF找vtable、反编译确认读写逻辑到Frida抓取数据的完整流程。建议实操时先验证消息对象与vtable,结合外层封装关联具体接口,并通过类型、函数、数据、链路四类证据闭环验证防误Hook,版本升级时需重新走查地址。 综合评分: 92 文章分类: 逆向分析,实战经验,移动安全
MacOS企业微信5.0.7逆向分析:如何定位protobuf序列化与反序列化Hook点
xiaoxinre xiaoxinre
看雪学苑
2026年7月27日 17:59 上海
在小说阅读器读本章
去阅读
本文面向已经会使用 IDA 和 Frida,但对 generated-lite / protobuf Hook 还不太熟悉的读者。
本文只讨论消息的 protobuf 序列化、反序列化以及 Hook 点定位,不展开登录协议、加密算法、业务字段含义、接口重放或具体业务模块分析。请只在自己的程序、账号和已获授权的环境中测试。
先说结论
如果只记住一件事,可以记下面两条:
请求:业务对象 -> InternalSerialize -> protobuf bytes
响应:protobuf bytes -> Parse / MergeFrom -> 业务对象
PackRequest、HandleResponse 和 GAPSessionPacket 是外层上下文或网络封装;它们很有用,但不能自动等同于 protobuf 序列化函数。
想看“字段怎样编码”,重点看 InternalSerialize 和 Parse;想知道“这段消息属于哪个接口”,再把它们和 PackRequest、HandleResponse 按时间顺序关联起来。
一、先把几层“封包/解析”分清楚
逆向网络代码时,最容易踩的坑就是看到函数名里有 Serialize、Parse 或 MakeBody,就直接认为它是 protobuf。实际至少要区分下面几层。
1. protobuf message 层
InternalSerialize:业务对象 -> protobuf bytes
Parse / MergeFrom:protobuf bytes -> 业务对象
ByteSize: 计算 protobuf bytes 的长度
这一层处理的是真正的 protobuf message。只要反编译里出现读写 field tag、wire type、varint、fixed32/fixed64 或 length-delimited 数据,才有理由把它判断为 protobuf 逻辑。
2. 网络帧层
例如 GAPSessionPacket:
SerializeToBuffer:把 GAP 头和 body 组装成网络帧
ParsePacketHeader:读取网络帧头,确认 header/body 边界
它处理的是版本、命令号、序号、body 长度和校验值,不等于 protobuf message 的字段序列化。
3. CGI / 接口外层
例如 WeWorkProtocolTask:
PackRequest: 请求 body/header 的外层封装,可能包含压缩和 Base64
HandleResponse:响应外层解包,再进入后续业务回调
它适合确认请求或响应的接口归属,但不能替代 Parse / InternalSerialize。
当前样本中几个容易混淆的地址如下:
上图是示意图。真实分析时,重点是确认
InternalSerialize/Parse和PackRequest/HandleResponse的层次关系。
#
二、第一步:先搜索 protobuf 类型名
从 socket、send 或 recv 往回猜,通常会撞上很多通用代码,最后很难判断某个 buffer 属于哪个接口。更稳的做法是:先找消息类型,再从类型找到 vtable。
可以从这些地方找类型名:
- 日志里的类名或
className; - 业务调用点里的请求/响应类型;
- IDA 字符串窗口中的
xxxReq、xxxRsp、Request、Response; - 类型名函数返回的完整名称。
类型名函数一般很简单:
std::string *__usercall MessageTypeName@<X0>(std::string *out@<X8>)
{
return SomeStringHelper(out, "pb.InterfaceRequest");
}
这个函数本身不是序列化入口,它只是告诉我们“这个消息的类型名是什么”。对它做 XREF,继续找所在的 vtable 或类型元数据。
#
三、第二步:从 vtable 找四个关键函数
C++ 对象通常在首地址保存 vtable 指针:
message_object + 0x00 -> vtable
不同 protobuf runtime、编译器和版本的槽位可能变化,所以不要只记固定偏移。先读 vtable,再反编译候选函数确认。
找到消息对象后,先记录目标 vtable,再检查其中的关键槽位。
示意结构如下:
这组偏移只代表当前分析目标的候选映射,不是所有 generated-lite 版本的固定规律。最终必须用 vtable 内容和反编译结果确认。
怎么确认是 Parse?
可靠的 Parse 候选一般有这样的结构:
tag = reader.ReadTag();
switch (tag >> 3) {
case 1:
// field 1
case 2:
// field 2
default:
// skip unknown field
}
重点看这些特征:
- 读取 tag 或 field number;
- 根据 wire type 分支;
- 调用 varint、fixed32、fixed64 或 length-delimited reader;
- 把结果写回
this + offset; - 未知字段走 skip 或保存逻辑;
- 嵌套 message 继续调用自己的 Parse。
怎么确认是 InternalSerialize?
序列化函数通常会出现:
WriteTag(writer, field_number, wire_type);
WriteValue(writer, value);
常见表现包括:向输出指针写入 field tag,写入 varint 或 fixed-width 数值,写入 length-delimited 数据的长度和内容,或者调用嵌套消息的序列化函数。
有些 InternalSerialize 只是很短的 wrapper,真正的字段写入会继续进入公共实现或 writer helper。
因此可以同时保留两个观察点:
- Hook vtable 中的
InternalSerialize,抓消息对象和输出对象; - 跟进 writer helper,观察每个 field 的 tag、长度和数据来源。
ByteSize 有什么用?
它通常不是最终抓包点,但可以帮助确认 vtable 槽位、预测输出长度、判断哪些字段参与序列化,并和实际 dump 长度交叉验证。
上面的 IDA 示意图同时覆盖了类型名、vtable 槽位以及 Parse / InternalSerialize 的判断要点。
#
四、第三步:用 Frida 先确认消息对象
第一次 Hook 不要立刻把所有参数当 buffer。先记录对象地址、vtable、参数指针和回溯,确认类型后再读数据。
function ptrText(p) {
return p && !p.isNull() ? p.toString() : '0x0';
}
function inspectMessage(msgPtr) {
if (!msgPtr || msgPtr.isNull()) {
return { messagePtr: '0x0', vtable: '0x0' };
}
let vtable = NULL;
try {
vtable = msgPtr.readPointer();
} catch (_) {}
return {
messagePtr: ptrText(msgPtr),
vtable: ptrText(vtable),
};
}
一个比较可靠的判断链路是:
args[0] 可以读取 vtable
↓
vtable 对应目标消息类型
↓
调用者回溯属于目标接口
↓
调用时机发生在请求序列化或响应解析阶段
如果 args[0] 实际是 writer、stream 或字符串对象,就回到 IDA 重新确认函数原型和 ARM64 寄存器传参,不要强行假设第一个参数一定是 this.
五、序列化 Hook:抓完整 protobuf bytes
hook.js 的 processManualBuffers() 里有 protobuf-serializer 处理分支,基本流程是:
enter:保存消息对象和输出对象
↓
读取 vtable,记录候选槽位
↓
leave:读取输出对象的指针和长度
↓
通过 network-dump 保存完整二进制 bytes
进入函数时可以先记录:
const msgPtr = args[0];
const outObjectPtr = args[1];
send({
type: 'network-log',
event: 'protobuf-serialize-enter',
message: inspectMessage(msgPtr),
outObject: ptrText(outObjectPtr),
});
protobuf 是二进制数据,不能用 readUtf8String()。应读取“数据指针 + 实际长度”:
const info = readWeChatStringLike(outObjectPtr, PROTOBUF_DUMP_LIMIT);
if (info.buffer && info.length > 0) {
send({
type: 'network-dump',
direction: 'protobuf-serialize',
length: info.length,
truncated: !!info.truncated,
}, info.buffer);
}
如果输出对象是 std::string,要区分 libc++ 的短字符串和长字符串布局。读取错误时,常见表现就是 dump 为空、长度异常,或者始终只有前几个字节。
六、反序列化 Hook:抓输入 protobuf bytes
反序列化优先 Hook 已确认的 Parse / MergeFrom,而不是直接 Hook recv。因为 recv 处可能还是 TLS、HTTP、长连接、压缩数据或包含多个接口包的外层 buffer。
可以先用一个只观察调用关系的 Hook:
const parseAddress = CORE_MODULE.base.add(PARSE_OFFSET);
Interceptor.attach(parseAddress, {
onEnter(args) {
this.message = args[0];
this.reader = args[1];
send({
type: 'protobuf-parse-enter',
message: inspectMessage(this.message),
reader: ptrText(this.reader),
arg2: ptrText(args[2]),
});
},
onLeave(retval) {
send({
type: 'protobuf-parse-leave',
message: inspectMessage(this.message),
retval: retval.toString(),
});
},
});
args[1] 不能预先假设是裸 buffer:
- 如果是
CodedInputStream或 reader 对象,读取它的内部 buffer、当前位置和剩余长度; - 如果是
(data, len),再按数据指针和长度读取; - 如果是封装字符串,使用
readWeChatStringLike()或readCppStringLike(); - 如果是嵌套 reader,继续跟进构造位置。
Parse 的 leave 事件只能说明解析结束。想抓原始输入,通常在 Parse 入口读取 reader 当前区间,或者在 HandleResponse / ParseResponse 附近复制响应 body。
七、如何把 protobuf Hook 和具体接口对应起来?
单独看到一次 Parse 或 InternalSerialize 命中,还不一定知道它属于哪个接口。可以把事件按时间顺序串起来:
请求构造
-> protobuf InternalSerialize
-> PackRequest
-> 网络发送
网络接收
-> HandleResponse / ParseResponse
-> protobuf Parse
-> 业务回调
当前脚本里适合做接口关联的点包括:
字段恢复仍然应该以 protobuf 的 Parse 分支和 Serialize writer 为准。不要因为 HTTP body 中偶然出现了几个类似长度的字节,就直接猜字段含义。
九、如何确认不是误 Hook?
一个比较可靠的闭环至少包含四类证据:
类型证据:消息对象的 vtable 对应目标消息类型
函数证据:Parse 有 wire-tag 分支,Serialize 有 writer 调用
数据证据:Serialize 输出能按 protobuf wire format 解码
链路证据:请求/响应时序能和接口外层 Hook 对上
下面这些现象单独出现时,都不能证明已经定位到了 protobuf:
- 函数名包含
serialize或parse; - 网络数据里存在一个看起来像长度的字段;
- socket 层抓到了一段二进制;
GAPSessionPacket里出现了 body;- 外层接口 body 可以 dump。
真正有说服力的证据,是“消息对象 + vtable + wire tag/writer + 完整 bytes”能够相互对应。
#
十、版本升级时怎么复用?
从 5.0.7 升级到其他版本时,不要直接复制旧地址。建议重新走一遍:
- 搜索接口消息类型字符串;
- 找到 type-name 函数;
- 通过 XREF 找 vtable;
- 反编译确认
Parse、ByteSize、InternalSerialize; - 对比调用者、寄存器和参数布局;
- 再把地址写入 Frida Hook 配置;
- 用运行时日志、完整 bytes 和接口时序做闭环验证。
#
十一、速查表
#
先搜类型名,确认消息是谁;
再沿 XREF 找 vtable;
从 vtable 找 Parse / ByteSize / InternalSerialize;
反编译确认 wire tag、reader 和 writer;
Frida 先看对象、vtable、回溯,再读 buffer;
PackRequest / HandleResponse 用来关联接口;
最后用完整 bytes 和请求/响应时序做交叉验证。
结语
这件事可以用一个很直白的思路来做:先搞清楚“消息对象是谁”,再确认“它的 vtable 里哪个函数负责读、哪个函数负责写”,最后用 Frida 把对象、输入/输出 bytes 和接口时序串起来。
#
看雪ID:xiaoxinre
https://bbs.kanxue.com/user-home-1014395.htm
*本文为看雪论坛优秀文章,由 xiaoxinre 原创,转载请注明来自看雪社区
第十届安全开发者峰会【议题征集】-欢迎投稿
往期推荐
从2026CCB决赛堆题CreditMarket学习Glibc2.39后ptmalloc的机制更新
2019-SUCTF-SUDriver 学习笔记
深度理解 VEH-CheatEngine vs VEH-PAGE_GUARD
CVE-2022-0847 Dirty Pipe 漏洞原理深度分析
如何避免窗口被游戏反作弊误杀以及替代方案
球分享
球点赞
球在看
点击阅读原文查看更多
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:看雪学苑 xiaoxinre xiaoxinre《MacOS企业微信5.0.7逆向分析:如何定位protobuf序列化与反序列化Hook点》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论