Soul协议逆向实录:抓包、SSL Pinning绕过与Frida对抗
2026/9/16 22:09:39 网站建设 项目流程

这类社交App的协议分析,我一直觉得是最考验功力的方向之一:它的通信链路长、加密环节多、客户端还有一套自己的风控逻辑,你用常规抓包工具根本看不到东西,用Frida刚挂上去App就闪退。Soul就是非常典型的样本——表面看是个“灵魂社交”产品,实际在客户端安全上花了不少功夫,走长连接、做证书固定、核心算法下沉到so层,还把Frida检测做得很用力。这篇文章我会把我对Soul聊天协议加密和Frida检测机制的完整研究过程拆开讲,从抓包翻车开始,到Java层定位加密函数,再到native层还原算法,最后聊清楚检测到底发生在哪些环节、对抗思路又该怎么落。如果你是做安卓逆向、App安全评估或者对协议加密感兴趣的开发者,这篇应该能帮你省掉不少弯路。

1. 抓包视角下的Soul协议全貌:为什么Charles和Burp直接哑火

1.1 先搞清楚它走的是哪条路

拿Soul开抓包工具之前,多数人会默认它和普通App一样,请求都走HTTPS,抓到就是JSON。实际不是这样。Soul的聊天消息走的是自建长连接通道,不是WebSocket也不是标准XMPP,更像是一套自定义二进制协议封装在TLS里面。你用Charles挂代理,能看到登录、个人资料这类HTTP接口的CONNECT请求,但真正核心的聊天消息、在线状态、心跳这些,全部在同一条TCP长连接里裸奔。

整个通信链路大概是这样的:App在启动后建立一个长连接,服务端会下发一个地址,客户端连上去之后先做握手,握手里带着设备信息、token、协议版本号;握手成功之后,后面所有消息都是二进制帧。帧结构通常是一个固定长度的header加上变长的body,header里包含magic、version、cmdId、seq、bodyLen这些字段,body再根据cmdId走不同的加密和序列化逻辑。这种设计让常规抓包工具基本失效,因为就算你把TLS解开,看到的也是一堆没有可读性的二进制流,根本分不清哪段是消息内容哪段是控制指令。

1.2 SSL Pinning把解密这条路也堵死了

Soul在TLS层做了证书校验,而且不是只信系统CA那么简单。它做了证书固定,把服务端证书的公钥或者证书链信息直接编译进客户端里,运行时只认内置的那份证书。你用Burp或者Charles抓包,第一步就得往手机装CA证书,普通App装上之后就能解HTTPS了,但Soul会直接拒绝这个证书,TLS握手阶段就被掐断。

这里的细节值得说一下:有些App只做系统CA校验,你把证书装进系统证书目录就能骗过去;但Soul这类会把证书校验写进native层,Java层Hook都不好使。我试过把Burp的CA证书用Magisk模块塞进系统证书目录,结果一样失败,因为它的TrustManager内部还会校验证书指纹,你替换了CA它也认不出服务端证书的指纹。这就是为什么你要做协议分析,第一个要解决的问题不是解密聊天内容,而是怎么让客户端的证书校验闭嘴。

1.3 我实际抓包时踩到的第一个坑

我的测试环境是Pixel真机加Magisk root,BurpSuite做代理,配合一个抓包App抓长连接层的流量。实际操作下来:HTTP接口的请求和响应能正常看到,但聊天消息在代理层只能看到TLS密文,就算关掉加密也看不到内容,因为消息body本身还有一层自定义加密。这也是很多新手误判的地方——以为过了证书校验就等于拿到明文,实际Soul在应用层对消息payload又做了一层加密,这层加密和TLS无关,是协议自身的加密。所以要完整还原协议,至少得解决三层问题:第一层TLS证书校验、第二层长连接帧结构解析、第三层消息payload的加解密。Frida在这个链条里的价值,就是从第一层开始一层层把这些“锁”打开。

2. 用Frida扒掉SSL Pinning这件外衣

2.1 为什么这个场景非Frida不可

面对证书固定,传统方案是反编译改smali,把校验逻辑删掉再重打包。但Soul有完整的签名校验和完整性校验,改了代码之后App直接拒绝启动,甚至可能在启动阶段就检查包名和签名的对应关系。这种情况下Xposed也可以做一些事情,但要刷框架、重启、开模块,而且Xposed的某些特征比Frida更容易被检测到。Frida的优势是动态插桩,不用重打包,通过注入的方式在进程运行时修改逻辑,整个分析过程的迭代速度会快很多。

Frida的另外一个好处是它既能做Java层Hook,又能做native层Hook,还能直接读写进程内存。你在分析中经常会遇到这种情况:刚在Java层找到某个关键函数,追了两步发现它调到native方法,如果是Xposed就只能在Java层看着干瞪眼,Frida可以直接跟到so层去。我一般直接用frida -U -f 包名 --no-pause启动App,这样进程一创建就会被suspend住,等Frida脚本注入完成后再恢复运行,保证在App早期代码执行之前就把Hook装好。

2.2 证书校验的两种Hook打法

第一种是针对Java层标准的SSL校验逻辑。很多App的证书固定是基于OkHttp的CertificatePinner,或者自己实现了X509TrustManager。这类的Hook前面做分析时已经非常成熟,核心思路就是找到SSLContext.init方法,把TrustManager换掉,或者找到checkServerTrusted方法直接return。下面这段是我常用的一段基础代码,用来确认Soul的Java层有没有暴露这些点:

Java.perform(function () { var okHttpPinner = Java.use('okhttp3.CertificatePinner'); okHttpPinner.check.overload('java.lang.String', 'java.util.List').implementation = function (hostname, peerCerts) { console.log('Bypassing CertificatePinner for ' + hostname); }; var trustManagerImpl = Java.use('com.android.org.conscrypt.TrustManagerImpl'); trustManagerImpl.verifyChain.overload('[Ljava.security.cert.X509Certificate;', 'java.lang.String', 'java.util.List', 'boolean', 'boolean').implementation = function (chain, host, authType, unused, chainIsTrusted) { return this.verifyChain(chain, host, authType, unused, true); }; });

第二种情况要麻烦一些:Frida脚本跑起来之后什么都没报错,抓包发现TLS还是握手失败。这通常说明证书校验已经不在Java层了,而是在某个so文件里自己实现了一套校验。碰到这种情况,我的思路是先用Frida枚举进程加载的native库,看有没有可疑的so,比如叫libcore.so、libsec.so、libkwsg之类的;再从JNI函数列表里搜和ssl、verify、trust相关的导出函数。Soul走的几乎就是这条路,Java层的OkHttp和TrustManager只是表象,真正的校验逻辑在so里。

2.3 我在做SSL Unpinning时踩过的坑

最大的坑是“版本差异”。Soul的某个版本你Hook OkHttp的CertificatePinner就行了,换一个版本它可能已经把OkHttp整个混淆掉,类名和方法名全变了,Java.use('okhttp3.CertificatePinner')直接抛ClassNotFoundException。这种时候不能死磕Java层,要从调用栈入手:先用Frida的Java.perform拿到SSLContext.getInstance返回的Context,打印所有已知的TrustManager,再逐个Hook实现类。

另一个坑是Frida挂上之后,App不进主页、一直转圈。这不是Hook本身写错了,而是Soul做了Frida检测,检测到注入后故意不让功能正常走。所以做SSL Unpinning的时候我发现一个现象:Hook代码本身没有报错,但App行为异常。这时候就要转到后面会详细说的检测对抗部分,先解决Frida反调试,再回过头来看证书校验。

3. 协议加密的逆向主战场:从Java层追到native层

3.1 拿到密文之后的三个判断标准

SSL Pinning解决之后,抓包还是看到一堆二进制。这时候不要着急去翻smali,先把抓到的密文数据拉到本地做几个基础判断:第一,看数据长度是不是固定分块。如果密文是按16字节、32字节这种倍数出现的,大概率是对称加密,而且多半是AES的CBC或ECB模式,因为AES的分组长度就是16字节,加密后会自动补齐padding。第二,看数据开头有没有重复的magic头或者版本号。Soul这种自研协议一般会在帧头保留固定字段,哪怕body加密了,cmdId和seq这些控制字段也可能是明文。第三,看不同消息之间的密文重复度。如果两条内容相似的消息密文完全不同,说明加密里带了时间戳、随机数或者序列号之类的扰动因素。

我当时从抓包里拿到的消息帧结构类似这样一个简化格式:

0x00-0x03 magic 0x534F554C 0x04-0x05 version 0x06-0x09 cmdId 0x0A-0x0D seq 0x0E-0x11 bodyLen 0x12-0x?? encryptedBody

这种设计很典型:头部保留控制信息,body做整体加密,这样服务端收到帧之后先解析头部决定路由,再按会话密钥解密body。这意味着你在协议还原阶段可以先把帧头当作文本来解析,聚焦精力攻body加密。

3.2 Java层Hook定位加密入口

判断出是对称加密之后,我先在Java层找一个“指路牌”:Hook所有加密相关的标准类,看哪个类被实际调用。最常出现在这个层面的有javax.crypto.Cipher、javax.crypto.Mac、java.security.MessageDigest、javax.crypto.spec.SecretKeySpec。代码可以这样写:

Java.perform(function () { var cipher = Java.use('javax.crypto.Cipher'); cipher.getOutputSize.implementation = function (len) { var result = this.getOutputSize(len); console.log('Cipher.getOutputSize from:', Java.use('android.util.Log').getStackTraceString(Java.use('java.lang.Throwable').$new())); return result; }; cipher.init.overload('int', 'java.security.Key', 'java.security.spec.AlgorithmParameterSpec').implementation = function (opmode, key, params) { if (opmode === 1) { console.log('Cipher.init ENCRYPT mode, key algo:', key.getAlgorithm()); } else if (opmode === 2) { console.log('Cipher.init DECRYPT mode, key algo:', key.getAlgorithm()); } return this.init(opmode, key, params); }; });

Soul的Java层确实会调Cipher,但那不是消息加密的主链路,更像是某个辅助加密模块。真正聊天消息的加密入口在native层,Java层只能看到一个native方法,比如sendMessage(byte[])这样的签名,进去之后整个加密过程都在so里,你看不到AES初始化的任何Java调用。

这个时候有个技巧:用Java.choose或者反射拿到该方法的Method对象,再用Frida的getMethodName和getDeclaringClass打印出来,看它在哪个类里声明,然后顺藤摸瓜用Frida的enumerateLoadedClasses把类名带可疑关键词全部扫一遍。这一步往往能锁定真正的加密服务类,比如长连接管理类、消息编解码类。

3.3 native层的一场硬仗

Java层追到native方法后就进入另一个战场。我的常规做法是先用frida-trace对目标so做一次导出函数追踪,看看在发送聊天消息时哪些native函数被连续调用,通过调用顺序和参数特征判断哪一个是加密函数,哪一个是序列化函数,哪一个是封包函数。命令大概是:

frida-trace -U -i "libsec.so!*encrypt*" 包名 frida-trace -U -i "libsec.so!*crypt*" 包名

不过frida-trace的批量跟踪在真实场景里很吵,Soul的so里可能同一时间跑着大量无关线程,输出根本看不过来。更可控的方式是先用脚本枚举目标so的导出函数,把函数名和地址打出来,然后针对几个可疑函数下断点,通过打印参数buffer的前16字节来做判断:

var module = Process.findModuleByName("libsec.so"); if (module) { module.enumerateExports().forEach(function (exp) { if (/encrypt|decrypt|crypt|serialize|pack/i.test(exp.name)) { console.log(exp.name, exp.address); } }); }

定位到具体函数之后,看参数和返回值里的byte数组,如果返回值的长度和抓包得到的body长度一致,那基本就是加密函数。接下来要做的是静态分析,把so拖进IDA或者Ghidra,找到这个导出函数对应的实现,看它内部怎么生成密钥、怎么调用底层加密原语。Soul这类产品的so一般不会直接用OpenSSL的公开符号,而是把算法内联或魔改过,你分析的时候会看到一些不标准的常量表或者奇怪的初始向量。

3.4 从算法还原到密钥生成

还原算法的完整逻辑大概分四步:第一步确定加密算法,这一步通过AES加密结果长度和分组特征可以猜出;第二步确定工作模式,从加密后body前16字节是否有随机IV来判断,CBC模式通常会把IV拼在密文前面;第三步确定密钥来源,这里是最关键的,密钥可能在so里硬编码,更常见的是根据设备信息加一个固定salt算出来的;第四步是确定padding规则,不同实现可能用PKCS5、PKCS7或者干脆不填充。

我遇到的情况是:密钥不是单一硬编码,而是“固定字符串 + 设备id + 时间戳”组合后经过hash生成的,这导致你拿到设备A的key,换一个设备就不适用。但这种设计也有弱点:它的hash算法和参与要素都是固定的,你在Frida里直接调用App自己的native函数就能复现同样的密钥生成过程,不需要自己从零写一遍密码学实现。这也是动态分析的便利之处——你不需要完全逆向出so里的每行汇编,只要能让App帮你算出中间结果,你就能把这些函数作为“预言机”,用来验证自己的Python原型是否正确。

4. Frida检测与反检测:为什么hook到一半App就闪退

4.1 Frida的哪些特征暴露了你的存在

分析Soul过程中最让人头疼的不是算法本身,而是Frida检测。明明刚才还hook得好好的,一旦某个so被加载,App直接闪退,或者所有网络请求停摆。要绕过去,首先得知道它在检测什么。

从我的复现和排查结果看,它的检测维度可以列成一张表:

检测维度具体表现被检测原因
进程扫描遍历系统进程,找frida-server、gum-js-loop等进程名Frida默认的server进程特征明显
端口探测连接127.0.0.1的27042端口,看是否有回包Frida默认控制端口固定
D-Bus协议探测向特定端口发送D-Bus认证字符串,看返回是否符合特征Frida的通信基于D-Bus
maps文件扫描读/proc/self/maps,搜索frida、gadget、gum-js-loop等关键词Frida注入后so内存映射里会留痕迹
内存特征扫描搜索特定字节串,比如gum-js-loop、frida-server相关特征静态字符串检测的升级版
ptrace检测调用ptrace检查自身是否被调试,或者主动ptrace自己Frida注入过程可能触发调试器检测
线程检测查找命名异常的线程,如gmain、gum-js-loop线程Frida运行时会创建自己的线程

Soul对这些特征的检测不是单一使用,而是组合式、分层式。启动阶段先跑一遍基础扫描,加载so之后再跑一遍native层扫描,你在Frida控制台看到App闪退,往往是因为踩中了第三层或第四层的检测。

4.2 闪退背后的执行链路

Frida注入到目标进程之后,会在目标进程里创建一个运行环境,包括加载自己的一些so文件、创建线程、建立socket通信。这些动作在进程内部都是有“痕迹”的,Soul的反调试模块就是在这些痕迹上做文章。

它的检测线程启动得比较早,通常是在main函数执行前后,甚至在Application的attachBaseContext阶段就开始跑。这个环节有一个很重要的现象:有时候你的Frida脚本已经成功执行了一部分Hook,但App仍然闪退,原因是检测线程和你的业务Hook是并行的,检测它跑它的,业务Hook被恢复之后一执行就撞上检测点。这也是为什么后期我习惯在脚本入口处先把可能执行检测的so函数全部Hook掉,再让App继续跑,否则你永远处于“边跑边杀”的状态。

4.3 为什么单纯改frida-server文件名并不总是有效

很多新手第一反应是:你检测frida-server进程名,那我改名叫asdf不就行了?实测下来,这种方法在最初期有效,改完名字和默认端口之后确实能躲过第一层进程扫描。但Soul这种等级会往前多走一步:它扫描内存映射文件,查的是有没有gum-js-loop、frida-agent等字符串,这些字符串在server二进制改名后不会消失,因为注入的agent库里的字符串是编译在代码段里的,进程名改了照样存在。

还有一种情况是它检测的是D-Bus协议特征。Frida的客户端和server之间走D-Bus协议,通信时会发送固定的认证字符串,这个字符串和进程名没关系,你可以用非默认端口,但协议特征改不了,除非自己编译定制版Frida。所以到后面我判断一个检测能不能绕,依据不是“改了什么名字”,而是“它检测的是哪一层的特征”。

4.4 从注入时机和注入方式上想办法

基于上面的分析,绕过思路就清楚了,要么让检测代码找不到特征,要么让检测代码没机会执行。

第一个思路是延迟注入。Soul的检测集中在启动早期,如果你用frida -U -f启动,注入发生在进程创建之初,检测线程可能还没跑,但你的agent也暴露在早期环境里,容易被早期扫描抓包。换成attach模式,先让App正常启动,全部初始化完成之后再附加,这样能避开大部分启动期检测。缺点是App的一些加密逻辑如果在启动阶段就已经执行完毕,你会错过部分调用点,需要用别的办法重新触发。

第二个思路是替换注入方式。不用frida-server,改成把frida-gadget以so的形式放进App的lib目录,配置成监听模式等待连接。这样做的好处是进程里看不到frida-server进程,端口扫描也失效,gadget在进程里的形态和普通so更接近。缺点是gadget仍然会有内存特征,需要配合内存特征清理。

第三个思路是直接Hook检测函数。在搞清楚了检测函数具体是哪些之后,用Frida把检测结果篡改掉。比如它检测到异常后调用了System.exit(),你把exit hook掉,或者把检测结果bool值改成false。这个方法比较直接,但前提是你能先绕过前面的静态扫描把Frida脚本跑起来,属于“先活下来再反杀”的思路。

5. 绕过检测的对抗思路:从检测方视角看怎么破

5.1 先分清“静态特征扫描”和“动态行为检测”

谈绕过之前,我建议先把检测类型分清楚。静态特征扫描就像小区门口的保安看身份证,你只要照片和证件一致就能过;动态行为检测更像便衣跟着你走一段,看你走路姿势是不是正常人。Soul的检测两者都有,而且它在native层的检测策略里动态行为的比重越来越大。

静态特征扫描的绕过方法我们刚才说了:改名字、改端口、用gadget。动态行为检测的绕过方法则完全不同,它看的是你的行为特征,比如某个线程的执行时间异常、某些系统调用过于频繁、ptrace状态异常、某个地址段在运行时有奇怪的读写行为。这些很难通过改配置解决,只能通过更精细的Hook来“隐瞒”自己。

5.2 对抗手段和实际效果对比

我整理了自己在实际分析中尝试过的手段和效果:

对抗手段绕过哪一层实际效果代价
frida-server改名进程名扫描对早期版本有效,对新版几乎无效
修改默认端口端口探测能躲过部分,但D-Bus协议特征还在
使用frida-gadget替代server进程名、端口扫描有效,能躲过大部分第一层检测
延迟attach启动期早期扫描有效,但会错过启动阶段的调用
Hook检测函数本身所有检测点有效,但需要先跑通前面的注入
定制版Frida/修改源码编译内存字符串特征、D-Bus特征最彻底,但维护成本高

我的个人建议是,在分析Soul这类有成熟反调试机制的目标时,不要一上来就选定制版Frida,那是最后的手段。先用gadget加延迟attach的组合,把App启动起来观察哪一层还在报警,再做定点Hook,性价比最高。

5.3 客户端不闪退了,还有服务端行为检测

很多人在客户端成功绕过检测后,以为万事大吉,结果发现发出去的请求和正常App不一样的响应,甚至过一会儿账号就被风控。这说明Soul的服务端也不是摆设,它会对设备指纹、心跳频率、消息序列号、请求时间特征做建模。你本地把所有加密都解开了、把所有逻辑都调通了,但如果是用脚本批量发消息,每条消息之间的时间间隔、seq的增长模式、自定义UA的格式,都会暴露这不是人工操作。

服务端行为检测是更高级的一环,它的核心是设备指纹,包含Android ID、IMEI、MAC、传感器列表、build属性和各种硬件信息,综合起来生成一个特征码。如果你用模拟器,或者把真实设备的某个硬件信息改掉了,服务端就会把你的请求标记为异常。这意味着协议逆向不仅要搞定客户端加密,还要完整模拟设备环境,否则只在算法层面通过验证是跑不通全链路的。当然,从安全研究的角度看,这部分本质上是在论证:光靠客户端加密不够,服务端行为风控才是最后一道防线。

5.4 我总结的“绕检测三步法”

我自己在实际分析中已经形成了一套相对固定的流程,第一步是脱敏启动:用gadget模式或者延迟attach,先让App用最干净的方式启动起来,不做任何Hook,只观察日志和网络流量;第二步是特征探测,用Frida的Process.enumerateModules和Process.enumerateThreads把所有模块和线程拉出来,对照已知检测特征逐项扫描;第三步是渐进式Hook,一次只Hook一个点,观察是否触发闪退,通过二分法锁定具体的检测代码。这套方法虽然慢,但稳定,遇到任何有反调试的App都能用同样的思路推过去。

6. 把协议还原结果工程化:从动态分析到可复现的代码

6.1 先把参数清单整理清楚

协议分析做到最后,一定会产出一套参数清单,这也是工程化的第一步。至少要包括:加密算法(AES/ChaCha20/自定义异或)、工作模式(CBC/ECB/GCM)、IV的生成规则、密钥的生成规则、Padding方式、帧头字段顺序、序列化方式(protobuf/JSON/自定义二进制)、消息命令字表和seq递增规则。这些信息如果只存在笔记里很容易丢,建议直接整理成一份结构化的JSON配置文件。

以我遇到的Soul风格协议为例,简化版配置是这样:

{ "encrypt": { "algorithm": "AES", "mode": "CBC", "padding": "PKCS7", "iv": "random_16_bytes_prepended", "key_source": "sha256(device_id + salt + timestamp)" }, "frame": { "magic": "0x534F554C", "version_offset": 4, "cmd_offset": 6, "seq_offset": 10, "body_len_offset": 14 } }

这套配置的价值在于:你可以把协议的解析、加解密、帧组包完全独立成模块,不依赖任何App内部的类结构,后续如果你要复现某个算法或做协议层面的自动化验证,直接调这个模块就行。

6.2 用Python把加解密算法重写一遍

拿到参数和算法细节之后,我的习惯是用Python快速实现一套和App等价的加解密逻辑,然后用从抓包里拿到的真实数据做验证。验证最重要的一步是“解密已知数据”:从Frida里dump出一段明文和对应的密文,再拿自己的Python代码把密文解一遍,看能不能得到一样的明文,如果能,说明算法和密钥都对了;如果不对,就回过去检查密钥生成规则或者IV位置。

这里有一个很有用的技巧:直接在Frida脚本里调用App自己的native加密函数,把当前设备的密钥、IV和一段明文的加密结果打印出来,作为测试向量。然后把这段结果拿到Python里当基准数据,你的独立实现只要能和它对上,就说明算法链路是完整的。这个方法比单纯看汇编猜算法要快得多,因为App本身就是最好的参考实现。

6.3 工程化落地时的几个坑

第一个坑是动态Key问题。如果密钥是每次启动时临时生成的,你当天调试出的密钥,隔天就失效了。解决方案是写一个自动化的Frida脚本,在启动时Hook密钥生成函数,把密钥实时导出到本地文件,Python模块每次运行前先读取最新的密钥。

第二个坑是序列化顺序。消息加密前要先序列化,Soul这类App对字段的顺序极其敏感,你少一个字段或者顺序不对,解密端直接抛错。这个问题从外部很难查,只能在抓包数据里多找几组不同cmdId的消息,挨个比对序列化布局。

第三个坑是seq和心跳机制。长连接协议一般都有心跳包和消息序列号,你在本地重放消息时如果seq断档或者心跳超时,服务端会断开连接甚至拉黑账号。工程化之后一定要把心跳线程也实现出来,别只把加密算法写对就以为完工了。

我自己在这一步最深的一个体会是:逆向一个协议,最繁琐的不是找加密函数也不是绕过检测,而是把动态分析得到的零散信息沉淀成可运行的代码。这一步需要耐心,要把每一个字段、每一个参数都验证一遍,任何一个想当然都会在下一次运行时被服务端教育。但只要你按部就班走完这套“环境准备→抓包→绕过证书校验→定位加密函数→分析native算法→绕过检测→工程化验证”的流程,再遇到同类型的社交App协议,基本就是换一个App名字的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询