ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Soul协议逆向实录:抓包、SSL Pinning绕过与Frida对抗

Soul协议逆向实录:抓包、SSL Pinning绕过与Frida对抗 这类社交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加上变长的bodyheader里包含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 rootBurpSuite做代理配合一个抓包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-Busmaps文件扫描读/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名字的事。
返回列表