金融App加密通信逆向验证:Frida实战SM4加解密链路
1. 这不是“破解”,而是金融App通信安全机制的逆向验证实践
很多人看到标题里的“破解”两个字,第一反应是“这不合规?”——我的第一反应也一样。去年底帮一家持牌第三方支付机构做SDK集成兼容性评估时,对方技术负责人递来一份需求文档,里面明确写着:“需对当前合作银行App的H5容器通信链路做一次端到端加密机制验证,确认其是否符合《金融行业移动应用安全规范》JR/T 0092-2023中第6.3.2条关于‘客户端与服务端间敏感数据传输应采用国密SM4算法并绑定设备指纹’的要求。”
我们没碰App的业务逻辑,没绕过登录,更没触碰任何用户账户数据。整个过程只聚焦在通信层加解密行为的可观测性验证上:它用的是什么算法?密钥怎么生成?IV如何传递?加密后数据是否被二次Base64编码?这些信息本就该在合规设计中可审计、可验证。Frida在这里不是“攻击武器”,而是像示波器之于电路板——你得先看到信号波形,才能判断滤波电容有没有虚焊。
关键词“Frida”“金融App”“加密通信”背后的真实诉求,其实是三类人共同关心的问题:
- 安全工程师需要确认生产环境App是否真正在用国密算法,而不是开发阶段用AES、上线切回弱加密;
- 渗透测试人员要厘清加密边界——哪些字段被加密(仅token?还是整个请求体?)、哪些环节可hook(OkHttp拦截器?还是底层JNI函数?);
- 合规审计员得拿到可复现、可留痕的技术证据,证明加密实现与白皮书描述一致。
这篇文章记录的是我在某股份制银行App(v5.8.2,Android 12,targetSdk 31)上完成的完整验证链路。不讲原理空话,不堆命令参数,从第一个Java.perform执行失败开始,到最终抓到SM4解密前的明文payload结束,每一步都带着当时掉进的坑、填坑的依据、以及为什么非得这么填。如果你正面临类似任务,这篇就是你明天早上打开电脑后可以直接跟着敲的实操日志。
2. 为什么必须用Frida?静态分析在这里为何失效
2.1 静态反编译的三大断点:混淆、动态加载、JNI跳转
拿到APK后,我习惯性先用JADX-GUI打开。但这次刚展开com.xxx.bank.network包,就发现所有类名都是a,b,c,方法名全是a(),b(int),字符串全被抽取到a.a.b.c这样的嵌套常量类里。这不是ProGuard简单混淆,而是用了腾讯乐固的深度混淆+字符串加密方案——JADX能解析出调用栈,但无法还原出a().b().c()实际对应的是Encryptor.getInstance().encrypt(payload)。
更麻烦的是网络请求入口。静态扫描发现OkHttpClient的构建被拆成三处:
NetworkConfigLoader从assets读取JSON配置;SecurityManager根据配置动态选择OkHttpClient.Builder的拦截器;- 最终
ApiService通过反射调用Builder.build()。
这意味着:你无法在静态代码里定位到“加密发生在哪里”。因为加密逻辑可能藏在某个运行时才加载的Dex文件里(该App启用了MultiDex + 动态模块化),也可能在so库的JNI层(libcrypto.so里有大量未导出符号)。我试过用objdump -T libcrypto.so | grep encrypt,结果返回27个模糊匹配,其中19个是OpenSSL内部函数,根本分不清哪个是业务加密入口。
提示:当静态分析卡在“找不到加密函数调用点”时,别急着换工具,先问自己三个问题:① 该App是否启用了R8完整模式(而非ProGuard)?② 网络层是否使用了自定义HTTP Client(如基于Netty或自研协议栈)?③ 是否存在Java层仅做密钥调度、实际加解密由so完成的情况?这三个问题的答案,直接决定你该hook Java层还是Native层。
2.2 Frida的不可替代性:运行时上下文还原能力
Frida的价值,恰恰在于它不依赖源码,而依赖进程运行时的状态快照。比如,当App发起一个转账请求时:
- 我们可以hook
OkHttpClient.newCall(),拿到原始Request对象,看它携带的header和body; - 再hook
RealCall.getResponseWithInterceptorChain(),观察经过所有拦截器后的Request; - 如果发现body从明文变成了乱码,说明加密发生在某个拦截器里;
- 此时再用
Java.choose("com.xxx.bank.interceptor.EncryptInterceptor", {...})精准定位到那个类,甚至直接dump它的encrypt()方法字节码。
这种“请求发起→中间态捕获→结果比对”的链路,是静态分析永远做不到的。它不需要你知道加密函数叫什么,只需要你知道“这个请求发出去之前,数据一定被改过”。就像修车师傅听发动机异响,他不需要看懂ECU源码,但能根据声音频率判断是气门间隙问题还是正时皮带松动。
我实测对比过:用JADX静态分析耗时4.5小时,最终只确认了“加密逻辑存在”,但无法确定算法类型;用Frida脚本从启动到捕获首个加密请求,全程17分钟,且直接拿到了SM4的密钥派生参数(PBKDF2迭代次数=10000,salt=设备IMEI前8位+App版本号MD5)。
2.3 为什么不用Xposed或Magisk Module?
有人会问:Xposed也能hook啊?确实能,但它有硬伤:
- Xposed框架本身会修改Zygote进程,触发部分金融App的Root/框架检测(该App调用
/system/bin/getprop ro.debuggable和/proc/self/maps扫描xposed相关so); - Magisk Module需要重启生效,而金融App普遍有“冷启动检测”——首次启动时校验DEX签名,若发现系统分区被修改,直接闪退并上报风控系统;
- Frida的
frida-trace和frida-ps支持热加载脚本,无需重启App,且注入方式更隐蔽(通过ptrace附加到目标进程,不修改内存段属性)。
更重要的是,Frida的JavaScript API对加密场景做了专门优化。比如Memory.readByteArray()能直接读取JNI层malloc分配的内存块,Java.use("javax.crypto.Cipher").getInstance.overload("java.lang.String")能精准捕获Cipher初始化时的算法字符串——这些能力在Xposed里需要写大量JNI胶水代码才能实现。
3. Frida环境搭建避坑指南:从adb失败到root权限获取
3.1 设备准备:为什么必须用真实机而非模拟器
该App的加固方案包含硬件特征绑定:启动时会读取/dev/block/bootdevice/by-name/system的SHA256值、/proc/cpuinfo中的CPU Serial、以及getprop ro.boot.serialno。模拟器的这些值要么为空,要么是固定字符串(如Genymotion的serialno恒为0123456789ABCDEF),导致App在Application.attach()阶段就抛出SecurityException并退出。
我试过三台设备:
- 小米12(MIUI 14,已解锁Bootloader):Frida-server能正常运行,但App启动后立即检测到
/proc/self/status中的CapEff字段含cap_sys_ptrace,触发反调试; - 一加9(OxygenOS 13,未解锁):
adb root失败,adb shell无root权限,Frida-server无法写入/data/local/tmp; - 华为Mate 40 Pro(EMUI 12,已解锁):唯一成功设备。关键在于华为的
adb root实现不依赖adbd的root模式,而是通过hdc协议桥接,Frida-server以普通用户权限运行即可完成ptrace注入。
注意:不要迷信“已root”标签。很多所谓“一键root”工具只是挂载了su二进制,但
adbd进程仍以shell用户运行。验证方法很简单:执行adb shell "id",输出必须是uid=0(root) gid=0(root),否则Frida-server会因权限不足无法注入。
3.2 Frida-server部署全流程(含华为设备特例)
步骤必须严格按顺序执行,漏一步就会卡在Failed to spawn: unable to locate suitable process:
- 下载匹配版本:该App targetSdk 31,对应Android 12,需用Frida 15.2.2(2022年10月发布)。去 官方GitHub Releases 下载
frida-server-15.2.2-android-arm64.xz,解压得到frida-server二进制; - 重命名并推送:
adb push frida-server /data/local/tmp/,然后adb shell "chmod 755 /data/local/tmp/frida-server"; - 华为特例处理:EMUI 12默认禁用
ptrace,需先执行adb shell "echo 0 > /proc/sys/kernel/yama/ptrace_scope"(需root权限),否则Frida会报Operation not permitted; - 后台运行server:
adb shell "/data/local/tmp/frida-server &",此时adb shell ps | grep frida应显示进程; - 验证连接:
frida-ps -U,若返回空列表,执行adb forward tcp:27042 tcp:27042和adb forward tcp:27043 tcp:27043,再试一次。
我踩过的最大坑是第3步——华为设备必须在frida-server启动前关闭ptrace_scope,且该设置重启失效。后来写了个一键脚本,每次启动前自动执行:
#!/bin/bash adb shell "su -c 'echo 0 > /proc/sys/kernel/yama/ptrace_scope'" adb shell "/data/local/tmp/frida-server &" sleep 2 frida-ps -U3.3 Python环境配置:为什么不用frida-tools而选纯Python API
frida-tools(如frida-trace)虽方便,但在金融App场景下有两个致命缺陷:
- 它的
frida-trace -i "*encrypt*"会hook所有含encrypt的函数,包括android.util.Base64.encode()这种高频调用,导致App卡死; - 它无法处理JNI层hook,而该App的SM4加密实际在
libsm4.so的Java_com_xxx_crypto_Sm4_encrypt函数里。
因此我直接用Python Frida API写脚本,核心优势在于:
- 可精确控制hook时机(如只在
onCreate()后hook); - 能在
onMessage回调里做条件过滤(如只打印request.url.contains("transfer")的加密数据); - 支持
Module.load()加载so模块,直接调用Module.findExportByName("libsm4.so", "Java_com_xxx_crypto_Sm4_encrypt")。
安装命令:pip install frida==15.2.2(版本必须与server一致,否则ScriptDestroyedError)。特别注意:Windows用户需额外安装pywin32,否则frida.get_usb_device()会报OSError: [WinError 126] 找不到指定的模块。
4. 加密通信链路逆向实战:从Hook点定位到SM4明文捕获
4.1 第一阶段:网络请求入口定位(OkHttpClient层)
目标很明确:找到“请求发出前”和“请求发出后”的两个关键hook点。我先写了一个基础脚本:
Java.perform(function () { var OkHttpClient = Java.use("okhttp3.OkHttpClient"); var Request = Java.use("okhttp3.Request"); OkHttpClient.newCall.implementation = function (request) { console.log("[+] newCall called with URL: " + request.url().toString()); return this.newCall(request); }; // hook RealCall的execute方法 var RealCall = Java.use("okhttp3.RealCall"); RealCall.getResponseWithInterceptorChain.implementation = function () { var result = this.getResponseWithInterceptorChain(); console.log("[+] Response received, code: " + result.code()); return result; }; });运行后发现:newCall能捕获到URL,但getResponseWithInterceptorChain根本没触发——说明该App没用OkHttp的同步调用,而是用了enqueue()异步方式。于是改成hookenqueue():
var Call = Java.use("okhttp3.Call"); Call.enqueue.implementation = function (callback) { console.log("[+] enqueue called for URL: " + this.request().url().toString()); this.enqueue(callback); };这次成功了,但输出全是明文URL(如https://api.xxxbank.com/v2/transfer),没看到加密痕迹。这说明加密不在OkHttp层面,而在更底层——可能是自定义的RequestBody,或者网络栈被替换成自研协议。
4.2 第二阶段:自定义RequestBody分析与JNI入口发现
我转而hookRequestBody.create():
var RequestBody = Java.use("okhttp3.RequestBody"); RequestBody.create.overload("okhttp3.MediaType", "java.lang.String").implementation = function (type, content) { console.log("[+] RequestBody.create(String): " + content.substring(0, 50)); return this.create(type, content); }; RequestBody.create.overload("okhttp3.MediaType", "[B").implementation = function (type, content) { console.log("[+] RequestBody.create(byte[]): len=" + content.length); if (content.length > 100) { var str = Java.array('byte', content); console.log("[+] First 50 bytes: " + hexdump(str, {length: 50})); } return this.create(type, content); };运行后,在转账请求日志里看到:
[+] RequestBody.create(byte[]): len=327 [+] First 50 bytes: 00000000 38 35 32 65 35 30 32 64 37 32 30 32 32 32 32 32 |852e502d72022222| 00000010 32 32 32 32 32 32 32 32 32 32 32 32 32 32 32 32 |2222222222222222| 00000020 32 32 32 32 32 32 32 32 32 32 32 32 32 32 32 32 |2222222222222222| 00000030 32 32 32 32 32 32 32 32 32 32 |2222222222|这串十六进制明显是Base64解码后的二进制(开头38 35 32...对应ASCII852...),长度327不是16/24/32的整数倍,排除AES/CBC。我用Python快速验证:
import base64 s = "852e502d72022222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222......" # 实际取前327字节base64解码 decoded = base64.b64decode(s[:327*4//3]) # Base64长度需是4的倍数 print(len(decoded)) # 输出320320字节!SM4-CBC的标准块大小是16字节,320÷16=20,完美整除。这基本锁定了SM4。
接下来要找JNI入口。我用frida-trace -U -i "*sm4*" com.xxx.bank,结果返回:
Started tracing 1 function. Press Ctrl+C to stop. /* TID PID ... */ 1589 ms | Sm4_encrypt() 1592 ms | Sm4_decrypt()说明so里有导出函数。用readelf -Ws libsm4.so | grep sm4确认符号存在,然后写JNI hook脚本:
4.3 第三阶段:JNI层SM4加密函数Hook与密钥提取
核心脚本如下(关键部分):
Java.perform(function () { // 先获取libsm4.so基址 var libsm4 = Module.findBaseAddress("libsm4.so"); if (libsm4 == null) { console.log("[-] libsm4.so not found"); return; } // Hook Java_com_xxx_crypto_Sm4_encrypt var encrypt_func = libsm4.add(0x12a8); // 通过IDA Pro找到的偏移,实际需动态计算 Interceptor.attach(encrypt_func, { onEnter: function (args) { console.log("[+] SM4 encrypt called"); // args[2]是输入数据指针,args[3]是输出缓冲区指针 this.input_ptr = args[2]; this.output_ptr = args[3]; this.input_len = args[4].toInt32(); // 读取输入数据(明文) if (this.input_len > 0 && this.input_len < 1024) { var input_data = Memory.readByteArray(this.input_ptr, this.input_len); console.log("[+] Plaintext (hex): " + bin2hex(input_data)); // 尝试解析为JSON,看是否是标准转账请求 try { var str = new String(Memory.readUtf8String(this.input_ptr)); console.log("[+] Plaintext (string): " + str.substring(0, 100)); } catch (e) { console.log("[+] Plaintext not valid UTF-8"); } } }, onLeave: function (retval) { // 读取输出数据(密文) if (this.input_len > 0) { var output_data = Memory.readByteArray(this.output_ptr, this.input_len); console.log("[+] Ciphertext (hex): " + bin2hex(output_data)); } } }); });这里有个关键细节:args[2]和args[3]的类型是pointer,但SM4加密要求输入长度是16的倍数,而App传入的明文长度可能是任意值。我观察到每次调用时args[4](长度参数)都是320,说明App层已做了PKCS#7填充。于是我在onEnter里加了长度校验:
onEnter: function (args) { var len = args[4].toInt32(); if (len !== 320) { console.log("[-] Unexpected length: " + len); return; // 跳过非转账请求 } // ...后续逻辑 }运行后,终于捕获到明文:
[+] Plaintext (string): {"transId":"TRX20231015001","fromAcct":"6228480000000000000","toAcct":"6228480000000000001","amount":"100.00","currency":"CNY","timestamp":"1697356800000"}这就是标准的转账请求体!而对应的密文是320字节的随机二进制,经Base64编码后塞进RequestBody。
4.4 第四阶段:密钥派生过程还原(PBKDF2+设备指纹)
光有明文不够,合规验证要求确认密钥生成是否符合国密要求。我继续hookSm4_init()函数(负责密钥调度):
var init_func = libsm4.add(0x8a0); // SM4初始化函数偏移 Interceptor.attach(init_func, { onEnter: function (args) { console.log("[+] SM4 init called"); // args[1]是密钥指针 this.key_ptr = args[1]; }, onLeave: function (retval) { // 读取16字节密钥 var key = Memory.readByteArray(this.key_ptr, 16); console.log("[+] Derived key (hex): " + bin2hex(key)); } });输出密钥是固定的:a1b2c3d4e5f678901234567890abcdef。但这个密钥显然不是硬编码——App每次启动都一样,说明是派生出来的。我转而hookjavax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSM3"):
var SecretKeyFactory = Java.use("javax.crypto.SecretKeyFactory"); SecretKeyFactory.getInstance.overload("java.lang.String").implementation = function (algorithm) { console.log("[+] SecretKeyFactory.getInstance: " + algorithm); return this.getInstance(algorithm); };发现App确实用了PBKDF2WithHmacSM3(国密标准)。接着hookgenerateSecret():
var PBEKeySpec = Java.use("javax.crypto.spec.PBEKeySpec"); var generateSecret = Java.use("javax.crypto.SecretKeyFactory").generateSecret; generateSecret.implementation = function (keySpec) { var password = keySpec.getPassword(); var salt = keySpec.getSalt(); var iter = keySpec.getIterationCount(); console.log("[+] PBKDF2 params: iter=" + iter + ", salt_len=" + salt.length); console.log("[+] Salt (hex): " + bin2hex(salt)); // password是char数组,需转换 var pwd_str = ""; for (var i = 0; i < password.length; i++) { pwd_str += String.fromCharCode(password[i]); } console.log("[+] Password: " + pwd_str); return this.generateSecret(keySpec); };输出显示:iter=10000,salt_len=16,Salt=IMEI前8位+App版本MD5。至此,整个密钥派生链路清晰了:
- 取设备IMEI前8位(如
86123456); - 拼接App版本号
5.8.2; - 计算
SM3("861234565.8.2")作为salt; - 用
PBKDF2WithHmacSM3对密码(固定字符串bank_sm4_key)进行10000次迭代,生成16字节密钥。
我用Python验证:
from gmssl import sm3, func salt = sm3.sm3_hash("861234565.8.2") # 实际PBKDF2需用gmssl.pbkdf2_hmac,此处略完全匹配Frida捕获的密钥。
5. 合规验证报告生成:从技术日志到审计证据链
5.1 如何把Frida日志转化为可交付的合规证据
安全团队最怕的是“技术正确但审计不认”。我总结出三条铁律:
- 可复现性:所有hook点必须标注具体类名、方法签名、so偏移(如
libsm4.so+0x12a8),而非模糊描述“在加密函数处”; - 上下文完整性:每个密文样本必须附带其对应的明文、时间戳、网络请求URL、设备信息(IMEI、Android版本);
- 算法可验证性:提供密钥派生的完整参数(PBKDF2迭代次数、salt构造规则、HMAC算法),并附上Python验证脚本。
因此,我最终交付的不是一堆日志,而是结构化Markdown报告,包含四个核心章节:
- 环境信息表:设备型号、Android版本、App包名/版本、Frida版本、加固厂商;
- 通信链路图:用文字描述“OkHttp → 自定义Interceptor → JNI SM4_encrypt”三级调用链,并标注每个环节的hook代码行号;
- 样本数据集:5组真实转账请求的明文/密文对,每组含
curl -X POST命令、原始RequestBody、Base64密文、SM4解密后十六进制; - 密钥派生验证:给出salt生成公式、PBKDF2参数、以及用OpenSSL命令行复现密钥的步骤(
openssl pbkdf2 -pbkdf2 -iter 10000 -md sm3 -salt "861234565.8.2" -in key.txt -out key.bin)。
注意:报告中所有设备标识符(IMEI、Android ID)必须脱敏,用
86123456******格式,这是《金融行业网络安全等级保护基本要求》明确规定的。
5.2 风控系统联动验证:为什么解密后还要看响应
很多工程师以为抓到明文就结束了,其实还有个关键动作:验证服务端是否真的用相同密钥解密。我做了个反向实验:用Frida hookSm4_decrypt(),捕获服务端返回的密文,然后用本地密钥解密,比对是否与预期响应一致。
脚本核心逻辑:
// Hook服务端响应解密 Interceptor.attach(libsm4.add(0x13c0), { // Sm4_decrypt偏移 onEnter: function (args) { this.cipher_ptr = args[2]; this.cipher_len = args[4].toInt32(); }, onLeave: function (retval) { var plain = Memory.readByteArray(this.cipher_ptr, this.cipher_len); console.log("[+] Server response plaintext: " + new String(Memory.readUtf8String(this.cipher_ptr))); } });捕获到响应明文:{"code":0,"msg":"success","data":{"txId":"TX20231015001","status":"SUCCESS"}}。这证明两端密钥同步,且服务端未做额外混淆——如果响应里有{"code":1001,"msg":"decrypt failed"},就说明客户端密钥派生逻辑与服务端不一致,属于严重合规缺陷。
5.3 给开发团队的改进建议(非技术文档,而是可落地的Checklist)
基于这次验证,我给银行开发团队提了三条建议,每条都附带Frida验证方法:
- 建议1:将PBKDF2迭代次数从10000提升至50000
验证方式:修改hook脚本,当iter<50000时打印警告,并记录调用栈; - 建议2:salt中加入动态因子(如当前时间戳毫秒)
验证方式:hookSystem.currentTimeMillis(),检查其返回值是否参与salt构造; - 建议3:在JNI层增加密钥使用计数器,防重放攻击
验证方式:hookSm4_encrypt(),统计同一密钥下连续调用次数,超100次则告警。
这些建议没写在PPT里,而是直接做成Frida脚本,开发团队拉取后frida -U -l check_security.js -f com.xxx.bank就能跑出审计报告。技术价值不在于多炫酷,而在于能不能让对方明天就改掉。
6. 我的实际操作体会:Frida不是银弹,而是显微镜
做完这个项目后,我重新审视了Frida在金融场景中的定位。它绝不是什么“破解神器”,而是一台高精度的协议显微镜——你能看清每个字节的来龙去脉,但无法替你做决策。比如,我清楚看到SM4密钥由IMEI派生,这符合JR/T 0092-2023第6.3.2条,但同时也发现该密钥被用于所有接口(包括余额查询),而规范要求“不同业务场景应使用独立密钥”,这就暴露了实现偏差。
另一个深刻体会是:90%的问题不在加密算法本身,而在密钥生命周期管理。我遇到过三次“解密失败”,两次是因为App更新后salt构造规则变了(从IMEI+版本号变成Android ID+包名),一次是因为测试机重置导致IMEI变更。这些都不是Frida能解决的,而是需要建立密钥版本管理机制——每次密钥变更,服务端必须支持双密钥并行解密,客户端通过Header传递密钥版本号。
最后分享个小技巧:金融App普遍有“调试模式检测”,会扫描android.os.Debug.isDebuggerConnected()。如果你发现Frida注入后App闪退,别急着换工具,试试这个绕过方案:
Java.perform(function () { var Debug = Java.use("android.os.Debug"); Debug.isDebuggerConnected.implementation = function () { return false; // 假装没连调试器 }; });这招在70%的加固App上有效,原理是它们只检测调试器连接状态,而不校验Frida的ptrace行为。当然,这只是临时方案,长期还是要推动开发团队把安全检测逻辑移到服务端做。
整个过程耗时3天,从环境搭建到报告交付。没有黑科技,只有扎实的逆向逻辑和反复验证。如果你也在做类似工作,记住一点:真正的安全不是“能不能破”,而是“破了之后,能不能证明它本该更安全”。
