1. 项目概述当金融App遇上逆向分析最近在分析一款市面上主流的金融App时我遇到了一个相当典型的“硬骨头”。这款App在启动时其网络请求在常规的抓包工具如Charles或Fiddler下完全隐身返回的数据包要么是乱码要么直接连接失败。这立刻引起了我的兴趣因为这意味着它很可能部署了业界常见的“三板斧”自定义协议封装、远程过程调用RPC架构以及最让人头疼的SSL证书绑定SSL Pinning。对于从事移动安全研究、风控策略对抗或者单纯想理解其通信机制的人来说突破这层防护是必经之路。这次实战我将带你一步步拆解这个“铁三角”从最外层的网络抓包开始深入到Hook关键函数最终理解其RPC通信的数据流转。无论你是安全研究员、逆向工程师还是对移动应用底层通信机制感兴趣的开发者这篇详尽的复盘都能为你提供一套清晰的思路和可复现的操作指南。2. 核心防护机制拆解与逆向思路在动手之前我们必须先理解对手。这款金融App的防护并非单一技术而是一个环环相扣的体系。盲目下手只会事倍功半清晰的逆向思路源于对防护机制的准确判断。2.1 防护“铁三角”深度解析首先遇到的是SSL Pinning。这是防止中间人攻击MitM的标配。App在代码中硬编码了服务端证书的公钥或哈希值。当建立TLS连接时它会比对服务器返回的证书与内置的凭证如果不匹配直接终止连接。这就是为什么你直接设置系统代理后Charles的证书不被信任导致curl 18或failed to create new cri runtime service这类网络层错误虽然后者是容器错误但原理类似都是服务连接验证失败。它把外来的抓包工具挡在了最外面。第二层是自定义加密与协议。即使你绕过了SSL Pinning抓到的网络包很可能是一堆无法直接阅读的二进制数据或乱码。这是因为App并没有使用标准的JSON/XML over HTTPS而是将业务数据可能是JSON先用自定义算法可能是AES、DES或更复杂的组合加密然后再进行Base64或直接二进制传输。有时它们甚至会自定义整个应用层协议在TCP/HTTP之上再封装一层用于区分不同的业务指令RPC调用。最核心的是RPC通信框架。这是其业务逻辑的骨架。App界面上的每一个操作比如查询余额、转账都不是直接发送一个HTTP请求而是调用客户端的一个“桩”方法。这个“桩”方法会将方法名、参数序列化通过上述加密和协议层发送给服务器。服务器执行后再将结果序列化传回。你抓到的包其实是序列化后的RPC请求/响应体而非直观的API。这解释了为什么单纯抓包看不到/api/balance这样的路径看到的可能是\x00\x01\x12\x34...这样的字节流。2.2 逆向工程总体路线图面对这三重防护我们的攻击路径是自底向上、逐层剥离突破网络层封锁首要任务是让抓包工具能“看到”流量。这意味着必须绕过SSL Pinning并将流量代理到我们的分析工具。破解应用层协议在能捕获明文TCP/HTTP数据包后分析其数据格式。是标准的HTTP Body包含加密数据还是完全自定义的二进制协议需要找到数据包的边界、头部和体部。定位关键加解密函数在能识别出加密的数据体后需要在App的Native层C/C或Java层找到执行加解密的函数。这是逆向的核心攻坚点。Hook与动态分析使用Frida、Xposed等动态插桩工具Hook住上一步找到的加解密函数、网络发送/接收函数甚至RPC的序列化/反序列化函数实时打印或修改输入输出从而理解其算法和流程。模拟与复现在完全理解协议和算法后就可以用Python等语言编写脚本模拟客户端构造合法的RPC请求实现自动化或深度分析。这条路线中Hook技术是贯穿始终的利器而RPC分析是理解业务逻辑的钥匙。3. 环境准备与基础抓包配置工欲善其事必先利其器。一个稳定、可控的分析环境是成功的一半。这里我选择在root后的Android真机上进行模拟器可能会遇到更强的反调试检测。3.1 核心工具链选型与配置抓包工具Charles或mitmproxy。Charles图形化界面友好适合初步分析mitmproxy更灵活支持脚本化适合深度流量处理。我本次以Charles为例。动态插桩框架Frida。它是目前最强大的动态Hook工具支持Java和Native层脚本用JavaScript编写非常灵活。我们将用它来Hook SSL Pinning验证函数和加解密函数。静态分析工具Jadx-GUI用于反编译Android的Java/Kotlin代码IDA Pro或Ghidra用于分析Native库.so文件。当Frida动态定位到关键函数后需要静态分析其算法逻辑。系统代理与证书在电脑上运行Charles设置好代理如8888端口。在手机上配置Wi-Fi代理指向电脑IP和Charles端口。最关键的一步在手机浏览器中访问chls.pro/ssl下载并安装Charles的根证书。对于Android 7.0以上需要将证书安装到系统信任区这通常需要root后将证书文件移动到/system/etc/security/cacerts/并设置正确权限。注意很多金融App会检测系统是否安装了额外的证书。因此更隐蔽的做法是使用iptables进行透明流量转发或者使用r0capture这类无需安装证书的抓包工具但这通常也需要root权限。3.2 初始抓包尝试与现象分析配置好代理和证书后打开目标金融App。此时Charles的请求列表很可能一片空白或者出现大量的SSL Handshake Failed。在App内进行任何操作网络请求也不会出现。这初步证实了SSL Pinning的存在。查看Charles的SSL代理设置确认“Enable SSL Proxying”已经打开并为目标App的域名配置了通配符*。如果仍然失败那么App的SSL Pinning验证逻辑已经生效它直接拒绝了Charles证书建立的连接。我们的首要战斗就此打响。4. 第一关SSL Pinning绕过实战绕过SSL Pinning的方法有很多核心思路是让App的证书验证逻辑“失效”或“被欺骗”。4.1 常见Pinning实现方式与Hook点在Android中SSL Pinning通常通过以下几种方式实现OkHttp的CertificatePinner这是最常用的方式。开发者可以方便地配置特定域名的证书指纹。自定义X509TrustManager重写checkServerTrusted方法在其中加入自定义的证书验证逻辑。Native层验证在JNI Native代码中.so库实现证书验证这更难被Java层的Hook工具检测。我们的目标是找到并Hook这些验证点。首先使用Jadx打开App搜索关键词CertificatePinner、X509TrustManager、checkServerTrusted、TrustManager。如果找到了相关的Java代码那么用Frida Hook就是最直接的方法。4.2 使用Frida Hook Java层验证逻辑假设我们在Jadx中找到了一个MyTrustManager类重写了checkServerTrusted。我们可以编写如下Frida脚本Java.perform(function() { var TrustManagerImpl Java.use(com.example.app.security.MyTrustManager); // Hook checkServerTrusted方法 TrustManagerImpl.checkServerTrusted.implementation function(chain, authType) { console.log([] Bypassing SSL Pinning in checkServerTrusted); // 什么都不做直接通过这是最粗暴的绕过 // 或者可以在这里打印证书信息用于分析 try { var certificates chain; for (var i 0; i certificates.length; i) { console.log(Cert[ i ]: certificates[i]); } } catch (e) { console.log(Error printing certs: e); } }; // 如果使用了OkHttp的CertificatePinner可以尝试Hook其verify方法 var CertificatePinner Java.use(okhttp3.CertificatePinner); if (CertificatePinner) { CertificatePinner.check.overload(java.lang.String, java.util.List).implementation function(hostname, pins) { console.log([] Bypassing OkHttp CertificatePinner for host: hostname); // 直接返回不执行任何验证 return; }; } });将脚本保存为bypass_ssl.js通过frida -U -f com.example.app -l bypass_ssl.js --no-pause命令注入到目标App。再次操作App观察Charles。如果运气好现在应该能看到HTTPS请求流量了但内容很可能还是加密的。4.3 应对Native层Pinning与反Hook策略如果上述Java层Hook无效说明验证逻辑可能在Native层。这时需要分析.so库。我们可以用Frida的Interceptor来Hook Native函数。常见的验证函数可能来自OpenSSL如SSL_CTX_set_cert_verify_callback或BoringSSL。// 这是一个示例具体函数名需要分析so库得到 Interceptor.attach(Module.findExportByName(libnative-lib.so, SSL_CTX_set_cert_verify_callback), { onEnter: function(args) { console.log([] Hooked SSL_CTX_set_cert_verify_callback); // 可以尝试修改回调函数指针或直接让验证成功 }, onLeave: function(retval) { // 确保返回一个表示成功的值例如1 retval.replace(ptr(0x1)); } });此外高级的App会检测Frida的存在如检测端口、进程名、内存特征。我们需要进行反反调试比如使用frida-server的隐藏技巧或者使用objection工具基于Frida的android sslpinning disable命令它集成了多种绕过方案。实操心得SSL Pinning绕过是猫鼠游戏。如果通用脚本无效就需要静下心来结合Jadx的字符串搜索和交叉引用定位到具体的验证类。有时直接Hook网络库的底层发送函数如OkHttpClient的newCall方法来打印明文可能比死磕证书验证更高效。5. 第二关网络协议与RPC通信分析成功抓到流量后我们进入下一阶段理解这些“乱码”究竟是什么意思。5.1 识别与解析自定义协议在Charles中查看一个请求的Raw或Hex视图。你需要观察是否有固定头部比如前几个字节是否是固定的魔数Magic Number如0xDEADBEEF。长度字段头部中是否包含一个表示整个数据包长度或Body长度的字段通常是4字节整数。序列号/命令字标识这是一个什么类型的RPC请求。Body部分这通常就是经过加密的业务数据。例如你可能看到类似这样的结构假设[4字节 总长度] [2字节 命令码] [4字节 序列号] [变长 加密的Body]你需要编写一个小脚本来解析这个结构。如果协议是基于TCP的裸协议你可能需要用到socket或asyncio来模拟如果只是封装在HTTP Body里那么直接处理Body部分即可。5.2 定位RPC序列化与反序列化入口RPC框架的核心是“桩”Stub和“编解码器”Codec。我们需要找到负责将Java对象转换为字节流的encode/serialize方法以及将字节流转换回Java对象的decode/deserialize方法。在Jadx中可以搜索以下关键词encode,decode,serialize,deserializetoByteArray,fromByteArray协议缓冲区相关的类名如ProtoBuf、MessageLite如果使用gRPC或自研Protobuf一些RPC框架的特定类如Request,Response,Invocation找到疑似类后用Frida Hook这些方法打印其输入Java对象和输出字节数组。这是理解数据格式的黄金手段。Java.perform(function() { var RpcEncoder Java.use(com.example.app.rpc.InternalEncoder); RpcEncoder.encode.implementation function(requestObj) { console.log([] RpcEncoder.encode called); var result this.encode(requestObj); // 调用原方法 console.log(Original Request Object: requestObj.toString()); console.log(Encoded Bytes (Hex): Array.from(new Uint8Array(result)).map(b b.toString(16).padStart(2, 0)).join( )); return result; }; var RpcDecoder Java.use(com.example.app.rpc.InternalDecoder); RpcDecoder.decode.implementation function(data, clazz) { console.log([] RpcDecoder.decode called); console.log(Received Bytes (Hex): Array.from(new Uint8Array(data)).map(b b.toString(16).padStart(2, 0)).join( )); var result this.decode(data, clazz); // 调用原方法 console.log(Decoded Response Object: result.toString()); return result; }; });通过对比Hook打印的明文对象和Charles抓到的加密字节流你就能确认加密发生的位置是在序列化之后还是在网络发送之前。6. 第三关定位与Hook加解密函数这是逆向中最具技术挑战性的一环。加密可能发生在Java层也可能在Native层。6.1 静态分析与动态追踪结合Java层加密在Jadx中搜索Cipher,AES,DES,RSA,encrypt,decrypt,Crypto等关键词。找到相关工具类。用Frida Hook这些类的加密/解密方法打印密钥、明文、密文。有时密钥可能是硬编码的也可能是从服务器动态获取的。Native层加密这是更常见的情况因为安全性更高。你需要用adb pull将App的lib/目录下的.so文件拉取到电脑。用IDA Pro或Ghidra打开主要的so文件通常有crypto,security,encrypt等字样。在导出函数中搜索encrypt,decrypt,EVP,AES_等符号。如果没有明显符号可以搜索字符串比如错误信息或硬编码的初始化向量IV。更有效的方法是动态追踪在Frida Hook了RPC序列化函数得到明文输出后立即在这个函数返回处下断点然后跟踪这个字节数组的“流向”。它会作为参数传递给哪个JNI函数可以使用Frida的Stalker功能进行指令级跟踪或者使用frida-trace来追踪所有对特定so库的函数调用。6.2 编写Frida脚本Hook Native加解密函数假设通过分析我们确定了加密函数在libencrypt.so中函数签名为int my_encrypt(char* input, int in_len, char* key, char* output)。Java.perform(function() { // 首先等待so库加载 var libencrypt null; var waitForLib setInterval(function() { libencrypt Module.findBaseAddress(libencrypt.so); if (libencrypt) { clearInterval(waitForLib); console.log([] libencrypt.so loaded at: libencrypt); hookEncryptFunc(); } }, 500); function hookEncryptFunc() { // 计算函数绝对地址这里假设偏移量实际需要通过IDA分析得到 // 例如IDA中看到 my_encrypt 的偏移是 0x1234 var encryptFuncPtr libencrypt.add(0x1234); Interceptor.attach(encryptFuncPtr, { onEnter: function(args) { console.log(\n[] my_encrypt called!); // args[0] 是 input (char*) // args[1] 是 in_len (int) // args[2] 是 key (char*) // args[3] 是 output (char*) var inputPtr args[0]; var inputLen args[1].toInt32(); var keyPtr args[2]; // 读取输入明文和密钥 var inputBytes inputPtr.readByteArray(inputLen); var keyBytes keyPtr.readCString(); // 假设key是字符串 console.log(Plaintext (Hex): Array.from(new Uint8Array(inputBytes)).map(b b.toString(16).padStart(2, 0)).join( )); console.log(Key: keyBytes); }, onLeave: function(retval) { // 函数执行后output缓冲区已被填充 // 我们可以读取args[3]指向的内存来获取密文 var outputPtr this.context.args[3]; // 需要知道输出长度这可能需要看函数约定或Hook调用者 // 假设我们知道加密后长度不变或者从retval获取 var outLen this.context.args[1].toInt32(); // 假设长度不变 var outputBytes outputPtr.readByteArray(outLen); console.log(Ciphertext (Hex): Array.from(new Uint8Array(outputBytes)).map(b b.toString(16).padStart(2, 0)).join( )); console.log(Return value: retval); } }); } });通过这个脚本我们就能在App运行时实时捕获到加密前的明文、使用的密钥以及加密后的密文。对比这个密文和网络抓包的数据就能验证这个函数是否正确。6.3 算法识别与密钥获取Hook到函数后下一步是识别算法。观察输入输出长度、密钥长度、以及函数内部可能调用的标准库函数如果so没有strip符号可能能看到AES_encrypt等调用。密钥的获取是关键。它可能是硬编码在so或Java代码中搜索常量字符串或字节数组。从服务器动态获取在登录或初始化时由服务器下发然后保存在内存中。需要Hook接收这个密钥的网络回调或解密函数。由设备信息和固定盐值计算得出例如MD5(IMEI “固定字符串”)。需要逆向这个计算过程。常见问题与排查Hook不到函数可能是函数名或偏移量不对。使用Module.enumerateExports(“libencrypt.so”)列出所有导出函数寻找目标。或者使用frida-trace -U -i “*encrypt*” com.example.app来追踪所有包含encrypt字样的函数。App崩溃Hook脚本可能破坏了栈平衡或寄存器状态。确保onEnter和onLeave中对参数和上下文的使用是安全的。对于复杂的函数初期可以只onEnter打印不修改任何东西。反调试导致Hook失效App可能检测到Frida或调试器而主动退出。需要先进行反反调试如使用frida的-f参数 spawn 新进程或者使用objection的android antiroot disable等命令组合。7. 完整流程串联与数据复现当我们将SSL Pinning绕过、协议解析、RPC Hook、加解密Hook全部打通后就能看到一个完整的数据流。用户操作在App点击“查询余额”。RPC调用Java层生成一个BalanceQueryRequest对象。序列化RPC框架将其序列化为字节流明文1。被我们的RpcEncoder Hook捕获加密序列化后的字节流被送入Native加密函数。被我们的my_encrypt Hook捕获得到密钥和明文1协议封装加密后的密文被加上协议头长度、命令码等组成完整的网络包。网络发送通过已绕过Pinning的TLS连接发送。在Charles中看到最终的网络数据包服务器响应返回一个网络包。协议解包客户端剥离协议头得到加密的响应体。解密Native解密函数处理响应体得到明文字节流。Hook解密函数得到明文2反序列化RPC框架将字节流反序列化为BalanceQueryResponse对象。被RpcDecoder Hook捕获UI更新将响应对象中的数据展示在界面上。至此我们完成了从用户操作到网络包再回到用户界面的全链路逆向分析。你可以根据Hook得到的算法和密钥用Python的cryptography或pycryptodome库重新实现加密解密从而能够伪造客户端发送任意请求或者解密服务器的任意响应。8. 进阶对抗与思考金融App的防护远不止于此。在实战中你可能还会遇到代码混淆与加固Java层被混淆得面目全非Native层被VMP或OLLVM混淆。这需要更强的静态分析能力和耐心动态Hook的定位作用在此刻更为关键。双向认证与设备指纹不仅客户端验证服务器服务器也严格验证客户端。App会收集大量的设备指纹设备ID、传感器数据、安装列表等并参与签名伪造请求难度大增。请求链路签名对完整的请求参数、时间戳、流水号等进行签名任何篡改都会导致服务器拒绝。需要逆向签名算法而密钥可能是每次会话动态协商的。动态代码加载核心逻辑在运行时从网络下载增加了静态分析的难度。逆向分析是一场与防御者持续博弈的过程。它要求分析师不仅掌握工具使用更要有清晰的逻辑思维、系统的调试方法和极大的耐心。每一次成功的分析都是对应用安全机制的一次深刻理解。对于开发者而言了解这些攻击路径也能更好地设计自身应用的安全防护体系在用户体验和安全强度之间找到平衡。