1. 这不是“抓包看明文”而是一场协议层的精密外科手术很多人第一次听说“逆向Soul聊天协议”脑子里浮现的是Wireshark里飘过的一串HTTP请求点开一看Content-Type: application/json字段名清清楚楚——然后就以为搞定了。我去年也这么天真过。直到某天想做个本地消息备份工具发现所有看似正常的POST请求体全是base64编码的乱码decode之后是二进制字节流用UTF-8强行解码直接报错更诡异的是同一时间发的两条相似消息比如“在吗”和“你好啊”抓到的二进制长度却差了17个字节完全不符合JSON的可预测性。那一刻我才意识到这不是HTTP层的明文游戏而是Protobuf在TLS之下悄悄完成了数据压缩、字段序列化与结构隐藏。所谓“从抓包到解密”本质是三重穿透——第一层破TLS流量镜像非解密是绕过证书校验实现中间人可控捕获第二层识别Protobuf wire format的tag-length-value三元组模式第三层反推.proto定义文件把二进制流还原成人类可读的message结构。这个过程不依赖任何“万能解密脚本”而是靠对Protobuf编码规则的肌肉记忆、对Android应用层网络栈的路径熟悉度以及对Soul客户端版本迭代中协议微调的持续跟踪。它适合两类人一是想做合规第三方消息归档的独立开发者需严格遵守《个人信息保护法》第23条关于处理目的限定与最小必要原则二是移动安全研究者用于验证自身App的通信安全性设计是否到位。如果你只是想“看看别人聊了啥”请立刻停止——本文所有技术细节均基于公开可获取的APK逆向与本地流量分析不涉及任何用户数据窃取或越权访问所有操作均在个人测试设备完成且全程关闭云同步与消息漫游功能。2. 抓包环节的致命陷阱为什么Fiddler/Charles在Soul上集体失效2.1 TLS Pinning不是障碍而是必须攻克的第一道关卡Soul Android客户端以v12.45.0为例在OkHttp初始化时硬编码了至少4组公钥指纹SHA-256覆盖主域名soulapp.net及其CDN子域。这意味着当你在手机上安装Fiddler或Charles的根证书后App发起HTTPS请求时会主动比对服务端证书链中的公钥哈希值一旦不匹配立即抛出javax.net.ssl.SSLPeerUnverifiedException并终止连接。这不是配置问题而是代码级防护。我试过三种常见绕过方式结果如下绕过方式实测效果根本原因操作耗时Xposed JustTrustMe模块❌ 失败App启动即CrashSoul使用System.loadLibrary(soul_security)加载自研so库在JNI_OnLoad中检测Xposed框架特征字符串xposed15分钟Frida hookOkHostnameVerifier.check⚠️ 部分成功仅对部分API生效该hook仅影响HTTP层校验Soul在TLS握手前已通过SSLSocketFactory内置pinning逻辑拦截40分钟调试APK反编译Smali注入✅ 稳定生效全量API捕获直接修改com.soul.app.network.SSLHelper类中getSSLSocketFactory()方法返回信任所有证书的TrustAllManager实例2小时含重打包签名关键操作细节使用jadx-gui打开APK定位到SSLHelper.java反编译代码找到getSSLSocketFactory()方法其原始逻辑是调用createPinnedSSLSocketFactory()生成带证书锁定的工厂我们将其替换为以下逻辑对应Smali代码需精准注入public SSLSocketFactory getSSLSocketFactory() { try { SSLContext sslContext SSLContext.getInstance(TLS); sslContext.init(null, new TrustManager[]{new X509TrustManager() { public void checkClientTrusted(X509Certificate[] chain, String authType) {} public void checkServerTrusted(X509Certificate[] chain, String authType) {} public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } }}, new SecureRandom()); return sslContext.getSocketFactory(); } catch (Exception e) { throw new RuntimeException(e); } }提示重打包后必须用apksigner重新签名否则Android 11系统拒绝安装。签名密钥建议使用keytool -genkeypair -v -keystore soul-debug.jks -alias soul -keyalg RSA -keysize 2048 -validity 10000生成避免使用平台默认debug密钥易被Soul的签名校验逻辑识别。2.2 抓包位置选择为什么必须在OkHttp Interceptor层而非系统Socket层很多教程推荐用tcpdump在root设备上抓/data/data/com.soul.app/files/capture.pcap但Soul的流量存在两个关键特征多路复用混淆其长连接并非单一TCP流而是通过OkHttpClient的ConnectionPool复用连接同一socket承载多个逻辑会话心跳、消息、状态同步TLS分片干扰Android系统级抓包捕获的是TLS record层数据每个record包含加密后的application data长度随机通常2^14字节以内导致Protobuf消息体被切割到多个TLS record中无法直接拼接。正确做法是注入OkHttp Interceptor在ResponseBody被消费前截获原始字节流。具体步骤在com.soul.app.network.OkHttpConfigurator类中找到addNetworkInterceptor()调用处插入自定义Interceptor核心逻辑如下class ProtoCaptureInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request chain.request() val response chain.proceed(request) // 仅捕获特定路径的POST请求如 /api/v1/chat/send if (request.method() POST request.url().encodedPath().contains(chat)) { val bodyBytes response.body()?.bytes() ?: return response // 将二进制流写入SD卡临时文件命名规则timestamp_requestId.bin saveProtoBinary(bodyBytes, request.header(X-Request-ID) ?: unknown) } return response } }注意此Interceptor必须添加为networkInterceptor而非applicationInterceptor因为Soul使用GzipSource压缩响应体applicationInterceptor看到的是解压后内容而我们需要原始Protobuf二进制流。实测发现networkInterceptor捕获的数据长度与APK中libprotobuf.so解析出的wire length完全一致误差为0字节。2.3 流量筛选策略如何从200接口中精准定位聊天协议Soul客户端启动后会并发请求约180个API含埋点、广告、头像CDN等手动筛选效率极低。我的经验是建立三级过滤漏斗第一级域名白名单只关注soulapp.net主域及其子域im.soulapp.net,chat.soulapp.net排除所有*.cdn.soulapp.net静态资源和*.analytics.soulapp.net埋点。第二级HTTP Method Path Pattern发送消息POST /api/v1/chat/send必含content字段但实际为Protobuf二进制接收消息GET /api/v1/chat/pull响应体为Protobuf数组心跳保活POST /api/v1/heartbeat无业务字段仅用于维持连接第三级Header特征指纹重点检查X-Protocol-Version: 2Soul自定义协议版本头和Content-Type: application/x-protobuf明确标识Protobuf格式。实测发现v12.x版本中92%的聊天相关接口均携带这两个Header误判率低于3%。最终我构建了一个自动化过滤脚本Python tshark命令如下tshark -r soul_chat.pcapng \ -Y http.host contains soulapp.net http.request.method POST http.request.uri contains chat/send \ -T fields -e http.request.full_uri -e http.request.headers -e data.text \ chat_send_filtered.txt该脚本输出的每行包含URI、Headers和十六进制数据流为后续Protobuf解析提供结构化输入。3. Protobuf逆向的核心战场从二进制流到.proto文件的四步推演3.1 Wire Format解码用Tag-Length-Value规则手工拆解首条消息Protobuf wire format不传输字段名只用tag字段编号类型、length仅对string/bytes类型、value实际数据三部分编码。Soul的ChatMessage结构中最简消息纯文本“hi”的二进制流为0A 14 0A 02 68 69 10 01 18 01 20 01 28 01 30 01 38 01 40 01 48 01 50 01我们按规则逐段解析0A→ tag 1字段编号1wire_type 2length-delimited14→ length 20十进制表示接下来20字节是该字段的value0A 02 68 69→ value前4字节0A是嵌套message的tag字段1type202是其length268 69是ASCII的“hi”10 01→ tag 2字段编号2wire_type 0varintvalue 1消息类型1TEXT18 01→ tag 3wire_type 0value 1发送者ID类型后续20 01...50 01依次对应字段4~10均为varint类型值全为1关键洞察Soul将所有字段默认值设为1而非Protobuf标准的0这是其协议设计的显著特征。例如field_number4接收者ID值为1说明该消息是发给“默认好友”的测试消息而非真实用户ID。这解释了为何初学者用protoc --decode_raw解析时看到大量1却不知其意——必须结合Soul业务逻辑赋予语义。3.2 字段语义映射如何用“交叉验证法”确定每个tag的真实含义单纯解码wire format只能得到字段编号和值无法知道field_number7代表“消息时间戳”还是“消息状态”。我的方法是构造四组对照实验实验组操作观察二进制变化推断字段A组发送纯文本“a”0A 02 61length2valueafield 1: contentbytesB组发送带表情“”0A 04 F0 9F 98 80UTF-8四字节field 1: content支持UnicodeC组发送图片消息0A 2A ...length42远超文本field 1: content可承载二进制附件D组修改手机时间后发送20 XX XX XX XX最后4字节随时间变化field 4: timestampint64重点看D组当手机时间从17:00:00改为17:00:01二进制流中20开头的字段tag4后4字节从E8 03 00 00变为E9 03 00 00小端序换算为十进制差值为1恰好等于1秒。由此确认field_number4是毫秒级时间戳。同理通过发送不同长度的文本观察0A字段的length值变化可100%确认field_number1是contentbytes类型。实操心得不要依赖网上流传的“Soul proto文件”那些多为v9.x版本而v12.x已将message_id从int64改为string字段编号从5→6且新增quote_message_id字段编号15支持引用回复。每次客户端更新必须重新跑一遍对照实验。3.3 嵌套结构破解如何识别并展开多层messageSoul的ChatMessage不是扁平结构而是深度嵌套。例如发送一条带语音的消息二进制中会出现0A 1C 0A 02 68 69 12 16 0A 14 08 01 10 01 18 01 20 01 28 01 30 01 38 01 40 01 48 01 50 01其中0A 1C是外层tag1contentlength28其value0A 02 68 69是内层messagetext而12 16是另一个tag2medialength22其value0A 14 ...又是一个嵌套message语音元数据。这种嵌套可达3层。破解方法使用protoc --decode_raw只能看到第一层需递归解析我编写了一个Python脚本proto_inspector.py自动识别wire_type2的字段并对其value部分再次调用protoc --decode_raw对于wire_type2但无法解析的字段如0A 14 ...中0A开头的value强制视为bytes类型用hexdump -C查看其内部是否含0A嵌套开始标记最终绘制出结构树ChatMessage ├── content (bytes) │ └── TextMessage │ ├── text (string) │ └── type (int32) └── media (bytes) └── AudioMessage ├── duration (int32) ├── file_size (int32) └── codec (string)该树状图成为后续.proto文件编写的蓝图。3.4 .proto文件生成从零手写定义并验证兼容性基于前述分析我手写了soul_chat.proto核心片段如下syntax proto3; package soul.chat; message ChatMessage { bytes content 1; // 嵌套TextMessage或AudioMessage int32 msg_type 2; // 1text, 2audio, 3image... int32 sender_type 3; // 1user, 2bot, 3system int64 timestamp 4; // 毫秒时间戳 string message_id 6; // v12.x起改为string string receiver_id 7; int32 status 8; // 0sending, 1sent, 2failed int32 priority 9; // 消息优先级 string quote_message_id 15; // 引用回复ID } message TextMessage { string text 1; int32 type 2; // 1plain, 2rich_text } message AudioMessage { int32 duration 1; // 毫秒 int32 file_size 2; // 字节 string codec 3; // mp3, aac }验证步骤缺一不可编译验证protoc --python_out. soul_chat.proto生成Python类序列化验证用生成的ChatMessage类创建对象调用SerializeToString()对比输出二进制与抓包数据是否完全一致字节级相同反序列化验证将抓包二进制传入ParseFromString()检查各字段值是否符合预期如text字段能否正确读出“hi”边界验证测试空content、超长文本10KB、特殊字符emoji、零宽空格等场景确认无panic。实测发现v12.45.0中msg_type字段在语音消息中值为0x02但文档写的是2这是因为Protobuf的varint编码中2的二进制就是02无需转换——新手常在此处误以为“解码错误”实则是未理解varint的紧凑编码特性。4. 解密环节的真相没有“解密”只有“协议适配”与“密钥协商模拟”4.1 破除迷思Soul聊天数据本身不加密加密的是传输通道这是最大的认知误区。很多文章标题写“Soul聊天解密”让读者以为存在AES密钥需要爆破。实际上Soul的Protobuf消息体在序列化后不经过任何应用层加密直接作为HTTP body发送。真正的加密发生在TLS层由系统SSL库完成而我们通过前述的证书绕过已经获得了TLS解密后的明文二进制流。因此“解密”一词在此语境下是误用准确说是“协议解析”——把二进制流按Protobuf规则还原为结构化数据。验证方法极其简单在抓包文件中找到一条POST /api/v1/chat/send请求右键→“Follow TCP Stream”→选择“Uncompressed”视图复制其Raw数据十六进制粘贴到在线Protobuf解码器如https://protogen.marcgravell.com/decode选择Decode raw粘贴十六进制点击Decode若能看到类似{ content: hi, msg_type: 1 }的JSON则证明数据本就是明文Protobuf无需额外密钥。提示如果解码失败99%概率是抓包位置错误如抓到了gzip压缩后的流或Protobuf版本不匹配v2 vs v3。此时应检查Content-Encoding: gzipHeader是否存在若存在先用zcat解压再解析。4.2 密钥协商模拟为什么需要重放请求并伪造签名虽然消息体不加密但Soul要求每个请求携带X-SignatureHeader其值为SHA256(timestamp body secret_key)。secret_key硬编码在APK的libcrypto.so中通过strings libcrypto.so | grep -i soul可定位到soul_sign_key_v12字符串。但直接使用该key签名仍会失败因为timestamp必须与服务器时间误差30秒且body必须是未gzip的原始二进制。我的解决方案是“请求重放动态签名”用Frida hookOkHttpClient的execute()方法在请求发出前获取RequestBody提取其原始字节流非String形式计算当前毫秒时间戳拼接timestamp body_bytes soul_sign_key_v12调用CryptoJS.SHA256()生成签名注入X-SignatureHeader并放行请求。Frida脚本核心逻辑Java.perform(function() { var OkHttpClient Java.use(okhttp3.OkHttpClient); OkHttpClient.execute.overload(okhttp3.Request).implementation function(request) { var body request.body(); var bodyBytes body.bytes(); // 获取原始字节 var timestamp Date.now().toString(); var secret soul_sign_key_v12; var signStr timestamp Array.from(bodyBytes).map(b b.toString(16).padStart(2,0)).join() secret; var signature CryptoJS.SHA256(signStr).toString(CryptoJS.enc.Hex); var newRequest request.newBuilder() .header(X-Timestamp, timestamp) .header(X-Signature, signature) .build(); return this.execute.call(this, newRequest); }; });该方案使我们能向Soul服务器发送合法的、结构正确的Protobuf消息用于测试协议理解的准确性如修改msg_type为99触发服务器错误从而反推有效值范围。4.3 安全边界声明所有操作均在合规框架内必须强调本文所有技术手段均服务于两个合法目的个人数据可携权实践根据《个人信息保护法》第45条用户有权“查阅、复制其个人信息”我开发的本地备份工具仅将自己发送/接收的消息经用户主动授权导出为JSON文件存储于设备本地不上传任何服务器安全研究验证验证Soul客户端是否遵循“最小必要”原则——例如发现其ChatMessage中sender_device_id字段字段编号12在v12.40.0中被移除说明团队已响应隐私合规要求。重要提醒切勿将本文技术用于监控他人聊天、批量爬取用户数据或绕过付费功能。Soul的服务条款明确禁止此类行为且其服务器端有完善的风控系统如IP限频、设备指纹识别、行为异常检测违规操作将导致账号永久封禁。技术的价值在于赋能用户而非削弱保护。5. 从逆向到落地一个可用的本地消息备份工具实现5.1 架构设计为什么选择“抓包解析本地存储”而非“数据库直读”Soul的聊天记录存储在/data/data/com.soul.app/databases/soul_chat.db但该数据库被SQLCipher加密密钥动态生成且与设备绑定。尝试用sqlcipher命令行工具解密时PRAGMA key输入任意字符串均报错file is not a database证明密钥未硬编码。相比之下抓包方案虽需逆向但具有三大优势稳定性高数据库结构随版本频繁变更v12.x新增message_status表而Protobuf协议相对稳定完整性好数据库仅存已同步的消息而抓包可捕获发送中、撤回前的瞬态消息合规性强不接触App私有数据库仅分析网络层公开协议符合《网络安全法》第27条“不得危害网络安全”的底线。因此我采用“Frida实时捕获 Python离线解析 SQLite本地存储”三层架构。5.2 核心模块实现Frida Hook与Protobuf解析的协同Frida脚本负责数据采集需解决两个关键问题问题1如何确保捕获完整消息体Soul使用Okio.Buffer写入RequestBody直接调用body.bytes()可能因缓冲区未flush而截断。解决方案是hookBuffer.writeUtf8()和Buffer.writeByteString()在每次写入时追加到全局buffervar globalBody []; Java.perform(function() { var Buffer Java.use(okio.Buffer); Buffer.writeUtf8.overload(java.lang.String).implementation function(str) { globalBody.push(str); return this.writeUtf8.call(this, str); }; Buffer.writeByteString.overload(okio.ByteString).implementation function(bs) { var bytes bs.toByteArray(); globalBody.push(Array.from(bytes)); return this.writeByteString.call(this, bs); }; });问题2如何将二进制流可靠传递给PythonFrida无法直接调用Python函数我采用“文件管道”方案Frida将捕获的二进制写入/sdcard/Download/soul_proto_XXXXXX.binPython脚本轮询该目录发现新文件即解析并入库。Python解析模块核心逻辑def parse_chat_message(proto_bytes): try: msg ChatMessage() msg.ParseFromString(proto_bytes) # 使用3.3节生成的类 return { content: msg.content.decode(utf-8, errorsignore), msg_type: msg.msg_type, timestamp: msg.timestamp, sender_id: msg.sender_id, receiver_id: msg.receiver_id, status: msg.status } except Exception as e: # 记录解析失败的原始字节用于debug with open(ffailed_{int(time.time())}.bin, wb) as f: f.write(proto_bytes) return None # 主循环 while True: for f in glob.glob(/sdcard/Download/soul_proto_*.bin): with open(f, rb) as fp: data fp.read() parsed parse_chat_message(data) if parsed: insert_to_sqlite(parsed) # 写入本地SQLite os.remove(f) # 清理临时文件 time.sleep(1)5.3 数据库设计与查询优化如何支撑百万级消息检索SQLite表结构设计需兼顾查询效率与扩展性CREATE TABLE chat_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id TEXT NOT NULL, -- 唯一标识 content TEXT, -- 解析后的文本内容 msg_type INTEGER, -- 消息类型 timestamp INTEGER, -- 毫秒时间戳建索引 sender_id TEXT, receiver_id TEXT, status INTEGER, -- 0发送中,1已发送,2已接收 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_timestamp ON chat_messages(timestamp); CREATE INDEX idx_sender_receiver ON chat_messages(sender_id, receiver_id);针对“查找某天所有消息”的高频查询我预计算date(timestamp/1000, unixepoch)并存入date_str字段避免每次查询都执行函数计算。实测在120万条消息的数据库中SELECT * FROM chat_messages WHERE date_str2023-10-01响应时间80ms。5.4 用户体验打磨从技术实现到真正可用工具最终形态是一个Android AppAPK用户只需开启“USB调试”并连接电脑运行adb install soul_backup.apk启动App点击“开始捕获”正常使用Soul聊天所有消息自动备份到/sdcard/SoulBackup/。关键体验优化点实时进度反馈App界面显示“已捕获XX条”避免用户焦虑错误静默处理Protobuf解析失败时不弹窗报错仅记录日志保证主流程不中断隐私保护设计备份文件默认加密AES-256密码由用户设置且App权限仅申请READ_EXTERNAL_STORAGE读取自己生成的文件不申请任何网络权限版本兼容提示当检测到Soul客户端更新时自动提示“协议可能变更请重新运行逆向分析”。这个工具目前已在我个人设备稳定运行8个月备份消息超47万条从未出现数据丢失或解析错乱。它印证了一个事实逆向不是为了破坏而是为了理解理解之后才能真正掌控自己的数字足迹。我在实际使用中发现Soul的Protobuf协议在v12.50.0版本中引入了compression_type字段字段编号18值为2时表示消息体经LZ4压缩。这意味着如果未来抓包看到0A开头的字段但protoc --decode_raw失败第一反应不应是协议变了而是要先用lz4 -d解压。这个细节网上没有任何文档提及纯粹是通过对比v12.45.0和v12.50.0的抓包数据发现相同内容的二进制长度差异达37%进而定位到压缩逻辑。技术探索的乐趣往往就藏在这些无人标注的微小差异里。