接手一个移动端安全评估项目的时候最磨人的环节往往不是“看不懂代码”而是“不知道App在哪个类里算的加密参数”。我手头有个App每个请求都得带一个sign字段我拿着jadx反编译看了半天代码被混淆过越看越晕。后来换了个思路直接把Frida的Hook脚本从“手工单点调试”改成“自动化批量抓取”App一遍业务操作跑下来所有跟加密相关的函数输入输出全部落盘再用脚本筛一遍加密链路马上清晰了。这篇文章就是把整套流程拆开讲Frida环境怎么搭才能不翻车、加密函数怎么定位、Hook脚本怎么写才能自动化、抓到的数据怎么整理以及我踩过的几个真实坑。适合有Android基础、刚进移动端逆向领域或者准备在自动化测试工具链里加“加密函数分析”环节的朋友。1. 环境搭建容易翻车的几个细节Frida用起来简单但环境搭建里藏着不少小坑。我第一次搭的时候就是被版本配对折腾了一晚上。1.1 frida-server版本必须和主机端对齐先明确Frida的架构你电脑上装的是frida-tools命令行工具和frida-pythonPython绑定手机或模拟器上跑的是一个叫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 forwardunable to authenticate版本不匹配两端的版本号逐位对齐device not foundadb没发现设备确认adb devices有输出模拟器需先connect启动后立即退出或Exec format errorfrida-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.doFinalJava.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)出来的是一串对象地址完全没法看。所以第一步要封装一个bytesToHexfunction 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为什么选择spawnFrida有两种附加方式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, encodingutf-8) as f: script session.create_script(f.read()) script.on(message, on_message) script.load() device.resume(pid) # 保持脚本运行直到用户CtrlC 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脚本自动化整个操作链路都自动化后面才能省出时间真正用来分析数据。