本文面向已经会使用 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。
当前样本中几个容易混淆的地址如下:
| 地址 | 脚本相对偏移 | 函数 | 判断 |
|---|---:|---|---|
| 0x1020A7BF8 | 0x20A7BF8 | GAPSessionPacket::SerializeToBuffer | 网络帧封装 |
| 0x1020A7DB8 | 0x20A7DB8 | GAPSessionPacket::ParsePacketHeader | 网络帧头解析 |
| 0x1020B35F4 | 0x20B35F4 | WeWorkProtocolTask::PackRequest | CGI 外层请求封装 |

上图是示意图。真实分析时,重点是确认 InternalSerialize / Parse 和 PackRequest / HandleResponse 的层次关系。
二、第一步:先搜索 protobuf 类型名
从 socket、send 或 recv 往回猜,通常会撞上很多通用代码,最后很难判断某个 buffer 属于哪个接口。更稳的做法是:先找消息类型,再从类型找到 vtable。
可以从这些地方找类型名:
类型名函数一般很简单:
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,再检查其中的关键槽位。示意结构如下:
| vtable 偏移 | 作用 | 识别依据 |
|---:|---|---|
| +0x48 | type-name | 返回消息类型名 |
| +0x58 | InternalSerialize 候选 | 进入 writer/helper |
| +0x90 | Parse 候选 | 按 wire tag 分支处理字段 |
| +0x98 | ByteSize 候选 | 读取字段长度并累加 |
这组偏移只代表当前分析目标的候选映射,不是所有 generated-lite 版本的固定规律。最终必须用 vtable 内容和反编译结果确认。
怎么确认是 Parse?
可靠的 Parse 候选一般有这样的结构:
tag = reader.ReadTag();
switch (tag >> 3) {
case 1:
case 2:
default:
}
重点看这些特征:
怎么确认是 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
-> 业务回调
当前脚本里适合做接口关联的点包括:
| 函数 | 相对主模块偏移 | 用途 |
|---|---:|---|
| WeWorkProtocolTask::PackRequest | 0x20B35F4 | 请求外层打包前后确认 |
| WeWorkProtocolTask::HandleResponse | 0x20B40C8 | 响应解包和业务回调前确认 |
| HttpCgiTaskDispatcher::ParseResponse | 0x1CC41A4 | HTTP 响应进入接口解析流程前确认 |
字段恢复仍然应该以 protobuf 的 Parse 分支和 Serialize writer 为准。不要因为 HTTP body 中偶然出现了几个类似长度的字节,就直接猜字段含义。

九、如何确认不是误 Hook?
一个比较可靠的闭环至少包含四类证据:
类型证据:消息对象的 vtable 对应目标消息类型
函数证据:Parse 有 wire-tag 分支,Serialize 有 writer 调用
数据证据:Serialize 输出能按 protobuf wire format 解码
链路证据:请求/响应时序能和接口外层 Hook 对上
下面这些现象单独出现时,都不能证明已经定位到了 protobuf:
真正有说服力的证据,是“消息对象 + 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 和接口时序串起来。
[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于 2小时前
被xiaoxinre编辑
,原因: