MacOS企业微信5.0.7逆向分析:如何定位protobuf序列化与反序列化Hook点

admin 2026-08-04 08:42:34 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细介绍了MacOS企业微信5.0.7中定位protobuf序列化与反序列化Hook点的逆向分析方法。核心在于区分protobuf消息层、网络帧层与CGI外层,精准HookInternalSerialize和Parse函数。作者提供了从搜索类型名、XREF找vtable、反编译确认读写逻辑到Frida抓取数据的完整流程。建议实操时先验证消息对象与vtable,结合外层封装关联具体接口,并通过类型、函数、数据、链路四类证据闭环验证防误Hook,版本升级时需重新走查地址。 综合评分: 92 文章分类: 逆向分析,实战经验,移动安全


cover_image

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 -> 业务对象

PackRequestHandleResponse 和 GAPSessionPacket 是外层上下文或网络封装;它们很有用,但不能自动等同于 protobuf 序列化函数。

想看“字段怎样编码”,重点看 InternalSerialize 和 Parse;想知道“这段消息属于哪个接口”,再把它们和 PackRequestHandleResponse 按时间顺序关联起来。

一、先把几层“封包/解析”分清楚

逆向网络代码时,最容易踩的坑就是看到函数名里有 SerializeParse 或 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 类型名

从 socketsend 或 recv 往回猜,通常会撞上很多通用代码,最后很难判断某个 buffer 属于哪个接口。更稳的做法是:先找消息类型,再从类型找到 vtable。

可以从这些地方找类型名:

  • 日志里的类名或 className
  • 业务调用点里的请求/响应类型;
  • IDA 字符串窗口中的 xxxReqxxxRspRequestResponse
  • 类型名函数返回的完整名称。

类型名函数一般很简单:

std::string *__usercall&nbsp;MessageTypeName@<X0>(std::string *out@<X8>)

{

return&nbsp;SomeStringHelper(out,&nbsp;"pb.InterfaceRequest");

}

这个函数本身不是序列化入口,它只是告诉我们“这个消息的类型名是什么”。对它做 XREF,继续找所在的 vtable 或类型元数据。

#

三、第二步:从 vtable 找四个关键函数

C++ 对象通常在首地址保存 vtable 指针:

message_object +&nbsp;0x00&nbsp;->&nbsp;vtable

不同 protobuf runtime、编译器和版本的槽位可能变化,所以不要只记固定偏移。先读 vtable,再反编译候选函数确认。

找到消息对象后,先记录目标 vtable,再检查其中的关键槽位。

示意结构如下:

这组偏移只代表当前分析目标的候选映射,不是所有 generated-lite 版本的固定规律。最终必须用 vtable 内容和反编译结果确认。

怎么确认是 Parse

可靠的 Parse 候选一般有这样的结构:

tag = reader.ReadTag();

switch&nbsp;(tag >>&nbsp;3) {

case&nbsp;1:

// field 1

case&nbsp;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&nbsp;ptrText(p) {

return&nbsp;p && !p.isNull() ? p.toString() :&nbsp;'0x0';

}

function&nbsp;inspectMessage(msgPtr) {

if&nbsp;(!msgPtr || msgPtr.isNull()) {

return&nbsp;{&nbsp;messagePtr:&nbsp;'0x0',&nbsp;vtable:&nbsp;'0x0'&nbsp;};

}

let&nbsp;vtable =&nbsp;NULL;

try&nbsp;{

vtable = msgPtr.readPointer();

}&nbsp;catch&nbsp;(_) {}

return&nbsp;{

messagePtr:&nbsp;ptrText(msgPtr),

vtable:&nbsp;ptrText(vtable),

};

}

一个比较可靠的判断链路是:

args[0]&nbsp;可以读取 vtable

↓

vtable 对应目标消息类型

↓

调用者回溯属于目标接口

↓

调用时机发生在请求序列化或响应解析阶段

如果 args[0] 实际是 writer、stream 或字符串对象,就回到 IDA 重新确认函数原型和 ARM64 寄存器传参,不要强行假设第一个参数一定是 this.

五、序列化 Hook:抓完整 protobuf bytes

hook.js 的 processManualBuffers() 里有 protobuf-serializer 处理分支,基本流程是:

enter:保存消息对象和输出对象

↓

读取 vtable,记录候选槽位

↓

leave:读取输出对象的指针和长度

↓

通过 network-dump&nbsp;保存完整二进制 bytes

进入函数时可以先记录:

const&nbsp;msgPtr&nbsp;= args[0];

const&nbsp;outObjectPtr&nbsp;= args[1];

send({

type:&nbsp;'network-log',

event:&nbsp;'protobuf-serialize-enter',

message:&nbsp;inspectMessage(msgPtr),

outObject:&nbsp;ptrText(outObjectPtr),

});

protobuf 是二进制数据,不能用 readUtf8String()。应读取“数据指针 + 实际长度”:

const&nbsp;info&nbsp;=&nbsp;readWeChatStringLike(outObjectPtr, PROTOBUF_DUMP_LIMIT);

if&nbsp;(info.buffer && info.length >&nbsp;0) {

send({

type:&nbsp;'network-dump',

direction:&nbsp;'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&nbsp;parseAddress = CORE_MODULE.base.add(PARSE_OFFSET);

Interceptor.attach(parseAddress, {

onEnter(args) {

this.message = args[0];

this.reader = args[1];

send({

type:&nbsp;'protobuf-parse-enter',

message: inspectMessage(this.message),

reader: ptrText(this.reader),

arg2: ptrText(args[2]),

});

},

onLeave(retval) {

send({

type:&nbsp;'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&nbsp;format&nbsp;解码

链路证据:请求/响应时序能和接口外层 Hook 对上

下面这些现象单独出现时,都不能证明已经定位到了 protobuf:

  • 函数名包含 serialize 或 parse
  • 网络数据里存在一个看起来像长度的字段;
  • socket 层抓到了一段二进制;
  • GAPSessionPacket 里出现了 body;
  • 外层接口 body 可以 dump。

真正有说服力的证据,是“消息对象 + vtable + wire tag/writer + 完整 bytes”能够相互对应。

#

十、版本升级时怎么复用?

从 5.0.7 升级到其他版本时,不要直接复制旧地址。建议重新走一遍:

  • 搜索接口消息类型字符串;
  • 找到 type-name 函数;
  • 通过 XREF 找 vtable;
  • 反编译确认 ParseByteSizeInternalSerialize
  • 对比调用者、寄存器和参数布局;
  • 再把地址写入 Frida Hook 配置;
  • 用运行时日志、完整 bytes 和接口时序做闭环验证。

#

十一、速查表

#

先搜类型名,确认消息是谁;

再沿 XREF 找 vtable;

从 vtable 找 Parse / ByteSize / InternalSerialize;

反编译确认 wire tag、reader 和 writer;

Frida 先看对象、vtable、回溯,再读 buffer;

PackRequest / HandleResponse 用来关联接口;

最后用完整&nbsp;bytes&nbsp;和请求/响应时序做交叉验证。

结语

这件事可以用一个很直白的思路来做:先搞清楚“消息对象是谁”,再确认“它的 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点》

评论:0   参与:  0