首页
社区
课程
招聘
[原创]macOS企业微信5.0.7逆向分析(一):如何定位protobuf序列化与反序列化Hook点
发表于: 4小时前 144

[原创]macOS企业微信5.0.7逆向分析(一):如何定位protobuf序列化与反序列化Hook点

4小时前
144

本文面向已经会使用 IDA 和 Frida,但对 generated-lite / protobuf Hook 还不太熟悉的读者。

本文只讨论消息的 protobuf 序列化、反序列化以及 Hook 点定位,不展开登录协议、加密算法、业务字段含义、接口重放或具体业务模块分析。请只在自己的程序、账号和已获授权的环境中测试。

先说结论

如果只记住一件事,可以记下面两条:


请求:业务对象 -> InternalSerialize -> protobuf bytes

响应:protobuf bytes -> Parse / MergeFrom -> 业务对象

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

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

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

逆向网络代码时,最容易踩的坑就是看到函数名里有 SerializeParseMakeBody,就直接认为它是 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 / ParsePackRequest / HandleResponse 的层次关系。

二、第一步:先搜索 protobuf 类型名

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

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

  • 日志里的类名或 className

  • 业务调用点里的请求/响应类型;

  • IDA 字符串窗口中的 xxxReqxxxRspRequestResponse

  • 类型名函数返回的完整名称。

类型名函数一般很简单:


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:

   // 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。因此可以同时保留两个观察点:

  1. Hook vtable 中的 InternalSerialize,抓消息对象和输出对象;

  2. 跟进 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.jsprocessManualBuffers() 里有 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 和具体接口对应起来?

单独看到一次 ParseInternalSerialize 命中,还不一定知道它属于哪个接口。可以把事件按时间顺序串起来:


请求构造

-> 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:

  • 函数名包含 serializeparse

  • 网络数据里存在一个看起来像长度的字段;

  • socket 层抓到了一段二进制;

  • GAPSessionPacket 里出现了 body;

  • 外层接口 body 可以 dump。

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

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

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

  1. 搜索接口消息类型字符串;

  2. 找到 type-name 函数;

  3. 通过 XREF 找 vtable;

  4. 反编译确认 ParseByteSizeInternalSerialize

  5. 对比调用者、寄存器和参数布局;

  6. 再把地址写入 Frida Hook 配置;

  7. 用运行时日志、完整 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编辑 ,原因:
收藏
免费 0
打赏
分享
最新回复 (2)
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
这个是iOS版本的企业微信吗,不太懂
2小时前
0
雪    币: 229
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
kx_enrse 这个是iOS版本的企业微信吗,不太懂
macos版本,和ios版差异不大
1小时前
0
游客
登录 | 注册 方可回帖
返回