接了个有点棘手的分析任务:一款日活过亿的大厂App,IM消息走的是私有长连接。按我平时的工作习惯,先把Charles挂上、证书装好、代理指过去,结果打开流量面板的时候整个人愣了一下——里面清一色是“图片”:一张张PNG、JPEG文件,还夹着一堆看不懂的二进制流,应用层的业务数据一个都看不见。那一刻的心情,做过逆向的朋友应该都能体会。
后来冷静下来梳理了一遍,发现这根本不是抓包工具的问题,而是我对App的通信架构判断错了。现在很多大厂App早就不是“HTTP一把梭”的时代了,私有长连接、自研加密协议、Native层收发通道,这些都是常规操作。抓包工具只能看到HTTP层,或者只认识常见协议的特征,遇到私有协议自然就“懵”了,甚至还会把二进制数据误判成图片类型。
这篇文章我想把这套问题彻底讲透:为什么你抓大厂App全是图片没有数据、私有长连接到底是什么、什么叫逆向容灾的思路,以及我最终是怎么把这条“看不到的链路”完整打穿的。全程以实操为主,代码和命令都给到,你可以照着排查自己的项目。
1. 大厂App为什么抓不到明文:先搞清楚你面对的是哪一层防护
1.1 你看到的问题,本质是三层防护叠加
很多人一上来就问“为什么我抓包全是图片”,我通常先反问一句:你用的是哪一类抓包工具?如果你的答案是Charles、Fiddler、Burp Suite,那问题基本就锁定了——它们都是基于HTTP代理工作的工具,而大厂App的实时通信早就不走HTTP了。
具体来说,你会遇到三层叠加的防护:
第一层,流量根本不走HTTP代理。App为了IM消息、推送、实时互动的时效性,普遍会维持一条TCP长连接,或者使用WebSocket、自带协议的QUIC连接。这类流量不会主动走系统HTTP代理,你的Charles自然看不到明文,只能看到一条条原始TCP流转成二进制。
第二层,就算流量走了HTTP,证书校验也会卡死你。SSL Pinning已经成了大厂App的标配,App把服务端证书或公钥指纹写死在客户端里,你自签的Burp证书根本不被信任,中间人代理直接失效。体现在抓包面板上,就是一堆TLS握手失败或者直接空数据。
第三层,数据本身有加密。私有长连接里的payload通常是一套自定义的序列化协议,外层再做AES-GCM、ChaCha20或者国密SM4加密,密钥通过协商或设备绑定产生。就算你用Wireshark把原始TCP流完整dump下来,也只能看到密文,一堆没有任何规律的字节。
三层叠加之后,你手里的HTTP代理工具就成了“睁眼瞎”。而那些显示为“图片”的内容,其实是抓包工具根据TCP流的前几个字节去猜MIME类型,猜成了PNG或JPEG。说白了不是真的图片,是工具不认识私有协议时的误判。
1.2 大厂App的典型通信架构长什么样
理解完三层防护,你还需要对整个通信架构有个全局认识。我拆过不少大厂App,它们通常不是单一协议,而是多种通道并存:
- HTTP/HTTPS接口:低频请求、登录、配置拉取这些,特点是一次性请求,适合走CDN和HTTPDNS。
- 私有TCP长连接:IM消息、在线状态、服务端主动推送,连接一旦建立就长期存活,有专门的心跳包维护。
- WebSocket/QUIC:弱网优化、实时音视频信令,通常走自己的协议栈。
- Protobuf/FlatBuffers序列化:结构化数据在客户端和服务端之间传递时,几乎不用JSON明文,而是二进制序列化,效率高、体积小,也更难直接阅读。
这条架构背后还有一个核心逻辑:大厂要的是性能和实时性,HTTP的握手开销和明文传输在IM场景下太奢侈了。私有长连接能让客户端和服务器之间保持一条“专用隧道”,数据随时进出。
理解了这套架构,你就能明白为什么“把所有流量挂到Charles下面”这种方案从一开始就是错位的。你要做的不是把私有流量硬塞回HTTP代理,而是换一条分析路径——这正是下面要讲的逆向容灾。
2. 逆向容灾:为什么一套方案打不穿,需要的是“组合拳”
2.1 什么是逆向容灾
这个词算是我自己总结的习惯叫法。第一次遇到私有长连接的时候,我走了很多弯路:Charles不行就换Fiddler,Fiddler不行就换Wireshark,结果翻来覆去还是那堆乱码。后来我才意识到,不是工具不够多,而是我的思路是“一线单通”的——一条路走到黑,断了就卡死。
所谓逆向容灾,就是在开始分析之前,先规划好几条互相独立的技术路线,任何一条被App的安全机制检测到或者失效时,马上切到备用路线,不中断主体分析流程。类比一下就是服务端高可用:一台机器挂了,另一台立刻顶上,流量不丢、服务不中断。
在逆向工程里,这条思路特别重要。因为大厂App的对抗手段是动态的,你今天能hook的入口,明天发个新版本可能就被堵上了。如果你只准备了一套方案,一旦被反制,就得从头开始,时间成本完全失控。
2.2 常见对抗手段与失效场景
要设计容灾路径,你首先得知道自己可能死在哪个环节。我整理了一份常见的对抗清单:
- 反调试:检测ptrace被附加、读取TracerPid、检测调试端口。你一旦用IDA附加,App可能直接退出。
- 反Hook:检测frida-server进程、检查端口27042、扫描内存中frida特征字符串、检查maps文件里的frida库路径。很多大厂App会在Native层做这些检测。
- 反模拟器/反虚拟机:检查Build指纹、传感器列表、SIM卡状态、虚拟化特征。模拟器上跑起来直接闪退或功能降级。
- 加固与混淆:整个应用套壳,Java层代码被抽取,Native层VMP变形,静态分析的时候基本看不到关键逻辑。
- 设备指纹与风控:App会采集设备唯一标识、行为特征,检测到异常环境之后不直接拒绝,而是静默断链或者下发伪造数据,让你分析到的全是假的。
这几招单拿出来都还好说,难就难在它们经常组合使用。你今天绕过了反调试,明天又触发了反Hook;你刚hook到解密函数,风控又识别到设备异常,直接断了长连接。每一步都可能翻车。
2.3 容灾矩阵:把鸡蛋放进多个篮子里
我的做法是维护一个“容灾矩阵”,从四个维度做冗余备份:
环境容灾:至少准备一台root过的物理真机、一台主流模拟器、一台云手机。物理真机上跑真实场景,模拟器上跑自动化脱壳和批量分析,云手机用于多地区、多网络环境下的对比测试。
工具容灾:不能只依赖Frida,LSPosed、Objection、IDA/Ghidra、unidbg都要熟。Frida被检测了,换LSPosed从框架层hook;动态被卡,切到静态分析;实在不行,用unidbg在PC端模拟执行Native函数,脱离设备做单函数调试。
协议容灾:Charles负责HTTP层,Wireshark配合tcpdump负责TCP/IP层,再写一份自研的协议解析脚本兜底。每一层的数据都独立抓一份,互相校验,防止某一层的数据被App的干扰策略污染。
数据容灾:分析过程中产生的每一个中间结果都要及时保存。hook脚本、解密后的明文、指纹特征、协议格式备注,分门别类存档。这东西看起来是小事,但做逆向的人都知道,关键时刻一个对应的中间结果能省掉几个小时重复工作。
这套矩阵搭好之后,我的工作效率提升了不止一个档次。以前是“遇到一个坑填一个坑”,现在是一开始就按照多路并行的方式推进,任何一路断了都不会影响整体节奏。
3. 降维破局:三招把私有长连接打回原形
3.1 第一招:让App自己降级到HTTP
很多大厂App在生产环境用私有长连接,但并不是所有场景都强制走长连接。为了兼容弱网、老旧版本、或者某些特定地区,它们往往会保留一条HTTP/HTTPS兜底通道。比如长连接连续失败几次之后,App会把消息切换成HTTP轮询来发。
这个逻辑就是我们下手的机会。具体做法是:
第一步,Hook网络状态相关的API,让App误判当前网络环境极差。可以hookConnectivityManager.getActiveNetworkInfo(),或者hook系统层的网络评分模块,把网络类型改成无网络,把带宽改成几乎不可用。
第二步,观察App的日志和流量变化。如果设计合理,它会自动从长连接切换到HTTP轮询,此时你在Charles里就能看到一串串HTTP请求冒出来。
第三步,趁它切换的空档,把整个降级过程的抓包数据存下来。这时候你拿到的明文HTTP接口往往和长连接是同一套业务逻辑,通过对比两者的请求参数,你就能逆向出长连接里大部分业务字段的含义。
这条招数的优势在于完全不需要碰Native层,纯Java层hook就能实现,风险很低。缺点也很明显:不是所有App都有降级逻辑,有些极简主义的App只保留一条长连接通道,那就需要上第二招。
3.2 第二招:在TLS出口处拿明文(Hook SSL读写函数)
无论私有长连接有多复杂,数据在网络上传输之前,最终都会经过TLS层的加密封装。只要能hook住TLS的读写函数,就能拿到加密前的明文。
以主流App为例,它们基本都会调用OpenSSL或者BoringSSL的SSL_read和SSL_write。用Frida把这两个函数hook住,再把buffer里的数据打印出来,你就绕过了证书校验和中间的TLS握手,直接在应用层出口看到干净的业务数据。
先来个最基础的Frida脚本:
// hook_ssl.js // 用法: frida -U -l hook_ssl.js -f com.example.app // 不能直接用对象的名字,先用Module.findExportByName定位导出函数 const sslRead = Module.findExportByName("libssl.so", "SSL_read"); const sslWrite = Module.findExportByName("libssl.so", "SSL_write"); function hexdumpBuffer(ptr, len) { // 构造一个可读的hex输出,避免乱码误判 const bytes = ptr.readByteArray(len); console.log(hexdump(bytes, { offset: 0, length: len, header: true, ansi: false })); } if (sslRead) { Interceptor.attach(sslRead, { onEnter: function (args) { this.buf = args[1]; this.size = parseInt(args[2]); }, onLeave: function (retval) { const n = retval.toInt32(); if (n > 0 && n < this.size) { console.log("[SSL_read] len=" + n); hexdumpBuffer(this.buf, n); } } }); } if (sslWrite) { Interceptor.attach(sslWrite, { onEnter: function (args) { const buf = args[1]; const len = parseInt(args[2]); if (len > 0 && len < 1024 * 1024) { console.log("[SSL_write] len=" + len); hexdumpBuffer(buf, len); } } }); }这个脚本有几个细节要注意。
第一,libssl.so只是一个通用名字,不同App可能打包了不同版本的OpenSSL,甚至静态编译到libxx.so里。你先执行frida-ps -U拿到进程,然后cat /proc/[pid]/maps | grep ssl找到真正的so文件路径,再替换脚本里的模块名。
第二,缓冲区长度不要全量打印,否则一个几十KB的消息能把你的终端卡死。我在脚本里限制len < 1024 * 1024,主要抓正常的业务包,太大的流直接跳过。
第三,如果你发现hookSSL_read/write之后打印出来的还是乱码,那不是hook错了,而是数据在TLS层之外还有一层压缩或者加密。常见的做法是先用zlib解压,再解外层加密,最终才能看到业务明文。这就要用到第三招了。
3.3 第三招:从业务序列化层拿结构化数据(Hook Protobuf/序列化入口)
私有长连接的payload几乎都是Protobuf或者类似的结构化二进制格式。数据要发送之前,业务层会把对象序列化成字节流;收到数据之后,会把字节流反序列化成业务对象。无论加密怎么复杂,序列化这一步是绕不开的。只要找到序列化入口,hook住之后拿到的就是最干净的结构化数据。
以Protobuf为例,Java层的入口通常是GeneratedMessageLite.parseFrom或者MessageLite.writeTo。Native层则可能是google::protobuf::MessageLite::ParseFromArray或者自定义的封装函数。
先看Java层的hook示例:
// hook_protobuf_java.js // 以最常用的parseFrom为例 const ByteString = Java.use("com.google.protobuf.ByteString"); const Builder = Java.use("com.google.protobuf.GeneratedMessageLite$Builder"); // 反编译时经常能看到类名形如 XXX$1 的匿名内部类,先根据反编译结果调整类名 const TargetClass = Java.use("com.example.app.protocol.LoginRequest"); // hook SerializeToBytes 一类的入口 TargetClass.serializeToByteArray.implementation = function() { const result = this.serializeToByteArray(); console.log("[protobuf serialize] " + JSON.stringify(this)); return result; };但说实话,Java层的序列化入口在大厂App里已经被各种加固和混淆搞得很乱了。更稳妥的方式是直接在Native层找writeTo和mergeFrom的C++符号。
我的习惯是先用frida-trace快速过一遍Native层的动态库导出函数:
frida-trace -U -i "writeTo*" -i "mergeFrom*" -i "ParseFrom*" com.example.app跑起来之后,App只要一有数据传输,控制台就会疯狂打印命中的函数名。从里面挑名字带message、proto、lite的,基本就是Protocol Buffer的核心入口。
然后再针对这个入口做深度hook,直接读取序列化后的字节流。因为Protobuf本身是自描述的,只要你把字段编号对应上,就能还原出完整的消息内容。
三招的关系是层层递进的:第一招让App自己走HTTP,成本最低但取决于App设计;第二招hook TLS出口,通用性强但可能遇到二层加密;第三招hook序列化入口,最稳也最彻底,但需要先在反编译和动态跟踪上花些功夫。实际项目中我一般会先跑第二招,拿不到明文就立刻转第三招,协同推进。
4. 实操实录:一个IM长连接从黑盒到白盒的全过程
4.1 环境准备与全局抓包链路搭建
为了把前面讲的思路串起来,我写一个从我实际工作中脱敏后的完整案例。目标App是一款主打IM社交的应用,通信走私有TCP长连接,协议层封了一层类似TLS的自研加密。
我的环境清单如下:
- 一台Pixel 5真机,Android 13,已root并安装Magisk。
- Frida 16.x,通过Magisk模块把frida-server跑起来。
- jadx用于静态反编译。
- Wireshark用于离线分析pcap包。
- tcpdump先抓底层TCP流量。
- 自研的一套Python解析脚本,用于剖析私有协议。
第一步永远是先抓原始流量,而不是直接上Hook。原因很简单:先知道数据包长什么样,你才知道该往哪个方向去hook。执行命令抓包:
adb shell "su -c 'tcpdump -i any -s 0 -w /sdcard/im.pcap'" &然后正常使用App的IM功能,让几个朋友发几条消息,持续约十分钟,CTRL+C结束抓包。把pcap拉到本地,用Wireshark打开。
4.2 通过Wireshark识别私有协议特征
打开pcap之后,过滤掉TCP握手之后的数据片段。你会看到大量的TCP段,载荷集中在几十到几千字节之间,明显不是HTTP。盯着几个典型的包看数据内容,发现它们的开头几个字节非常规律:A1 B2 C3 D4,紧接着两个字节是协议编号,再两个字节是长度,后面跟着一大段payload。
这种固定magic header的出现,意味着协议有明确的帧格式。我标记一下特征:
- 偏移0-3:固定魔数
A1 B2 C3 D4,用于区分消息起始位置。 - 偏移4-5:协议号,大端序,表示消息类型。
- 偏移6-7:payload长度,大端序。
- 偏移8:payload本体,看起来是二进制,猜测内部还有一层结构。
到这一步,我已经知道整个长连接的帧格式了。接下来要搞清楚payload里的内容是什么。光靠Wireshark是解不开的,需要配合动态分析。
4.3 静态定位协议入口:从Java层到Native层
用jadx打开目标App的apk,全局搜索刚才抓到的魔数A1B2C3D4。如果搜不到A1 B2 C3 D4这种字节序列,就搜十进制0xA1B2C3D4或者小端序的D4C3B2A1。因为代码里这个值可能是通过整型常量定义的。
我搜到的结果是:一个叫native_client.so的Native动态库里导出了一个函数parse_frame,里面明确对接收的字节流做了magic判断。顺着这个函数的引用列表,找到了Java层的入口类IMChannel,它调用了NativeClient.nativeSend和NativeClient.nativeRecv。
这个结果说明,长连接的收发逻辑基本都封装在Native层,Java层只是个壳。这时候我面对的关键问题是:payload在Native层有没有做加密?
4.4 动态Hook:从序列化层直接拿明文
我决定双管齐下:第一路,hookSSL_read/write检查TLS层;第二路,直接hook protobuf的parse入口看有没有命中。
先跑一轮frida-trace,把所有带proto、message、packet字样的Native函数全部trace一遍。几轮消息收发之后,控制台里果然冒出一系列命中:
0x7a1234abcd message_lite.cc google::protobuf::MessageLite::ParseFromArray 0x7a1234ef56 message_lite.cc google::protobuf::MessageLite::SerializeToArray这就有意思了。说明虽然传输层有加密,但应用层自己先在内存里把业务对象序列化成了Protobuf字节流,再交给加密模块做加密传输。那我只要在SerializeToArray和ParseFromArray这两个入口上hook,就能拿到最原始的Protobuf数据。
针对这个目标,写一个聚焦的Frida脚本:
// hook_proto_native.js // 目标: hook google::protobuf::MessageLite 的序列化/反序列化入口 const serialize = Module.findExportByName("native_client.so", "_ZN6google8protobuf11MessageLite16SerializeToArrayEPvj"); const parse = Module.findExportByName("native_client.so", "_ZN6google8protobuf11MessageLite14ParseFromArrayEPKvj"); if (serialize) { Interceptor.attach(serialize, { onEnter: function (args) { this.buf = args[1]; this.size = parseInt(args[2]); }, onLeave: function (retval) { if (retval.toInt32() > 0) { console.log("[SerializeToArray] dumped size=" + this.size); var bytes = this.buf.readByteArray(this.size); send({ type: "proto", size: this.size, data: Array.from(new Uint8Array(bytes)) }); } } }); } if (parse) { Interceptor.attach(parse, { onEnter: function (args) { this.buf = args[1]; this.size = parseInt(args[2]); }, onLeave: function (retval) { if (retval.toInt32() > 0) { console.log("[ParseFromArray] input size=" + this.size); var bytes = this.buf.readByteArray(this.size); send({ type: "proto", size: this.size, data: Array.from(new Uint8Array(bytes)) }); } } }); }跑起来之后,配合收发消息,控制台果然打出了一串串ParseFromArray的命中记录,并且dump出来的字节流里能清晰看到业务字段。导出到本地之后,用Python对Protobuf做一次字段推断。
4.5 从二进制到业务字段:解析私有协议的最后一公里
解析Protobuf最直接的方法是找到App里的.proto定义。但多数大厂不会把.proto明文存在包体里,你需要根据字段编号和wire type做人工推断。
对dump下来的字节流做结构化输出:
import struct # 简单演示: 读取PCAP中提取的一段payload def parse_pb_varint(data, offset): result = 0 shift = 0 while True: b = data[offset] result |= (b & 0x7F) << shift offset += 1 if not (b & 0x80): break shift += 7 return result, offset def parse_pb_message(data): offset = 0 fields = {} while offset < len(data): tag, offset = parse_pb_varint(data, offset) field_no = tag >> 3 wire_type = tag & 0x7 if wire_type == 0: value, offset = parse_pb_varint(data, offset) fields.setdefault(field_no, []).append(("varint", value)) elif wire_type == 2: length, offset = parse_pb_varint(data, offset) value = data[offset:offset + length] offset += length fields.setdefault(field_no, []).append(("bytes", value.hex())) elif wire_type == 1: value = struct.unpack("<Q", data[offset:offset + 8])[0] offset += 8 fields.setdefault(field_no, []).append(("fixed64", value)) elif wire_type == 5: value = struct.unpack("<I", data[offset:offset + 4])[0] offset += 4 fields.setdefault(field_no, []).append(("fixed32", value)) return fields # 这里填入从Frida dump出的payload payload = bytes.fromhex("...") print(parse_pb_message(payload))跑出来之后,字段1大概率是msg类型,字段2是消息内容,字段3是时间戳,字段4是发送者ID。再加上你在Wireshark里看到的A1 B2 C3 D4 + 协议号 + 长度帧头,整个私有长连接的协议格式就完全白盒化了。
到这一步,我不仅拿到了长连接里每条消息的明文,还理清了客户端和服务端之间从TCP帧到业务对象的完整链路。整个过程没有正面硬刚App的加固和反调试,而是通过容灾矩阵里的多路方案组合,“降维”到了序列化层去拿数据。
5. 常见问题与排查技巧实录
实战中我会反复踩到一些坑,顺手整理成一张速查表,供你排查时对照。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| Charles里全是图片/二进制流,看不到业务数据 | 流量不走HTTP代理,走的是私有TCP长连接 | 放弃HTTP代理工具,改用tcpdump+Wireshark抓原始包,再结合Hook拿明文 |
| Frida一attach就闪退 | 触发了反Frida检测,检测到frida-server进程、端口27042或内存特征 | 修改frida-server端口和进程名,用frida-server -l 0.0.0.0:12345换端口;或使用定制版Frida |
HookSSL_read/write后打印出来的是乱码 | 数据在TLS层之外还有压缩或加密,或者hook错了SSL库 | 先看前几个字节是否满足zlib/gzip头部特征(0x78 0x9C等),是则先解压再分析;同时用cat /proc/[pid]/maps | grep ssl确认真正的SSL库 |
| Wireshark抓到TCP包但全是重传,没有正常数据 | App检测到网络异常、代理或者设备被调试,主动放弃长连接 | 确保当前环境干净,关闭多余代理,只做被动抓包不干预流量;必要时切换到物理真机 |
| 抓到的数据长度明显异常,短包丢失 | 长连接协议分包/粘包,一次读取只拿到半截数据 | 在hook层面缓存数据,根据帧头里的长度字段做粘包重组;如果已经离线,就用Python按魔数和长度字段重新拆分 |
| Hook Protobuf入口一个都不命中 | App用的不是标准Protobuf,可能是FlatBuffers、自研序列化或者被VMP隐藏了符号 | 用frida-trace -i "serialize*" -i "parse*" -i "write*"拉宽范围;找不到C++符号就跟踪Java层入口,或者用unidbg在PC端模拟执行 |
| 重打包App后会闪退 | 签名校验,App在启动时校验自身签名与官方包不一致 | 不重打包,直接加载Xposed/LSPosed模块进内存做Hook;或者找到签名校验函数单独绕过,但工程量大,不推荐 |
除了表格里的排查项,还有几个长期积累下来的独家技巧,分享给你。
利用“字符串交叉引用”快速定位关键代码。拿到反编译代码之后,先用strings或者jadx的搜索功能找到类似connection lost、heartbeat timeout这样的业务提示词。因为App虽然有安全防护,但业务层的日志和提示字符串很难全部混淆。从这些字符串交叉引用往上推,往往能找到长连接的核心处理函数,效率比漫无目的地翻代码高得多。
Hook的时候优先打印hexdump而不是直接转字符串。很多二进制协议的字段是紧凑的数值型,直接print字符串会得到一堆乱码和不可见符号。先打印hex格式,你才能准确判断magic、长度、字段编号这些结构信息。等确认某个字段是文本内容之后,再单独按UTF-8解码。
如果App的Native层有VMP或者指令虚拟化保护,导致IDA打开全是加密指令,别死磕。换到系统调用层面去看:Hooksend、recv、read、write,或者直接看/proc/[pid]/syscall的输出。VMP保护的是业务代码逻辑,但不能不调用系统socket。在系统调用层拿到的字节流,和你在Wireshark里看到的密文是一一对应的,配合网络包特征一样能反推协议。
还有一个实操里经常被忽略的点:当你发现目标App有大量的数据压缩,先看头部特征有没有zlib。zlib压缩流的第一个字节通常是0x78,gzip是0x1F 0x8B,看到这些就去针对性解压,而不是一头扎进加密分析里。很多时候你以为的“加密算法”,其实只是压缩算法,先压缩后解压就全部变明文了。
写在最后
这套组合拳打下来,我最深的体会是:逆向分析这行,心态比技术重要。第一次看到满屏“图片”和乱码的时候,我也怀疑过是不是自己的工具配置有问题。但后来明白了一个朴素的道理——抓不到数据不是抓包工具不行,而是你选错了分析维度。先确认协议栈在哪一层、加密在哪一层、序列化在哪一层,然后再切到对应的维度去拿数据,问题自然就打开了。
最后再分享一个小技巧:每次分析一个陌生App之前,先在纸上画一条从“用户操作”到“网络字节流”的链路图,标出你打算在哪几个节点做hook、抓包、反编译。画完之后你会发现,大多数看似无解的抓包难题,其实都是因为链路图没画全,漏了关键的中间层。思路理顺了,工具永远只是执行手段而已。