接手一个移动端安全评估项目的时候,最磨人的环节往往不是“看不懂代码”,而是“不知道App在哪个类里算的加密参数”。我手头有个App,每个请求都得带一个sign字段,我拿着jadx反编译看了半天,代码被混淆过,越看越晕。后来换了个思路:直接把Frida的Hook脚本从“手工单点调试”改成“自动化批量抓取”,App一遍业务操作跑下来,所有跟加密相关的函数输入输出全部落盘,再用脚本筛一遍,加密链路马上清晰了。
这篇文章就是把整套流程拆开讲:Frida环境怎么搭才能不翻车、加密函数怎么定位、Hook脚本怎么写才能自动化、抓到的数据怎么整理,以及我踩过的几个真实坑。适合有Android基础、刚进移动端逆向领域,或者准备在自动化测试工具链里加“加密函数分析”环节的朋友。
1. 环境搭建容易翻车的几个细节
Frida用起来简单,但环境搭建里藏着不少小坑。我第一次搭的时候,就是被版本配对折腾了一晚上。
1.1 frida-server版本必须和主机端对齐
先明确Frida的架构:你电脑上装的是frida-tools(命令行工具)和frida-python(Python绑定),手机或模拟器上跑的是一个叫frida-server的守护进程。这两端的版本必须严格对齐,比如PC端是16.x,那frida-server也得是16.x同一子版本,否则会直接连接失败。
主机端安装很简单:
pip install frida-tools frida --version然后根据输出的版本号去下载对应版本的frida-server。下载时注意架构,真机一般是arm64-v8a,老设备可能是arm,雷电等模拟器是x86_64。
# 推送到设备并启动 adb push frida-server /data/local/tmp/ adb shell "chmod 755 /data/local/tmp/frida-server" adb shell "/data/local/tmp/frida-server &" # 端口转发,然后验证连接 adb forward tcp:27042 tcp:27042 frida-ps -Ufrida-ps -U能列出设备进程,就说明环境通了。如果报错,大概率是版本不匹配或端口被占用,重新对一遍版本号基本能解决。
1.2 真机、模拟器与架构选择
雷电模拟器本身对Frida还算友好。模拟器通过adb connect 127.0.0.1:5555连上之后,frida-server要选x86_64版本。这里有个很容易忽略的点:模拟器的CPU架构和真机不一样,有些人把arm64的frida-server直接推到模拟器里,启动就报Exec format error。
判断架构用这条命令:
adb shell getprop ro.product.cpu.abi另外,frida-server需要root权限。真机如果没有root,一般要借助Magisk之类的方案,或者退而求其次用frida-gadget注入模式,但那就不是“服务器”的玩法了。模拟器里通常自带root,直接adb root就能拿到,省事很多。
1.3 连接失败的常见原因对照
我在社区里看过太多人问“为什么连不上”,常见就这几种:
| 现象 | 原因 | 快速排查 |
|---|---|---|
unable to connect to remote frida-server | 手机端frida-server没启动或端口不通 | 检查进程是否存在,重跑adb forward |
unable to authenticate | 版本不匹配 | 两端的版本号逐位对齐 |
device not found | adb没发现设备 | 确认adb devices有输出,模拟器需先connect |
启动后立即退出或Exec format error | frida-server架构选错 | 用getprop ro.product.cpu.abi确认架构 |
提示:
frida-server启动时建议加-l 0.0.0.0监听所有网络接口,否则部分模拟器网络模式下会连不上。真机通过USB调试时,-U走的是USB通道,一般不需要额外监听配置。
2. 定位加密函数:静态分析与动态验证配合
环境通了之后,下一步是找到“加密函数到底在哪”。这一步我习惯用静态分析和动态Hook配合,两条腿走路,准确率最高。
2.1 静态分析怎么找“可疑加密点”
用jadx或GDA打开APK,先别急着从头读到尾,直接搜特征:
- Java层标准加密类:
javax.crypto.Cipher、SecretKeySpec、IvParameterSpec、PBEKeySpec - 摘要和编码类:
MessageDigest、Mac、Base64 - 算法字符串:
AES/CBC/PKCS5Padding、RSA/ECB/PKCS1Padding、HmacSHA256、MD5 - 工具类命名:类名里带
Encrypt、Crypto、Security、Sign、Aes的
把这些搜索命中的代码位置标记出来。但静态分析有个天然局限:混淆后类名方法名全变成a.b.c,就算找到了Cipher.doFinal的调用点,也不一定能快速看清上层业务逻辑,因为真正的业务封装类可能分布在各个混淆包里。这时候就得靠动态Hook来补位。
2.2 动态Hook验证:跑起来看调用链
动态验证的思路很简单:不知道谁调用了加密函数,那就把标准加密库的方法全部挂上Hook,让App在运行中自己“交代”。典型的Hook点就是Cipher.init和Cipher.doFinal:
Java.perform(function () { var Cipher = Java.use('javax.crypto.Cipher'); Cipher.init.overload('int', 'java.security.Key').implementation = function (opmode, key) { console.log('[Cipher.init] opmode=' + opmode); console.log('[Cipher.init] algorithm=' + key.getAlgorithm()); return this.init(opmode, key); }; Cipher.doFinal.overload('[B').implementation = function (input) { var ret = this.doFinal(input); console.log('[Cipher.doFinal] input=' + bytesToHex(input)); console.log('[Cipher.doFinal] output=' + bytesToHex(ret)); showStack(); return ret; }; });这里的showStack()是关键,它能把这行调用所在的Java调用栈打出来:
function showStack() { var Log = Java.use('android.util.Log'); var Exception = Java.use('java.lang.Exception'); console.log(Log.getStackTraceString(Exception.$new())); }调用栈会直接告诉你:AES加密是哪个类的哪个方法触发的。拿到这个类名和方法名,再回jadx里精确定位,就能找到App自己的业务封装层。
2.3 静态与动态如何配合
举一个实际例子:我之前分析一个App,静态搜索发现Cipher被上百处调用,不可能逐个看。用Hook把Cipher.doFinal全部拦下来后,跑一遍登录流程,调用栈显示触发点是com.xxx.center.core.utils.a.a(String)。回jadx一搜,这个类的代码逻辑非常短,就一个AES加密工具类。整个过程从几小时缩短到十几分钟。
如果目标App的加固把Java层整套逻辑抽走了,动态验证的结果会显示标准库方法没有被调用,调用栈一片空白。这时候再判断“是不是走了Native层”,转向Native层Hook。
3. Hook脚本编写:从单点调试到批量自动化
很多教程教的是“遇到一个函数,写一个Hook脚本”,这种模式做单点分析还行,做全量加密函数盘点就很低效。我习惯写一套通用Hook框架,把“挂钩”这个动作做成批量、可配置的。
3.1 先把字节数组转换这类公共逻辑写好
加密函数最典型的数据形态就是byte[],如果直接console.log(input),出来的是一串对象地址,完全没法看。所以第一步要封装一个bytesToHex:
function bytesToHex(bytes) { if (bytes === null) return 'null'; var result = ''; for (var i = 0; i < bytes.length; i++) { var byteStr = (bytes[i] & 0xff).toString(16); if (byteStr.length === 1) result += '0'; result += byteStr; } return result; }再封装一个入参格式化函数,区分参数是String、byte[]还是普通对象:
function formatArg(arg) { if (arg === null) return 'null'; var getClass = Java.use('java.lang.Object').getClass; if (arg.getClass && arg.getClass().getName() === '[B') { return 'byte[](' + arg.length + '): ' + bytesToHex(arg); } return arg.toString(); }顺便说一句:bytesToHex这种函数在Native层同样常用,只是写法差别不大,建议直接统一放在公共脚本文件里,避免每个Hook脚本重复造轮子。
3.2 Java层方法重载的自动化遍历
Java方法天然支持重载。同一个方法名可能有多个不同参数类型的版本,手工逐个overload写起来很烦。更通用的做法是拿到一个类的某个方法的所有重载,全部挂上统一逻辑:
function hookJavaMethod(className, methodName) { var clazz = Java.use(className); var overloads = clazz[methodName].overloads; overloads.forEach(function (overload) { overload.implementation = function () { var args = Array.prototype.slice.call(arguments); var argInfo = args.map(formatArg).join(' | '); var ret = overload.apply(this, args); console.log('[' + className + '.' + methodName + ']'); console.log(' args: ' + (argInfo || '(no args)')); console.log(' ret: ' + formatArg(ret)); return ret; }; }); }注意两个细节:
overload.apply(this, args)调用原方法,保证业务逻辑不变。formatArg里判断byte[]用的是arg.getClass().getName() === '[B',这是JVM类型描述符的写法,不要写成instanceof,因为在Frida的Java桥接层里,instanceof有时对数组类型判断不稳定。
3.3 批量注入多个目标的调用方式
有了hookJavaMethod,就可以定义一个目标列表,一次性把所有要观察的加密类全挂上:
var targets = [ { cls: 'javax.crypto.Cipher', m: 'init' }, { cls: 'javax.crypto.Cipher', m: 'doFinal' }, { cls: 'javax.crypto.spec.SecretKeySpec', m: '$init' }, { cls: 'javax.crypto.spec.IvParameterSpec', m: '$init' }, { cls: 'android.util.Base64', m: 'encodeToString' }, { cls: 'android.util.Base64', m: 'decode' }, { cls: 'java.security.MessageDigest', m: 'digest' }, { cls: 'javax.crypto.Mac', m: 'doFinal' } ]; targets.forEach(function (t) { try { hookJavaMethod(t.cls, t.m); } catch (e) { console.log('[-] Failed to hook ' + t.cls + '.' + t.m + ': ' + e); } });这个脚本一跑,App业务操作中所有涉及标准加密库的调用都会被打点。每个加密调用都带调用栈,收集完再用脚本聚合,加密链路基本就重建出来了。
3.4 Native层导出符号与offset定位
Java层Hook只能覆盖Java代码调用。很多App把核心算法下沉到so文件里,或者用JNI直接调Native函数。Native层Hook的思路是Interceptor.attach到目标地址:
var lib = Module.findBaseAddress('libnative-lib.so'); if (lib) { // 方式一:通过导出符号定位 var encryptAddr = Module.getExportByName('libnative-lib.so', 'Java_com_example_crypto_NativeCrypto_encrypt'); if (encryptAddr) { Interceptor.attach(encryptAddr, { onEnter: function (args) { console.log('[Native] encrypt called'); console.log(' arg0(JNIEnv): ' + args[0]); console.log(' arg2(String): ' + args[2].readCString()); }, onLeave: function (retval) { console.log('[Native] encrypt ret: ' + retval); } }); } // 方式二:通过静态偏移定位 // var offset = 0x12345; // Interceptor.attach(lib.add(offset), { ... }); }导出符号的方式最直接,适用于没有去掉导出表的so。混淆比较狠的so,函数名会被strip掉,需要用偏移量定位。偏移量一般通过frida-trace初探,或者用Ghidra/IDA加载so文件,搜索关键字符串(比如AES密钥常量或算法标识),再找到引用它的函数地址来计算:
// 计算偏移:静态基址通常为0,动态基址通过Module.findBaseAddress获取 // 偏移 = 静态文件中的虚拟地址 - 静态ImageBase这里的“为什么”在于:Android的so加载到进程里后,基址是不确定的,但函数在so内部的相对偏移是固定的,所以用base + offset在运行时定位函数。写Hook时建议在onEnter里读取this.context下的寄存器,能拿到更多JNI调用细节,比如r0是JNIEnv指针、r1是jobject,实际业务参数一般从r2开始。
4. Python控制端与持续抓取设计
批量Hook脚本有了,但每次都要手动在命令行里frida -U -l hook.js,注入完还要盯着终端看日志,依然不够自动化。更进一步的做法是用Python写控制端,把“启动App、注入脚本、收集日志、落盘保存”全套流程自动化。
4.1 spawn vs attach:为什么选择spawn
Frida有两种附加方式:attach是附加到已运行的进程,spawn是启动进程并暂停在入口点,注入脚本后再恢复运行。
为什么自动化要选spawn?因为很多App的加解密逻辑在Application初始化阶段就会执行。如果用attach,等你连上的时候,启动阶段的加密调用已经发生完了,Hook脚本什么都没抓到。spawn能保证从进程启动第一条指令开始就处于监控状态,不会漏掉早期调用。
代价是spawn模式下App启动会被主动放慢,而且比较容易触发反调试机制,但作为数据抓取主力,绝大部分场景够用。
4.2 Python脚本驱动整体流程
Python端代码结构大致是这样:
import frida import sys import time def on_message(message, data): if message['type'] == 'send': print(message['payload']) elif message['type'] == 'error': print('[Error]', message.get('stack', message.get('description', ''))) def main(): device = frida.get_usb_device(3) # 等待设备上线 pkg = 'com.example.app' # spawn方式:先启动并暂停 pid = device.spawn([pkg]) session = device.attach(pid) with open('hook.js', 'r', encoding='utf-8') as f: script = session.create_script(f.read()) script.on('message', on_message) script.load() device.resume(pid) # 保持脚本运行,直到用户Ctrl+C try: while True: time.sleep(1) except KeyboardInterrupt: session.detach() if __name__ == '__main__': main()核心逻辑是:spawn后App先处于暂停状态,此时立即attach并加载脚本,脚本加载完成后再resume。顺序不能乱,否则容易漏掉启动早期的调用。
4.3 数据落地:写文件与rpc两种方式
终端直接打印日志适合即时调试,但做自动化数据采集时,日志量会非常大,滚动屏刷到没法看。我常用两种数据落地方式:
第一种是JS端直接写设备文件。Frida的FileAPI可以把数据追加写入设备路径:
var logFile = new File('/data/local/tmp/crypto_hook.log', 'a'); function logToFile(payload) { logFile.write(JSON.stringify(payload) + '\n'); logFile.flush(); }跑完业务操作后,adb pull /data/local/tmp/crypto_hook.log把日志拉回电脑分析。这个方式对日志量不敏感,几十万条也能扛住。
第二种是rpc.exports回调Python。在JS里导出一个函数,Python端调用,适合需要实时处理数据的场景:
rpc.exports = { log: function (msg) { console.log('[rpc]', msg); return msg; } };script.exports_sync.log('hello from js')两种方式对比:写文件适合大批量数据采集,rpc适合和自动化测试框架结合实时判定结果。我一般先写文件,复盘中需要实时交互时再切rpc。
4.4 自动化回归的一个简单思路
数据能稳定落地后,可以做一件很有价值的事:把加密函数的输入输出格式化成结构化JSON,存成基线库。后续每次App版本更新,自动跑一遍同一组业务操作,对比加密函数的入参出参有没有变化。
如果发现算法参数变了,比如AES的mode从CBC变成GCM,或者密钥长度从128变成256,那就是业务逻辑发生了重大调整,测试用例和签名算法都得跟着更新。这个思路放在自动化测试框架里,相当于给“加密链路”加了一层专属回归监控。
5. 踩坑实录:运行异常与性能影响处理
最后这部分是我的“事故现场”。自动化Hook脚本跑多了,会碰到各种运行异常和性能问题,处理不好会直接影响抓取质量。
5.1 spawn后反调试检测
有一类App会检测Frida特征,在spawn暂停期间或者resume后很快就主动崩溃。典型现象是:普通attach模式还能活几秒,spawn模式一进去就闪退。
这类问题的排查思路是:先确认是不是Frida-server的特征字符串被检测了。用frida-ps查看进程列表没问题,但App内部可能检查/data/local/tmp/frida-server文件路径、27042端口、或扫描maps里有没有frida的so模块。
常规应对思路包括给frida-server改名、端口改成非常规值,或者用Gadget模式把注入时机往后拖。这些手段主要用于授权测试场景,自己在本地做学习和CTF类题目时完全够用。
5.2 高频调用导致App卡死
加密函数往往是热点函数。有些App一个加密函数一秒钟被调用几千次,如果每次调用都console.log完整参数,终端刷屏还是小事,关键是App会肉眼可见地变卡,甚至直接ANR。
优化策略有三个,按优先级别来:
- 参数截断:
bytesToHex时只打印前32字节,加上长度标记。 - 去重输出:同样的入参组合只输出一次,后面不重复打印。
- 集中落盘:用队列在JS端攒够100条再写一次文件,而不是每条都
flush。
例:
var logQueue = []; function enqueueLog(payload) { logQueue.push(JSON.stringify(payload)); if (logQueue.length >= 100) { var logFile = new File('/data/local/tmp/crypto_hook.log', 'a'); logFile.write(logQueue.join('\n') + '\n'); logFile.flush(); logFile.close(); logQueue = []; } }这个写法能极大降低IO频率,对高频加密函数的性能侵扰会小很多。
5.3 类型转换与内存引用问题
用Frida Hook Java层方法时,如果频繁在JS和Java之间传递大对象,内存压力会很明显。特别是byte[]数组,如果每次都在formatArg里把它转成Hex字符串再拼到日志里,几分钟就会把设备内存吃满,最终OOM。
我的习惯是:只对“当前关心的数据”做完整转换,其它情况一律只记长度、前几个字节和哈希值。另外,Hook回调里不要长期持有Java对象的引用,函数执行完就让本地引用自然释放。JNI local reference表溢出这种报错,多半就是回调函数里反复获取对象但没释放引用导致的。
5.4 合规使用边界
这套技术是把双刃剑。它能让安全测试效率大幅提升,但也能被别人用来分析并绕过业务风控。我平时只在自己的测试设备、获得书面授权的App、以及公开的CTF/漏洞靶场上使用。做移动端安全的底线是:不能把别人系统的加密链路分析结果用于非法窃取数据、绕过支付或身份校验,否则后果不用说也明白。
注意:如果你的企业有自动化安全测试平台,这套Frida批量Hook可以作为一个节点接入进去,但要确保接入的设备、目标App都有明确的授权记录和测试边界。
再分享一个细节:环境搭建这一步值得固化成一个脚本文件。我最初每次都手动输adb forward、手动推frida-server、手动敲版本号,后来写了个一键部署脚本,把下载、推送、端口转发、版本校验全串起来,换机器、换模拟器都是两分钟搞定。自动化不只是Hook脚本自动化,整个操作链路都自动化,后面才能省出时间真正用来分析数据。