做移动端安全分析的人应该都有过这种经历:拿到一个App,抓包一看请求体全是密文,签名串是几串不知道哪来的哈希值,代码里搜了半天也找不到加密入口,只能一层层往上追。我最早接触Frida,就是在这样一个“一头雾水”的项目里。那时候网上资料零散得很,中文教程更是一句一句拼出来的,踩坑踩到怀疑人生。后来做多了,才慢慢把“定位加密函数—看入参—看返回值—还原加解密过程”这条链整理成一套自动化Hook的打法,现在一次分析的时间从半天压缩到十来分钟。这篇实战指南,就是把我这些年用下来的思路、脚本和坑一次性讲清楚。适合刚入门逆向、想系统做移动端安全分析的读者,也适合已经在用Frida但总觉得效率上不去的朋友。
1. Frida到底是什么,为什么移动端加密分析绕不开它
1.1 一句话说透Frida的工作原理
Frida本质上是动态插桩工具,也就是在不修改目标App安装包的前提下,把一段JavaScript引擎注入到App进程内部,然后让脚本直接操作内存中的对象、方法、甚至native层的函数指针。通俗点说,它像你在一个正在运转的流水线旁边临时搭了个观察台,任何经过的零件都能抄下来看一眼,不用停机也不用改图纸。
这个机制对移动端加密分析的意义非常大。很多App的加密参数都是运行时才生成的,静态反编译根本看不出来,比如动态生成的密钥、秒级变化的签名串、从服务端拉下来的加密算法参数。用Frida你只需要在运行时把对应函数拦住,输入输出直接打印出来,加密逻辑瞬间透明。尤其是加解密这类确定性函数,一旦你拿到“输入=原文,输出=密文”的样例,整个密码学过程基本上就摊在桌面上任你分析了。
Frida的注入和脚本执行是热更新的,改完脚本不用重装App、不用重启设备,几秒钟就能再次重放和分析。这是Frida和其他工具拉开差距的核心优势,也是自动化体系能建立起来的前提。我个人的习惯是,拿到新目标先跑一轮通用脚本把所有常见加密API罩住,再针对具体业务函数做定点分析,整个流程一气呵成。
1.2 为什么是Frida而不是Xposed或定制ROM
提到Hook,很多人第一反应是Xposed。我也用Xposed做过不少东西,它确实很强,但有一个致命伤:必须重启设备才能生效,而且对系统版本和框架兼容性要求高。定制ROM的方案就更麻烦了,刷机、适配、中间还容易把设备搞成砖头。在加密函数分析这种需要高频试错、频繁改脚本反复跑的场景下,这种“改一下就得重启五分钟”的节奏完全没法接受。
Frida在这几个维度上的对比非常明显,我做了一张表,大家直观感受一下:
| 对比维度 | Frida | Xposed | 定制ROM |
|---|---|---|---|
| 动态生效 | 秒级,无需重启 | 重启才能生效 | 刷机才能生效 |
| 安装包改动 | 不需要改动 | 不需要改动 | 需要重打包系统 |
| 批量自动化 | 脚本化,天然适合 | 模块开发门槛高 | 几乎不可能 |
| 对App检测的暴露面 | 相对可控 | 特征明显 | 系统级隐蔽性好 |
| 新手上手成本 | 中低,文档多 | 中等 | 极高 |
从实际工作效率来看,Frida几乎是为分析场景量身定做的。你可以在一条命令里启动脚本、收集数据、退出进程,整个过程完全可编程,后面讲自动化Hook体系时你会看到这一点有多重要。至于定制ROM那类方案,更多是用于对抗检测严重的场景,日常加密分析完全用不上杀鸡的牛刀。
1.3 基础环境搭建:版本匹配决定成败
先说一个最基础也最容易卡住新手的问题:Frida版本不匹配导致的注入失败。Frida的客户端(电脑端Python包)和Server端(手机或模拟器上跑的frida-server)必须严格同版本,哪怕只差一个小版本号,也可能出现attach成功但脚本不执行、或者直接报“unable to connect to remote frida-server”这一类诡异问题。
安装步骤非常直接,电脑端执行pip install frida-tools,然后在GitHub Releases页面下载对应平台的frida-server压缩包,解压后推送到设备上。真机用ARM64版本,模拟器则要看CPU架构。很多人在这里栽跟头:雷电模拟器默认是x86架构的,就得下载x86_64的frida-server,而不是ARM版本;如果你用的是ARM镜像的模拟器,那才需要arm64。这事我踩过不只一次,每次重新配置环境都先确认一件事——设备是什么架构,别想当然。
启动frida-server之前建议先给足权限,adb shell进入设备后chmod 755,然后以root权限运行。很多模拟器需要adb root才能拿到root权限,这个步骤不做,后续所有操作都会失败。搭好环境后,用一条最简单的命令验证一下:
frida-ps -U如果能看到设备的进程列表,说明注入通道已经畅通,可以开始正事了。另外一个小建议:把frida-server的启动命令写成一个批处理脚本,每次用设备前双击启动,省得频繁手敲,尤其是配合自动化分析的时候,环境一乱排查起来特别费神。
2. Hook加密函数的完整思路与前置分析
2.1 先分清“三层加密”:数据加密、协议加密、签名防篡改
刚接触逆向的人容易犯一个错误:一上来就满代码库搜“AES”“MD5”这类关键字,指望直接找到加密函数然后一把梭。实际上移动端的加密体系通常是分层嵌套的,至少可以拆成三层来理解。
第一层是数据加密,最常见的就是AES或RSA,用来保护业务字段内容,比如请求体里的账号密码、手机号、身份证号这类敏感信息。第二层是协议加密,更隐蔽一些,很多App会自定义二进制格式或对整体报文再做一层DES/ChaCha20混淆,这层的作用是防篡改和防协议回归分析。第三层是签名防篡改,通常是基于消息摘要实现的,比如MD5、SHA256,或者更考究一点会用HMAC,甚至叠加时间戳、随机数、设备指纹等动态因子。
这三层的性质完全不同,分析策略也必须分开。数据加密层,核心目标是拿到密钥和初始向量,从而能主动加解密消息;协议加密层,重点是还原封装和解封装的流程,往往需要Hook到native层去看;签名防篡改层,则是要理解它的拼接规则和摘要方式,然后复现出可用的签名机。你在动手Hook之前,先通过抓包和反编译判断目标处于哪一层,直接决定了后面的脚本怎么写。
2.2 定位关键函数的三种实用姿势
第一种是关键词定位法,适合代码没怎么混淆或混淆强度不高的目标。用jadx或者GDA打开反编译后的代码,搜“AES/CBC”“SecretKeySpec”“Cipher.getInstance”“MessageDigest”这类特征字符串,基本能锁定加密入口。高级一点的还可以搜“IvParameterSpec”“PBEKeySpec”,很多App会用基于口令的加密模式,密钥派生函数里藏着大量可分析信息。
第二种是调用栈回溯法,适合抓到包但不知道字段生成位置的场景。在Frida脚本里先对比较泛的方法,比如哈希摘要的update或final方法,打上Hook并打印调用栈。Frida的Thread.backtrace()方法可以直接把调用栈吐出来,瞬间就能看到是哪个类、哪个方法在触发加密逻辑,然后顺着栈往上追业务代码就行了。这个方法比关键词搜索效率高很多,因为它直接定位运行时真实调用关系,不会被混淆代码和死代码干扰。
第三种是全量枚举法,也是自动化绕不开的一环。很多大型App会使用代码混淆、字符串加密、花指令等方式隐藏加密逻辑,静态搜关键词基本失效。这时候要换个思路,在运行时用Frida的反射枚举能力遍历所有已加载的类和方法,过滤出包含“encrypt”“decrypt”“sign”“cipher”“aes”“des”“rsa”“md5”“hash”等特征的方法名。只要App运行到过相关逻辑,方法就会被加载到内存,枚举一定抓得住。Objection这个工具内部就是这套逻辑,执行android hooking list activities或者配合自定义脚本扫描方法列表,自动化程度非常高。
2.3 从抓包到反编译再到动态验证的闭环流程
我完整走一遍项目的时候很少直接上来就Hook,而是先搭一个三层闭环的分析路径。
第一步是抓包。这个环节Charles或者Burp Suite都行,很多App做了证书校验,那就在模拟器或真机上安装用户证书,配合Frida绕过SSL Pinning。注意了,绕过证书绑定不是用来干坏事的,而是为了能看清App自己发出的明文请求结构,确定哪些字段是动态生成、哪些字段是静态常量。第二步是反编译静态分析,用jadx打开APK,梳理出可疑的加密入口和密钥装配逻辑。第三步是动态验证,针对静态分析找到的目标函数写Frida脚本,打印输入输出,确认猜测是否正确。这三步形成一个闭环:抓包发现密文字段,反编译找到加密代码,Hook验证加密过程,验证完再回抓包看结果是否对得上。
这个闭环流程最关键的点在于“交叉验证”。有时候反编译看到的代码路径不一定被执行,有时候抓包看到的密文并不是你以为的那个函数生成的。只有把抓包、静态、动态三者统一起来,才能100%确认加密函数的真实逻辑。否则很容易被字符串搜索引入歧途,对着一个根本不会被调用的死代码分析半天,浪费时间。
3. 实战:Java层加密函数Hook全流程
3.1 Hook MD5哈希函数的完整脚本
多数App的签名逻辑第一站就是MD5或SHA系列,这类消息摘要算法在Java层的入口非常固定,都是java.security.MessageDigest类。我写了一个通用脚本,直接打印消息输入和摘要输出,作为所有项目的起手式:
Java.perform(function () { var MessageDigest = Java.use('java.security.MessageDigest'); MessageDigest.update.overload('[B').implementation = function (input) { var dataStr = bytesToHex(input); console.log('[MD5/update] invoke'); console.log(' input(' + input.length + ') = ' + dataStr); return this.update(input); }; MessageDigest.digest.overload().implementation = function () { var result = this.digest(); console.log('[MD5/digest] => ' + bytesToHex(result)); return result; }; function bytesToHex(bytes) { var hex = ''; for (var i = 0; i < bytes.length; i++) { var b = bytes[i] & 0xff; hex += ('0' + b.toString(16)).slice(-2); } return hex; } });这里有两个非常容易踩的坑需要提醒。第一,MessageDigest.update有多种重载,比如update(byte[] input, int offset, int len),你只Hook了最基础的[B版本,其他重载调用时打印不到,建议把所有重载都覆盖。第二,bytesToHex里必须做& 0xff处理,Java的byte是有符号类型,直接转十六进制会出现一堆ffffff80这种诡异输出,新手排查半天不知道怎么回事。
实际使用中我发现,绝大多数App的MD5签名逻辑会多次调用update,分多次输入不同的拼接片段,最后统一digest。这就意味着日志里可能有十几条update记录,真正关键的是每个update的输入顺序和内容。所以要重点关注“update的数据是哪里来的”——如果是从业务字段直接拼接过来的,那签名规则就基本摸清了。
3.2 Hook AES对称加密的进阶写法
AES在Java层的核心类是javax.crypto.Cipher,它的运作流程是:先通过init方法传入加解密模式和密钥,然后调用update或doFinal执行实际数据转换。要完整还原一个AES加密流程,必须同时抓到这两个环节。
Java.perform(function () { var Cipher = Java.use('javax.crypto.Cipher'); Cipher.init.overload('int', Java.use('java.security.Key')) .implementation = function (opmode, key) { console.log('[Cipher.init] opmode=' + opmode); console.log(' key class = ' + key.getClass().getName()); if (opmode === 1) { console.log(' [ENCRYPT_MODE] Initialized for encryption'); } else if (opmode === 2) { console.log(' [DECRYPT_MODE] Initialized for decryption'); } return this.init(opmode, key); }; Cipher.update.overload('[B', 'int', 'int').implementation = function (input, offset, len) { var dataHex = bytesToHex(input); console.log('[Cipher.update] input(' + len + ') = ' + dataHex); var ret = this.update(input, offset, len); if (ret !== null) { console.log('[Cipher.update] out = ' + bytesToHex(ret)); } return ret; }; Cipher.doFinal.overload('[B').implementation = function (input) { var dataHex = bytesToHex(input); console.log('[Cipher.doFinal] input(' + input.length + ') = ' + dataHex); var ret = this.doFinal(input); console.log('[Cipher.doFinal] out = ' + bytesToHex(ret)); return ret; }; });光看算法还不够,AES真正值钱的是密钥。你可以写一个额外的Hook,把javax.crypto.spec.SecretKeySpec的构造函数也罩住,这样当App执行到密钥装配代码时,你能直接看到密钥的字节内容:
var SecretKeySpec = Java.use('javax.crypto.spec.SecretKeySpec'); SecretKeySpec.$init.overload('[B', 'java.lang.String').implementation = function (keyBytes, algorithm) { console.log('[SecretKeySpec.$init] algorithm = ' + algorithm); console.log('[SecretKeySpec.$init] key = ' + bytesToHex(keyBytes)); return this.$init(keyBytes, algorithm); };有了密钥和算法参数,再配合你抓到的密文,理论上就能手工复现整个加密过程。即使密钥是动态生成的,Hook记录也能告诉你每个会话密钥什么时候生成、基于什么种子生成,顺藤摸瓜就能找到密钥交换协议。
3.3 参数处理、返回值读取与类型转换避坑
Java层Hook最常见的几个类型转换坑,我集中说一遍。
第一个是byte数组的打印。Java层函数的实参如果是[B类型,Frida里拿到的是Java对象引用,不能直接用JavaScript的数组方法,必须通过索引去取。我上面的bytesToHex函数就是最朴素的写法,实测稳定。有人喜欢用Java.array('byte', input)把Java数组转成JS数组再用,遇到大数组时性能很差,尤其是加解密函数处理几KB数据时,一秒钟可能转几十次,直接拖慢目标App甚至导致超时。建议直接用索引访问。
第二个是byte[]和String之间的编码问题。App传参时如果是String类型,Frida里可以直接调用myString.toString()拿到Java层的字符串原始内容;但如果拿到的是byte数组,别盲目转字符串,因为加密函数处理的数据往往已经是二进制密文,直接按UTF-8解码会得到一堆乱码和替换符,正确的做法是统一以十六进制字符串形式输出,后续要用时再转换。
第三个是重载匹配问题。Java方法可以有多个签名,Frida的overload必须严格匹配。我的经验是,先通过Jadx查找目标方法的完整签名,再在Frida脚本里精确声明。如果漏掉了某个重载,它的调用会直接穿透你的Hook,表现为“明明打了Hook但日志里只出现了一半记录”。排查时可以用Cipher.update.overloads来查看所有重载声明,再逐一覆盖。
第四个是返回值类型。Cipher.update和doFinal返回的是byte数组,需要打印;但有些重载接收byte[] input, int inputOffset, int inputLen, byte[] output, int outputOffset,返回值是int表示输出长度,这种情况下不能只打印返回值,必须打印传入的output缓冲区,因为真正的密文写在那个缓冲区里。很多人在这个点上栽过:返回值打印了半天都是个数字,加密结果永远看不到。
4. 实战:Native层加密函数的自动化Hook
4.1 定位so库导出函数与内部函数
Java层Hook再牛,也架不住App把核心加密逻辑下沉到native层。很多大厂App会在so库里用OpenSSL或者自己封装的加密算法,Java层只传进字节流,所有关键计算都在C/C++层完成。这种情况下,需要先把目标so库加载进内存,然后用Frida的Moudle API去定位关键函数位置。
第一个手段是导出表查询。直接用Module.findExportByName('libxxx.so', 'EVP_EncryptUpdate'),适用于目标so没有做导出表混淆的常规情况。但很多商业App会在编译时用visibility属性隐藏内部符号,导出表里只有JNI_OnLoad、Java_xxx这类入口函数,真正干活的自定义函数一个都看不见。这时就需要用Module.enumerateSymbols()枚举所有符号,先摸一下so库里到底暴露了什么,再决定后续方案。
第二个手段是内部函数手工定位。静态反编译so库文件,用IDA或者Ghidra找到目标函数相对于so基址的偏移量,然后运行时用基址加偏移算出真实内存地址,再Interceptor.attach到该地址上。这个方法在分析自定义魔改加密算法时几乎是唯一选择,因为函数名都被strip掉了,你只能通过交叉引用和字符串定位它的位置。有一次我分析一个游戏App的通信协议加密,整个so库只有一个导出函数Java_com_game_xxx_nativeRSA,实际内部至少有三层自定义编码,就是靠Ghidra一个个函数盯出来的。
第三个手段是JNI动态注册的Hook。现在很多App会在JNI_OnLoad里用RegisterNatives动态注册Java层和native层的对应关系,这种情况下Java_com_xxx_这种静态导出名字根本不存在。对策是Hook住RegisterNatives本身,当你调用目标Java方法时,Frida就能在运行时打印出它实际对应的native函数指针,拿到指针后再Interceptor.attach即可。
4.2 用Interceptor对EVP_EncryptUpdate做批量拦截
如果你确定目标App走的是OpenSSL加密链路,那libssl和libcrypto库里的函数就是天然的Hook锚点。OpenSSL的加密入口是EVP_EncryptUpdate和EVP_DecryptUpdate,它们在加解密数据时会被反复调用,非常适合做批量拦截。
下面是我常用的一段模板脚本:
var sslModule = Process.findModuleByName('libssl.so'); if (sslModule) { var encUpdate = Module.findExportByName('libssl.so', 'EVP_EncryptUpdate'); if (encUpdate) { Interceptor.attach(encUpdate, { onEnter: function (args) { var inBuf = args[1]; var inLen = args[3].toInt32(); console.log('[EVP_EncryptUpdate] inLen=' + inLen); var data = Memory.readByteArray(inBuf, inLen); console.log(hexdump(data, { offset: 0, length: inLen, header: true, ansi: true })); }, onLeave: function (retval) { console.log('[EVP_EncryptUpdate] ret=' + retval); } }); console.log('[+] EVP_EncryptUpdate hooked'); } }这里有个重要的细节:OpenSSL的EVP_EncryptUpdate是缓冲型API,数据有可能被拆分成多次调用,也有可能多次调用凑齐一整块才做一次加密。所以在分析时不要只看单条调用记录,要关注同一会话内连续多次调用之间的数据关系。配合EVP_EncryptInit_ex的Hook,你还能拿到每次加密会话使用的密钥和IV。把这两组日志放在一起对照,AES-CBC这类模式的加密基本透明。
还有一个性能问题要提。如果你拦截的是高频调用(比如每帧都加密的游戏协议),hexdump的日志量会非常巨大,容易导致App卡顿。建议在生产分析场景下先只打印长度和哈希特征,比如计算输入数据的MD5值,快速识别出哪些数据块是固定结构、哪些是动态变化的,再针对关键数据块用完整hexdump二次分析。
4.3 魔改算法的自动化定位思路
市面上很多App对AES、RC4这类标准算法做了“魔改”,比如改了S盒、改了轮数、改了初始向量拼接方式。这种时候,直接找标准函数签名是找不到的,因为它们根本没有使用标准库,而是在so里自己写了一套实现。
我的定位思路分三步。第一步,在Java层用Cipher实现自己的加密调用,在native层用字符串匹配或者交叉引用找到调用加密逻辑的发起函数,通常Java层的入口点会有一个对应的native方法。第二步,从native方法出发,在Ghidra中追踪它的调用链,找到循环结构和大量位运算、查表操作的代码块,这些都是对称加密算法的强特征。第三步,用Frida在可疑代码块的入口和出口分别打上日志,比对输入输出和已知明文之间的差异,反推出算法的具体变换规则。
举例来说,如果识别到某个函数每次处理16字节数据块,内部有多个轮次的轮换、异或、S盒查表,几乎可以肯定是类AES结构,只是细节被魔改了。你可以用Frida动态修改S盒数组的值来验证猜测——如果修改后输出与标准AES一致,说明你的理解是正确的。这种验证思路比纯静态逆向效率高一大截,毕竟是动态环境,改一个字节、跑一次函数、看结果,远比在几百KB的so库里翻汇编来得快。自动化方面,这种分析模式完全可以脚本化:跑一轮输入输出样本,算差值和特征,再自动打点验证。熟练之后,就算面对完全陌生的自定义加密算法,也能在两三个小时内还原出基本结构。
5. 搭建自动化Hook体系的思路与落地
5.1 frida-trace与Objection的自动化三板斧
单个脚本手动hook是基本功,但实际项目里你不可能每次都手动写脚本。到我手里的目标App更新频繁,上一版本的加密逻辑和新版本不一定一样,每次都要重新分析。所以从最初我就开始搭建自动化的Hook套件。第一板斧是frida-trace,它是Frida官方自带的批量跟踪工具,一条命令就能监听一类函数调用:
frida-trace -U -m '-[NSURL* requestWithURL:*]' com.demo.appAndroid端可以监听Java方法:
frida-trace -U -i "AES*" -i "MD5*" com.demo.app-i参数支持通配符匹配,相当于把一类加密相关的方法全部挂上探针,自动打印调用参数和返回值。这个工具适合第一轮信息收集,让你快速看到App运行时实际调用了哪些加密函数、调用频率和顺序如何。缺点是日志不够结构化,需要配合后续精加工。
第二板斧是Objection。这个工具基于Frida封装了一整套移动端动态检测能力,我最常用的是android hooking list classes和android hooking list class java.security.MessageDigest。它可以在不写一行代码的情况下,快速枚举类、枚举方法、hook方法。尤其适合那些Java层代码混淆严重、静态搜索无果的场景,Objection的运行时枚举几乎不受混淆影响,因为你枚举的是JVM实际加载的内存对象和方法表。
第三板斧是我自己写的通用Hook脚本模板库。我把MD5、SHA、AES、RSA、DES、Base64这些常用算法的Hook脚本整理成单独文件,按需加载。分析新App时先用frida-trace全局扫一遍,再针对命中的算法用对应模板脚本做深度hook,整个过程不超过五分钟。这套模板库我还写了注释和参数说明,每次接手新项目都能直接复用,省去大量重复劳动。
5.2 用Python驱动批量自动化分析
Frida最强大的地方在于它的Python绑定。你可以用Python脚本控制Frida启动、注入、接收日志、保存结果,这意味着整个分析流程完全可以程序化。
我在实际项目中写过一个批量分析框架,核心流程是这样的:
import frida import sys import os def on_message(message, data): if message['type'] == 'send': print('[HookLog] ' + message['payload']) else: print('[Error] ' + str(message)) def analyze_apk(package_name, script_path): device = frida.get_usb_device(timeout=5) session = device.attach(package_name) with open(script_path, 'r', encoding='utf-8') as f: script_code = f.read() script = session.create_script(script_code) script.on('message', on_message) script.load() return session, script def kill_and_restart(package_name): os.system(f'adb shell am force-stop {package_name}') os.system(f'adb shell am start -n {package_name}/.MainActivity') if __name__ == '__main__': target = sys.argv[1] if len(sys.argv) > 1 else 'com.demo.app' script_path = sys.argv[2] if len(sys.argv) > 2 else 'hooks/base_hooks.js' session, script = analyze_apk(target, script_path) kill_and_restart(target) print(f'[+] Attached to {target}, waiting for actions...')这个框架做到了三件事。第一,自动挂到目标进程,即使App做了多进程架构,也能按进程名分别attach。第二,脚本热加载,分析过程中想换一组Hook函数,不用断开会话,直接调用script.load()重新加载即可。第三,消息回调,所有JavaScript侧的日志通过send发回Python端,Python端统一记录到文件或标准输出,方便后续程序化处理。
批量场景更有用。比如你有一个目录放了10个待分析的App,每个都要先扫一遍加密函数分布,那就可以写一个外层循环,依次启动每个App、attach、跑30秒通用Hook日志、保存结果、下一个。全程无人值守,输出结构化日志文件。这个能力在处理防火墙类应用或SDK轮询检测时特别有价值——你可以一次性把所有加密调用点全部标记出来,画出一张完整的加密调用拓扑图。
5.3 输出结果处理与长期复用
自动化Hook如果不做结果沉淀,那就是白忙一场。我踩过这个坑:第一次分析完某个App,日志文件存在电脑桌面上,几个月后项目复盘再回来找,发现文件名是log_v3_final_2.log,内容乱成一锅粥,根本没法复用。
现在我的做法是严格分级输出。第一级是原始日志,来自Frida消息回调,按时间戳和进程名分目录保存,不做任何加工。第二级是结构化的Hook调用记录,解析原始日志后提取关键字段——函数名、入参、出参、调用栈顶部两层——写入JSON或SQLite。第三级是结论性报告,手写的加密流程说明、密钥获取路径、签名规则复现步骤。这三份数据分别对应三个使用场景:复盘排查、批量搜索特征、团队知识沉淀。
[ { "timestamp": "2025-01-12 14:23:55.123", "process": "com.demo.app", "class_method": "javax.crypto.Cipher.doFinal([B)", "operation": "ENCRYPT", "algorithm": "AES/CBC/PKCS5Padding", "input_hex_length": 48, "output_hex_length": 64, "key_hex": "e1a2b3c4d5e6f708192a3b4c5d6e7f80", "iv_hex": "000102030405060708090a0b0c0d0e0f", "call_stack_top": "com.demo.core.crypto.CryptoHelper.encrypt(Native Method) -> com.demo.network.RequestBuilder.sign(RequestBuilder.java:128)" } ]长期复用的关键是不要只存结果,要把“当时怎么分析出来的”这个过程也记录下来。我通常在每个项目的分析目录里放一个ANALYSIS_NOTES.md,里面记录分析顺序、用过的Hook脚本版本、关键实验现象和判断依据。几个月后回来看,等于拿到一本自己写的“解密日记”,省下的时间远远大于记录的时间。
6. 常见问题与排查技巧实录
6.1 崩溃、闪退、直接exit的经典故障
Frida使用过程中遇到最多的,就是注入后目标App当场崩溃。这里面的原因五花八门,但我在实际操作中总结出了几个高频触发点。
第一个是frida-server和frida客户端版本不匹配。这是最常见也最基础的原因,症状是attach成功后脚本还没执行,App就挂掉或者设备直接断开连接。解决办法很简单,用pip show frida查看电脑端版本,然后去GitHub下载完全相同版本的frida-server,覆盖设备上的二进制文件并重启。
第二个是Hook了不该Hook的critical method。Frida官方文档里明确说了,某些平台或JVM内部的方法(比如ClassLoader相关的关键路径)不能随便Hook,一旦Hook就会触发ART运行时崩溃。我踩过一次,Hook掉了java.lang.String的某个内部方法,设备直接重启。经验是:优先Hook业务层面的方法,不要动JDK核心类的底层实现。
第三个是多线程并发打印导致的崩溃。当Hook的函数被多个线程同时调用,而你的日志打印逻辑里做了字符串拼接或文件写入,Java层的console.log加上Python端的消息回传可能堆积成山,最终导致ANR或内存溢出。对策是在脚本内部直接用简单的send方法发送消息,不要做复杂的日志格式转换,高频日志适当做采样,比如只打印每秒前20条。
6.2 Hook不上、方法找不到的排查路径
“Hook不上”这个说法其实很笼统,我把它拆成几种具体场景来讲。
场景一:方法签名写错了。Frida的overload对签名非常严格,参数类型不匹配就直接抛异常。排查方法很简单,在控制台打印目标类的所有方法列表,以及每个方法的参数签名和返回类型,确认后再写overload。这个操作在Objection里就是一条命令的事。
场景二:目标类还没有被加载。App的某些加密类可能只在特定业务路径下才会被ClassLoader加载,App刚启动时你的脚本就尝试Java.use(),自然找不到类。解决办法是给脚本加上等待逻辑,或者主动触发目标业务路径(比如登录、支付)来确保类加载。也可以在Android的ClassLoader层次上做Hook,等目标类加载完成后再执行Java.use()。Frida提供了Java.perform结合setTimeout的方式来做延迟初始化,我一般在分析脚本开头预留一个可选延迟参数。
场景三:目标方法被混淆/内联/抽取。如果是代码抽取加固(比如梆梆、爱加密这类加固壳),方法体在运行前是加密存放的,静态反编译看不到真实代码,运行时Hook也要先等方法被decode到内存。这种情况下,单纯Hook业务方法会失败,需要先处理脱壳或等待。但如果是混淆改名,方法名变了但类结构还在,就可以通过类名和参数特征来匹配,或者在运行时枚举方法名再做模糊筛选。
场景四:目标函数被inlineHook或反Frida检测保护。现在主流App几乎都有不同强度的Frida检测,检测到注入后要么直接退出,要么瘫痪功能。这个属于对抗范畴,主流做法是用各种方式隐藏注入痕迹或延迟注入时机。我只能说这确实是一场持续的攻防博弈,脚本尽量精简、降低注入特征,能争取到更大的分析窗口。这里不展开讲具体绕过手段,合规使用是底线。
6.3 模拟器、真机、内核版本组合的坑
我最后想说一下运行环境的选择。模拟器开发调试非常方便,但加密分析场景下真机和模拟器的行为差别可能很大。
模拟器的问题集中在架构上。像雷电模拟器默认是x86架构,跑的是x86_64的Android系统,而市面上的大多数App是ARM指令集的APK包,运行在x86模拟器上会通过二进制翻译执行。二进制翻译后,native层的函数地址和内存布局与真机有差异,某些Hook点可能失效或行为诡异。所以我的建议是:能做真机分析尽量用真机,尤其是native层加密逻辑,真机的arm64环境下分析结果最可靠。如果确实只有模拟器,优先选择官方提供的ARM镜像,或者雷电模拟器里安装ARM转换组件,能大幅减少native层分析中的异常行为。
系统版本方面也有讲究。Android 7及以上对JNI Hook和ClassLoader机制的约束更严格,Android 10起对native内存访问做了更多限制,Android 14的某些版本连ptrace都加了限制。如果你工作用的测试机是Android 13以上,遇到莫名奇妙的Hook失败,先怀疑是不是系统版本限制,换一台Android 9或10的设备试试。我自己的主力分析设备就是一台Android 9的Pixel,系统版本老却反而稳定,能覆盖市面上绝大部分App的兼容性。
另外一个容易被忽略的点是CPU架构选择。真机找arm64版本没错,但很多模拟器上安装的frida-server是x86_64的,如果App内部有so库的arm64版本和x86版本之分,你Hook的so库可能是x86翻译过来的,地址偏移与arm64不同。所以在模拟器上做native分析前,先确认目标App加载的so库文件路径,用adb shell cat /proc/<pid>/maps看一下实际加载的是哪个库,再决定Hook策略。
排查这些问题时,我还有个习惯:每遇到一个新环境组合,先跑一遍最简单的“Hello World”级Hook脚本,比如Hook一下java.lang.System.currentTimeMillis,确认注入、脚本执行、日志回传三个环节都正常工作,然后再上复杂分析脚本。这个简单的冒烟测试能帮你快速区分“环境问题”和“分析问题”,省下大量定位时间。
最后再分享一个长期项目中特别有用的小技巧:给常用脚本做版本管理。Frida脚本更新迭代快,同一个App的不同版本需要不同脚本,同一个功能脚本也可能要针对不同环境微调。我一开始不重视这个,后来发现脚本散落各处改来改去,自己都分不清哪个能跑。现在所有生产脚本都纳入git管理,每个脚本头部写了适用场景、依赖环境和测试日期。每次分析前Pull一下最新代码,跑完确实有改进再Push上去。这套“脚本即代码”的流程,配合Frida本身的动态能力,基本上是我这几年移动端加密分析效率提升最大的一个变革点。