做移动端安全测试这些年,我见过太多人把大量时间花在反编译和读汇编上,遇到加了壳的应用就只能干瞪眼。后来我开始把 Frida 动态插桩作为主要分析手段,很多原本要折腾半天的任务,现在几分钟就能拿到结果。这篇文章我会从最基础的 Hook 写法讲起,一路讲到内存扫描、特征定位和脚本工程化,全程用我实际跑过的案例说话。Frida 是一个运行在目标进程内部的动态插桩工具,它的核心思路是把自己的 JavaScript 引擎注入到 App 或可执行文件所在的进程里,然后你就可以在运行时查看函数入参、修改返回值、主动调用任意方法,甚至直接扫描和改写内存。这意味着你不再需要把每个函数都逆成伪代码,而是让程序自己告诉你它做了什么。做协议分析、加密参数还原、自动化测试、恶意样本研究的人,都会从中受益。如果你是刚接触 Frida 的新人,这篇文章能让你少走至少两个月的弯路;如果你已经写过一些脚本,后半段关于内存扫描和工程化的内容应该能帮你补齐一块短板。先说清楚一个大前提:所有操作都必须在合法授权的设备与目标上开展,Frida 是安全研究工具,不是用来做越权测试的。1. Frida实战入门:动态插桩为什么比静态分析更省事1.1 我在静态分析里踩过的坑早期我做协议分析和加密参数还原,主要靠抓包加反编译。抓包能解决网络层的明文问题,但遇到 HTTPS 双向校验或者参数被加密,就得回到反编译看代码。普通的 Java 层代码还好,Jadx 打开基本能读个大概;但一旦遇到加固、混淆、Native 层算法,静态分析就非常痛苦了。最大的问题在于,静态分析看到的代码和运行时的实际状态是脱节的。你猜某个字段是用来放明文还是放密钥?你猜某个分支走不走?猜错了就得重新分析。更麻烦的是,很多关键变量只在特定条件下才会生成,比如用户点击一次按钮、App 启动后的第三秒、网络请求返回后。你光看代码设计不出这种触发场景,就需要一边运行一边观察。这时候 Frida 的优势就很明显了:你不需要猜测,直接在真实运行环境里看。方法有没有被调用,参数是什么,返回值是什么,一目了然。分析模式从“顺着代码找逻辑”变成“顺着运行过程找答案”,效率完全不在一个量级。1.2 Frida在安全测试里到底扮演什么角色Frida 本质上是一个动态插桩框架,它解决的是“在目标进程运行时介入和观察”这件事。它不负责替你解密,也不负责替你还原整个协议,而是给你一把能在运行时打开进程内脏的钥匙。它的核心能力有四块。第一是 Hook 函数调用。你可以监听任意 Java 方法或 Native 函数,打印参数、返回值,也可以直接替换函数实现、跳过原逻辑。这对于确认“哪个函数处理了登录”“签名算法在哪里执行”极其有效。第二是主动调用。你可以让目标进程里的某个类或某个方法在当前环境中执行,比如直接调用某个加密函数,把明文传进去拿到密文,而不是自己去重新实现一遍算法。第三是内存访问。你可以枚举模块、扫描内存特征、读取指定地址的数据,也可以向内存写入内容。很多在静态代码中根本看不到的密钥、明文、关键结构体,只会在运行时短暂存在于内存里,扫描内存往往能直接捞到答案。第四是脚本化与自动化。Frida 的脚本可以反复执行,可以和 Python、TypeScript 工程配合,做成命令行工具。你不需要每次都手动开控制台粘贴代码,一条命令就能完成批量任务。我用一个生活化的类比来解释:静态分析像是拿着图纸研究一栋楼的结构,看每一根柱子怎么受力;动态插桩则是你走进这栋楼,在关键房间装监听器,看每天到底有谁进出、搬运什么东西。图纸再复杂,也不如现场观察来得直接。2. Frida环境搭建与连接手机:版本不匹配的坑我踩过多次2.1 一套最省心的环境准备过程Frida 环境看起来简单,但第一次搭建时很容易在版本问题上卡住。很多新手遇到连接失败,其实都和版本不对齐有关。我的固定流程是这样:先在电脑上安装 frida-tools 客户端,然后在目标设备上放一个对应架构的 frida-server,最后用frida-ps -U验证连接。注意一个铁律:电脑上的 frida-tools 版本必须和手机上的 frida-server 版本保持一致,主版本不一致会导致连接失败或者注入后脚本无法加载。# 电脑端安装 pip install frida-tools # 查看客户端版本 frida --version # 查看设备架构 adb shell getprop ro.product.cpu.abi得到设备架构之后,去 Frida 的官方仓库下载对应架构的 frida-server 文件。常见的架构是 arm64-v8a,对应文件名字里带android-arm64。把它推送到设备上,加执行权限,然后用 root 权限启动。adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell "/data/local/tmp/frida-server &"启动后在电脑上执行frida-ps -U,能看到设备进程列表就说明环境通了。这里补充一个细节:如果你用模拟器,要注意模拟器本身的架构,很多模拟器是基于 x86_64 的,frida-server 也要选对应架构。之前在某个 x86 模拟器上直接用了 arm64 版本的 frida-server,结果frida-ps -U一直卡死,换了架构之后立刻正常。2.2 attach 和 spawn 两种模式怎么选Frida 接入目标进程有两种方式:attach 和 spawn。attach 模式是附加到一个已经在运行的进程上,命令是frida -U -f 包名是 spawn 模式,frida -U 进程名是 attach 模式。实际使用时,attach 更快捷,因为目标正在运行,你不需要等待冷启动过程;缺点是你只能在进程起来之后才注入,错过了很多早期的初始化逻辑。spawn 模式是让 Frida 直接拉起目标进程,在启动早期就注入脚本。这种模式适合需要监测 Application 初始化、DexClassLoader 加载、早期加密参数生成的分析场景。比如你想看一个库的初始化流程在什么时候调用了关键函数,用 spawn 模式才能完整覆盖。我个人的选型原则是:只想确认某个函数的参数和返回值,而目标 App 运行稳定,用 attach。需要跟踪启动流程,或者目标函数在执行早期就被调用,用 spawn。如果 attach 总是失败,或者注入后立刻崩溃,优先试试 spawn,很多初始化时序问题都能迎刃而解。还有一个容易忽略的小地方:spawn 模式下如果脚本里写死了Java.perform的时机,有时类还没加载完,需要结合setTimeout或延迟启动技巧。这个问题我在后面会展开。3. 三种常用Hook写法:Java层、主动调用、Native层3.1 先搞懂 Frida 脚本的三个核心概念写 Frida 脚本,你会发现所有逻辑都绕着三个 API 打转:Java.perform、Java.use和Interceptor。Java.perform是 Frida 进入 Java 运行时上下文的入口。因为注入的线程并不是 Java 线程,你直接调用 Java 相关 API 是不行的,必须把代码包在Java.perform里。Java.perform(function () { // 在这里写 Java 相关的 Hook });Java.use用于获取一个 Java 类的包装对象,之后你就能修改它的某个方法。比如var SignHelper = Java.use("com.demo.app.security.SignHelper"),接下来对这个类的静态方法或实例方法做 Hook。Interceptor则是用于 Native 层的 Hook 工具。它的核心方法是Interceptor.attach,第一个参数是 Native 函数地址,第二个参数是包含onEnter和onLeave回调的对象。onEnter在函数进入时触发,你可以读取寄存器参数;onLeave在函数返回时触发,你可以读取返回值。这三个概念理解了,剩下的事情就是组合运用。3.2 示例一:Hook 一个 Java 方法,把参数和返回值全打出来大多数场景的第一步,是找一个关键函数,确认它到底被谁调用、传入了什么。比如我们模拟一个需求:某 App 有一个com.demo.app.security.SignHelper类,里面有一个sign(String input)方法,负责生成请求签名。我们想知道所有调用它时传入的明文和最终签名结果。Java.perform(function () { var SignHelper = Java.use("com.demo.app.security.SignHelper"); SignHelper.sign.implementation = function (input) { var result = this.sign(input); console.log("[sign] input = " + input); console.log("[sign] result = " + result); return result; }; });这里有个很重要的细节:在implementation里调用this.sign(input)才是调用原始方法。如果直接写SignHelper.sign(input)或者漏调用,会发生什么?轻则拿不到正确返回值,重则栈溢出。很多人第一次写 Frida 把原始逻辑覆盖了,后面所有依赖签名结果的请求全部失败,就是这个原因。这个例子虽然简单,但实操价值极高。我处理过很多“参数怎么加密”“key 从哪里来”的问题,第一步都是用这种方式把入口函数的所有入参、返回值打出来,往往打几轮日志,关键信息就自己浮出水面了。3.3 示例二:主动调用加密函数,把密钥和算法结果拿出来另一种常见场景是:你已经在代码里定位到了某个加密算法,但它是由一个单例对象持有的,你拿不到这个对象,也就没法主动调用。这时候要用Java.choose在 Java 堆里枚举该类的实例。比如我们想主动调用某个密钥生成器com.demo.app.crypto.KeyGenerator的generateKey(String seed)方法,但它不是静态方法,需要先拿到实例。Java.perform(function () { Java.choose("com.demo.app.crypto.KeyGenerator", { onMatch: function (instance) { var key = instance.generateKey("test_seed"); console.log("[KeyGenerator] generateKey -> " + key); }, onComplete: function () { console.log("[KeyGenerator] search done"); } }); });Java.choose的目标是遍历堆中找到目标类的所有已存活实例。如果实例比较多,onMatch可能触发多次,要注意控制日志量。主动调用的核心好处是你不需要触发一次真实业务请求,直接在当前进程里把函数调用出来,方便快速验证算法逻辑和密钥组成。不过要提醒一点:有些算法函数不是线程安全的,主动调用时如果高峰期和多线程环境冲突,可能导致崩溃。建议在Java.perform里先确认调用环境,再执行。3.4 示例三:Native 层 Hook,定位 so 中的关键处理Java 层搞不定的时候,大概率核心逻辑在 Native 层。比如某 App 的 sign 算法放在libdemo.so里,你需要看它的一个导出函数Java_com_demo_app_security_SignHelper_nativeSign的输入输出。先找到模块基址,加上偏移量定位函数地址,然后用Interceptor.attach挂钩。var base = Module.findBaseAddress("libdemo.so"); if (base) { Interceptor.attach(base.add(0x12345), { onEnter: function (args) { console.log("[nativeSign] arg0 = " + args[0]); console.log("[nativeSign] arg1 = " + args[1]); }, onLeave: function (retval) { console.log("[nativeSign] retval = " + retval); } }); } else { console.log("libdemo.so not loaded"); }这里有一个新手常踩的坑:用绝对地址偏移。如果固件每次加载的基址不同,直接写死地址一定会失败。正确做法是Module.findBaseAddress("libdemo.so")拿到当前进程里的实际基址,再加上偏移。而且要判断base是否为null,因为 so 并不一定在脚本注入时就已经加载。如果 so 是运行时才加载的,你可以用Module.load或Process.findModuleByName("libdemo.so")配合定时轮询。更优雅的方式是使用Interceptor.attach之前先通过Module.enumerateExports确认导出符号是否存在,再把符号名直接传给Module.getExportByName,比写死偏移要稳得多。4. 内存扫描与高级分析实战:从堆里把关键参数捞出来4.1 为什么要从内存层面去分析Hook 函数能解决大部分问题,但有些情况函数级 Hook 也不好使:目标 Native 代码把数据藏得很深,或者算法在非常底层的环境里,你没有明确的地址、没有明确的导出符号。这时候,内存就是最后一个战场。字符串、指针、关键配置,只要在运行时被加载到内存,它就一定存在于某个地址范围内。很多样本的密钥在静态代码里搜不到,因为它是运行时动态拼接、解密、生成之后放进内存的。用内存扫描可以直接搜索特征字节串,然后定位到具体地址,再往上翻看是谁在引用它。内存分析还有一个常用场景:你需要验证某个修改是否生效。比如你想测试一个字符数组在内存中被改掉之后,某个校验逻辑是否还成立,那就直接向该地址写入新值。这种修改不需要重新编译 App,也不用重新签名,效率非常高。4.2 快速上手 Memory.scan 扫描特征字符串Frida 提供了Memory.scan和Memory.scanSync两组 API。前者是异步回调,适合大规模扫描;后者是同步扫描,适合脚本里快速定位。我大多数场景用的是scanSync,因为它同步返回结果,代码写起来更直观。假设我们在授权测试环境中,要查找内存中是否出现某个字符串user_token。先把它转成十六进制75 73 65 72 5f 74 6f 6b 65 6e,然后对可读写的内存范围进行搜索。var pattern = "75 73 65 72 5f 74 6f 6b 65 6e"; // user_token var ranges = Process.enumerateRanges({ protection: "rw-", coalesce: true }); ranges.forEach(function (range) { Memory.scanSync(range.base, range.size, pattern).forEach(function (match) { console.log("[scan] found at " + match.address + " in " + range.base); }); });Process.enumerateRanges的protection参数是权限过滤,常见值有r--、rw-、r-x。字符串一般出现在可读写的内存中,所以用rw-最合适。coalesce: true会让相邻的内存区域合并,减少遍历次数。实际搜索时如果直接搜整个进程的rw-范围,在大型 App 里可能耗时很夸张。我的做法是先通过Process.enumerateModules()缩小范围,优先检查可疑模块,或者只搜索 Java 堆的那几个区域。还有一个经验:如果搜索不到目标字符串,考虑它可能是 UTF-16 编码,搜索时要带上00字节,比如75 00 73 00 65 00 72 00。4.3 从扫描结果到关键对象:读内存、改内存的完整思路扫描命中的地址只是起点,你通常需要进一步读取这个地址前后的数据,确认它属于哪个对象,然后决定是否对它进行改写。读取内存核心用Memory.readByteArray、Memory.readUtf8String和Memory.readPointer。readUtf8String最常用,适合直接看字符串;readByteArray多用于恢复二进制结构。一段实际操作的逻辑是这样:我们搜索到一个 8 字节的字符串verify=1,它在内存中的地址是0x7b4c2d10。我们继续读取这个地址附近 64 字节,看看里面还有什么字段,然后判断它是一个完整结构体的一部分,还是一个孤立零散的字符串。var addr = ptr("0x7b4c2d10"); console.log(hexdump(addr, { offset: 0, length: 64, header: true, ansi: false })); Memory.writeUtf8String(addr, "verify=0");hexdump是 Frida 自带的内存查看函数,输出效果接近十六进制编辑器,非常适合分析内存上下文。上面的Memory.writeUtf8String直接改写内存内容,在验证逻辑时会用到。这里有一个血的教训:改写内存之前一定要先备份原数据。我经常用Memory.readByteArray(addr, 16)先把原始字节读出来保存到变量里,测试完再恢复,否则一旦改动影响到 App 的某个全局状态,后续行为会非常诡异,排查半天也找不到原因。4.4 内存分析的高级技巧:指针回溯与对象定位当你在内存里定位到一个关键地址,但它是动态分配的对象,直接分析地址本身是不够的,需要回溯到引用它的父对象。Frida 提供了Memory.scan配合指针宽度搜索的方式:扫描所有指向某个地址的指针。在 64 位进程上,地址会以 8 字节小端序存储,搜索模式要对应当前 CPU 架构。你可以把目标地址转换成小端序字节序列,再对整个可读内存范围做指针扫描。function findPointerTo(target) { var targetBuf = target.toMatchPattern(); var ranges = Process.enumerateRanges({ protection: "r--", coalesce: true }); var results = []; ranges.forEach(function (range) { Memory.scanSync(range.base, range.size, targetBuf).forEach(function (match) { results.push(match.address); }); }); return results; }NativePointer.prototype.toMatchPattern是 Frida 提供的便捷方法,直接把指针转换成匹配模式字符串。这样扫出来的是所有“引用着目标地址”的指针,顺着这些指针往上翻,常常能找到对象的根节点或者容器结构。这个方法在分析 Java 对象背后对应的 Native 对象时尤其好用,很多 Java 层传给 Native 的数据,本质上就是通过 JNI 引用间接指向一块 Native 内存,指针回溯可以帮你把这个关系画出来。5. 排查问题:Hook老失败,先看这5个原因5.1 类找不到,ClassLoader 不对写过一段时间 Frida 的人都会遇到同一个报错:ClassNotFoundException。明明反编译里能看到这个类,为什么 Frida 里找不到?原因是目标类不是由系统默认的 ClassLoader 加载的,尤其是在插件化、热修复、多 dex 场景下,类加载器是自定义的。遇到这种情况,第一步先枚举一下当前进程里到底加载了哪些类,看目标类在不在列表里:Java.perform(function () { Java.enumerateLoadedClasses({ onMatch: function (className) { if (className.indexOf("com.demo.app") !== -1) { console.log(className); } }, onComplete: function () {} }); });如果类确实存在,只是加载时机晚于 Hook 时机,那就需要延迟 Hook:用setTimeout或者给 App 一点时间启动,再执行 Hook。如果类名存在但当前 ClassLoader 无法访问,可以用Java.enumerateClassLoaders枚举所有加载器,找到能加载目标类的那个,再用Java.classFactory.loader切换上下文。Java.classFactory.loader = loader;这行代码,在分析很多动态加载的插件和模块时是救命稻草。5.2 frida-server 连不上或者脚本加载后毫无响应这个问题的排查优先级最高。每次遇到这种报错,我第一件事就是对比 frida 客户端版本和 frida-server 设备端版本:frida --version adb shell "/data/local/tmp/frida-server --version"两个版本号必须完全对上,差一个子版本都可能出问题。版本没问题,再看端口:默认连接端口是 27042,如果这台设备之前还有别的工具占用或者代理过端口,也会导致连接失败。有时候设备上能看到frida-ps -U正常输出,但脚本一 attach 就卡住不执行。这种情况优先怀疑是 spawn 模式附加到了没有写进 AndroidManifest 的进程,或者目标 App 主动检测了注入帧从而静默停止。遇到这种我一般会先试试轻量级脚本,比如只打一行日志,确定链路通不通,再逐步加复杂度。5.3 Hook 之后 App 崩溃,十有八九是函数时序问题崩溃最常发生在三类场景:一是Java.use时机太早,类还没完成初始化就修改实现;二是 Hook 方法被高频调用,日志打印过多导致 UI 线程阻塞;三是你在implementation里没有正常调用原始方法,返回了一个类型不对的值。解决思路也很简单:不要让 Frida 脚本在主线程做太重的工作。日志量大的时候,在 JS 层做一次简单过滤,或者把日志通过send()发给 Python 侧处理。另一个技巧是在implementation里加try-catch,即使目标函数内部抛出异常,你的 Hook 逻辑也能正常结束。SignHelper.sign.implementation = function (input) { try { var result = this.sign(input); console.log("[sign] input = " + input); return result; } catch (e) { console.log("[sign] error: " + e); return null; } };5.4 多线程环境下的 Hook 回调异常Frida 的回调默认运行在目标进程内的某个线程上下文里。如果你在回调中调用了一个只能在特定线程调用的 API,就会导致崩溃或死锁。比如有些加密库要求状态只能由初始化线程访问,但 Frida 的onLeave是在函数返回时执行的,线程归属完全取决于目标函数在哪条线程上执行,轮不到你控制。遇到这种情况,不要直接在线程回调里做复杂操作。把数据收集下来,放到队列里,然后在主脚本线程统一处理。或者使用Frida的Java.scheduleOnMainThread把需要访问 UI 或主线程对象逻辑切到主线程。5.5 地址不对,加了偏移还是找不到函数Native 层 Hook 最常见的问题是模块基址对不上。原因多半是你把 64 位进程的 so 文件偏移和 32 位进程混用了。你从 IDA 里看到的偏移一般是相对 so 文件头的虚拟地址偏移,而Module.findBaseAddress("libdemo.so")返回的是它在内存中的加载地址,两者要一致,不能拿文件偏移直接加。另一个容易忽略的点是:so 可能在运行时被重新加载多次。你用Module.findBaseAddress拿到的只是其中一次加载的地址,如果后面又被加载一次,那地址就要重新获取。可以在Module.findBaseAddress之后再用Module.enumerateModules确认加载次数和地址。我把这几个常见问题整理成了一个表,方便你以后快速定位:现象最可能原因排查/解决提示类找不到ClassLoader 不对或类未加载枚举类加载器,切换classFactory.loader连接失败frida-tools 与 frida-server 版本不匹配两端版本对齐注入后 App 崩溃implementation没返回正确类型返回原始方法结果并加 try-catch脚本卡住不动注入时机太早改用 spawn 模式,加延迟启动Native 地址找不到基址获取错误或架构混用使用符号名定位,确认 32/64 位6. 把一次性脚本工程化:做成能反复执行的自动化工具6.1 从控制台粘贴脚本到 TypeScript 工程刚开始写 Frida 脚本,都是开控制台,一行行往里粘。脚本短无所谓,但脚本一旦超过两三百行,维护性就非常差。变量冲突、函数作用域混乱、字符串转义出错,都是常态。我的做法是把脚本迁到 TypeScript 工程里,用frida-compile打包。这样你可以获得完整的类型提示,特别是 Frida 提供的类型定义,比如NativePointer、NativeFunction、JavaInstance,写代码的时候大幅减少低级错误。一个典型目录结构是这样:frida-tool/ ├── agent/ │ └── index.ts ├── tools/ │ └── analyze.py ├── scripts/ │ └── hook.js └── package.jsonagent/index.ts里写所有 JavaScript 逻辑,然后用 frida-compile 把它编译成单文件hooks.js。编译产物可以直接塞给 Frida 使用,不用再维护一大堆散落的 JS 片段。TypeScript 脚本里一个常用的模式是导出方法给外界调用。比如你想把某个内存扫描工具做成可复用函数,用rpc.exports暴露出来:rpc.exports = { scanMemory(pattern: string): string[] { const results: string[] = []; const ranges = Process.enumerateRanges({ protection: "rw-", coalesce: true }); ranges.forEach(range => { Memory.scanSync(range.base, range.size, pattern).forEach(match => { results.push(match.address.toString()); }); }); return results; } };Python 侧可以直接调用这些方法,把分析工具封装成命令行接口。比如一段简单的 Python 脚本:连接 USB 设备、附加目标进程、加载编译好的脚本、调用scanMemory方法。import frida import sys def main(): device = frida.get_usb_device() session = device.attach("com.demo.app") with open("scripts/hooks.js", "r", encoding="utf-8") as f: source = f.read() script = session.create_script(source) script.load() pattern = "75 73 65 72 5f 74 6f 6b 65 6e" addresses = script.exports_sync.scan_memory(pattern) print("matched addresses:", addresses) session.detach() if __name__ == "__main__": main()这里用script.exports_sync是因为 rpc 方法被编译后,Python 可以通过同步方式调用。如果你需要异步调用,就改成script.exports.scan_memory加上await。6.2 把内存分析能力沉淀成常用命令工程化之后,你完全可以把它做成一个自己的“内存分析工具箱”。比如你给我一个目标样本,我通常会按这三步走:第一步,枚举模块和关键 so 的加载状态。先确定目标进程里有哪些模块是可疑的,哪些是系统库。这一步用Process.enumerateModules(),过滤出不在系统路径下的模块,重点关注/data/app/和/data/data/下的文件。第二步,扫描特征字符串。把目标 App 包名、已知密钥头、常见标记字符串做成一个字典,一次性扫多个 pattern。比如搜BEGIN RSA PRIVATE KEY、AES、SECRET等,快速定位敏感数据是否明文驻留在内存中。第三步,对命中的地址做上下文分析。用hexdump打印前后 64 字节,分析它是不是一个完整结构体。再用指针回溯找引用者,确认它属于哪个 Java 对象或者 Native 对象。这三步在工程化之后,基本可以做到一条命令完成。我自己的习惯是做成一个analyze.py,每次拿到新样本,先跑一遍模块扫描,再跑一遍特征字符串扫描,最后把命中的地址存成 JSON 文件。后续所有分析都基于这个 JSON 展开,不用反复重新扫。6.3 关于工程质量的一点个人经验脚本工程化不只是把代码从 JS 搬到 TS。我见过不少朋友把 Frida 脚本写成了“一次性胶水代码”,每次新需求都从旧脚本里复制粘贴,完全没有抽象。这种习惯在项目周期长、目标多的时候特别吃亏。我的建议是,把常用能力拆成独立函数:listModules、findBase、scanMemory、dumpMemory、traceJavaMethod。每一块都单独测试过,再组合使用。这样当你面对一个新样本时,不需要从头写脚本,而是把这些积木搭起来,可能十分钟就能拿到第一版分析结果。另一个经验是注意日志和数据的隔离。在目标进程里打印日志会影响性能,尤其高频函数。我一般不在 Frida 脚本里大量console.log,而是用send(message)把数据送回 Python 侧,由 Python 统一输出、存日志、统计。这样脚本本身更轻,也方便做自动化。最后,无论是写脚本还是做内存分析,都要时刻注意授权边界。Frida 的能力越强,越需要自我约束。我只在获得测试授权的应用上使用这些技术,任何越权尝试都可能带来不可控的后果。分析完成后,我也会把脚本和采集到的数据放在加密目录里,避免泄露敏感内容。写了这么久的 Frida 脚本,我个人最深的一个体会是:这个工具虽然强大,但真正让你效率倍增的,不是某个炫技操作,而是你能不能在正确的时机使用正确的 API。同样的Memory.scan,选对扫描范围和执行时机,几秒钟就出结果;选错了,扫到天荒地老也找不到。多积累一些排查经验,把常见的报错、常见的崩溃原因记下来,你就能把精力花在分析目标本身,而不是和工具较劲。
做移动端安全测试这些年,我见过太多人把大量时间花在反编译和读汇编上,遇到加了壳的应用就只能干瞪眼。后来我开始把 Frida 动态插桩作为主要分析手段,很多原本要折腾半天的任务,现在几分钟就能拿到结果。这篇文章我会从最基础的 Hook 写法讲起,一路讲到内存扫描、特征定位和脚本工程化,全程用我实际跑过的案例说话。Frida 是一个运行在目标进程内部的动态插桩工具,它的核心思路是把自己的 JavaScript 引擎注入到 App 或可执行文件所在的进程里,然后你就可以在运行时查看函数入参、修改返回值、主动调用任意方法,甚至直接扫描和改写内存。这意味着你不再需要把每个函数都逆成伪代码,而是让程序自己告诉你它做了什么。做协议分析、加密参数还原、自动化测试、恶意样本研究的人,都会从中受益。如果你是刚接触 Frida 的新人,这篇文章能让你少走至少两个月的弯路;如果你已经写过一些脚本,后半段关于内存扫描和工程化的内容应该能帮你补齐一块短板。先说清楚一个大前提:所有操作都必须在合法授权的设备与目标上开展,Frida 是安全研究工具,不是用来做越权测试的。1. Frida实战入门:动态插桩为什么比静态分析更省事1.1 我在静态分析里踩过的坑早期我做协议分析和加密参数还原,主要靠抓包加反编译。抓包能解决网络层的明文问题,但遇到 HTTPS 双向校验或者参数被加密,就得回到反编译看代码。普通的 Java 层代码还好,Jadx 打开基本能读个大概;但一旦遇到加固、混淆、Native 层算法,静态分析就非常痛苦了。最大的问题在于,静态分析看到的代码和运行时的实际状态是脱节的。你猜某个字段是用来放明文还是放密钥?你猜某个分支走不走?猜错了就得重新分析。更麻烦的是,很多关键变量只在特定条件下才会生成,比如用户点击一次按钮、App 启动后的第三秒、网络请求返回后。你光看代码设计不出这种触发场景,就需要一边运行一边观察。这时候 Frida 的优势就很明显了:你不需要猜测,直接在真实运行环境里看。方法有没有被调用,参数是什么,返回值是什么,一目了然。分析模式从“顺着代码找逻辑”变成“顺着运行过程找答案”,效率完全不在一个量级。1.2 Frida在安全测试里到底扮演什么角色Frida 本质上是一个动态插桩框架,它解决的是“在目标进程运行时介入和观察”这件事。它不负责替你解密,也不负责替你还原整个协议,而是给你一把能在运行时打开进程内脏的钥匙。它的核心能力有四块。第一是 Hook 函数调用。你可以监听任意 Java 方法或 Native 函数,打印参数、返回值,也可以直接替换函数实现、跳过原逻辑。这对于确认“哪个函数处理了登录”“签名算法在哪里执行”极其有效。第二是主动调用。你可以让目标进程里的某个类或某个方法在当前环境中执行,比如直接调用某个加密函数,把明文传进去拿到密文,而不是自己去重新实现一遍算法。第三是内存访问。你可以枚举模块、扫描内存特征、读取指定地址的数据,也可以向内存写入内容。很多在静态代码中根本看不到的密钥、明文、关键结构体,只会在运行时短暂存在于内存里,扫描内存往往能直接捞到答案。第四是脚本化与自动化。Frida 的脚本可以反复执行,可以和 Python、TypeScript 工程配合,做成命令行工具。你不需要每次都手动开控制台粘贴代码,一条命令就能完成批量任务。我用一个生活化的类比来解释:静态分析像是拿着图纸研究一栋楼的结构,看每一根柱子怎么受力;动态插桩则是你走进这栋楼,在关键房间装监听器,看每天到底有谁进出、搬运什么东西。图纸再复杂,也不如现场观察来得直接。2. Frida环境搭建与连接手机:版本不匹配的坑我踩过多次2.1 一套最省心的环境准备过程Frida 环境看起来简单,但第一次搭建时很容易在版本问题上卡住。很多新手遇到连接失败,其实都和版本不对齐有关。我的固定流程是这样:先在电脑上安装 frida-tools 客户端,然后在目标设备上放一个对应架构的 frida-server,最后用frida-ps -U验证连接。注意一个铁律:电脑上的 frida-tools 版本必须和手机上的 frida-server 版本保持一致,主版本不一致会导致连接失败或者注入后脚本无法加载。# 电脑端安装 pip install frida-tools # 查看客户端版本 frida --version # 查看设备架构 adb shell getprop ro.product.cpu.abi得到设备架构之后,去 Frida 的官方仓库下载对应架构的 frida-server 文件。常见的架构是 arm64-v8a,对应文件名字里带android-arm64。把它推送到设备上,加执行权限,然后用 root 权限启动。adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell "/data/local/tmp/frida-server &"启动后在电脑上执行frida-ps -U,能看到设备进程列表就说明环境通了。这里补充一个细节:如果你用模拟器,要注意模拟器本身的架构,很多模拟器是基于 x86_64 的,frida-server 也要选对应架构。之前在某个 x86 模拟器上直接用了 arm64 版本的 frida-server,结果frida-ps -U一直卡死,换了架构之后立刻正常。2.2 attach 和 spawn 两种模式怎么选Frida 接入目标进程有两种方式:attach 和 spawn。attach 模式是附加到一个已经在运行的进程上,命令是frida -U -f 包名是 spawn 模式,frida -U 进程名是 attach 模式。实际使用时,attach 更快捷,因为目标正在运行,你不需要等待冷启动过程;缺点是你只能在进程起来之后才注入,错过了很多早期的初始化逻辑。spawn 模式是让 Frida 直接拉起目标进程,在启动早期就注入脚本。这种模式适合需要监测 Application 初始化、DexClassLoader 加载、早期加密参数生成的分析场景。比如你想看一个库的初始化流程在什么时候调用了关键函数,用 spawn 模式才能完整覆盖。我个人的选型原则是:只想确认某个函数的参数和返回值,而目标 App 运行稳定,用 attach。需要跟踪启动流程,或者目标函数在执行早期就被调用,用 spawn。如果 attach 总是失败,或者注入后立刻崩溃,优先试试 spawn,很多初始化时序问题都能迎刃而解。还有一个容易忽略的小地方:spawn 模式下如果脚本里写死了Java.perform的时机,有时类还没加载完,需要结合setTimeout或延迟启动技巧。这个问题我在后面会展开。3. 三种常用Hook写法:Java层、主动调用、Native层3.1 先搞懂 Frida 脚本的三个核心概念写 Frida 脚本,你会发现所有逻辑都绕着三个 API 打转:Java.perform、Java.use和Interceptor。Java.perform是 Frida 进入 Java 运行时上下文的入口。因为注入的线程并不是 Java 线程,你直接调用 Java 相关 API 是不行的,必须把代码包在Java.perform里。Java.perform(function () { // 在这里写 Java 相关的 Hook });Java.use用于获取一个 Java 类的包装对象,之后你就能修改它的某个方法。比如var SignHelper = Java.use("com.demo.app.security.SignHelper"),接下来对这个类的静态方法或实例方法做 Hook。Interceptor则是用于 Native 层的 Hook 工具。它的核心方法是Interceptor.attach,第一个参数是 Native 函数地址,第二个参数是包含onEnter和onLeave回调的对象。onEnter在函数进入时触发,你可以读取寄存器参数;onLeave在函数返回时触发,你可以读取返回值。这三个概念理解了,剩下的事情就是组合运用。3.2 示例一:Hook 一个 Java 方法,把参数和返回值全打出来大多数场景的第一步,是找一个关键函数,确认它到底被谁调用、传入了什么。比如我们模拟一个需求:某 App 有一个com.demo.app.security.SignHelper类,里面有一个sign(String input)方法,负责生成请求签名。我们想知道所有调用它时传入的明文和最终签名结果。Java.perform(function () { var SignHelper = Java.use("com.demo.app.security.SignHelper"); SignHelper.sign.implementation = function (input) { var result = this.sign(input); console.log("[sign] input = " + input); console.log("[sign] result = " + result); return result; }; });这里有个很重要的细节:在implementation里调用this.sign(input)才是调用原始方法。如果直接写SignHelper.sign(input)或者漏调用,会发生什么?轻则拿不到正确返回值,重则栈溢出。很多人第一次写 Frida 把原始逻辑覆盖了,后面所有依赖签名结果的请求全部失败,就是这个原因。这个例子虽然简单,但实操价值极高。我处理过很多“参数怎么加密”“key 从哪里来”的问题,第一步都是用这种方式把入口函数的所有入参、返回值打出来,往往打几轮日志,关键信息就自己浮出水面了。3.3 示例二:主动调用加密函数,把密钥和算法结果拿出来另一种常见场景是:你已经在代码里定位到了某个加密算法,但它是由一个单例对象持有的,你拿不到这个对象,也就没法主动调用。这时候要用Java.choose在 Java 堆里枚举该类的实例。比如我们想主动调用某个密钥生成器com.demo.app.crypto.KeyGenerator的generateKey(String seed)方法,但它不是静态方法,需要先拿到实例。Java.perform(function () { Java.choose("com.demo.app.crypto.KeyGenerator", { onMatch: function (instance) { var key = instance.generateKey("test_seed"); console.log("[KeyGenerator] generateKey -> " + key); }, onComplete: function () { console.log("[KeyGenerator] search done"); } }); });Java.choose的目标是遍历堆中找到目标类的所有已存活实例。如果实例比较多,onMatch可能触发多次,要注意控制日志量。主动调用的核心好处是你不需要触发一次真实业务请求,直接在当前进程里把函数调用出来,方便快速验证算法逻辑和密钥组成。不过要提醒一点:有些算法函数不是线程安全的,主动调用时如果高峰期和多线程环境冲突,可能导致崩溃。建议在Java.perform里先确认调用环境,再执行。3.4 示例三:Native 层 Hook,定位 so 中的关键处理Java 层搞不定的时候,大概率核心逻辑在 Native 层。比如某 App 的 sign 算法放在libdemo.so里,你需要看它的一个导出函数Java_com_demo_app_security_SignHelper_nativeSign的输入输出。先找到模块基址,加上偏移量定位函数地址,然后用Interceptor.attach挂钩。var base = Module.findBaseAddress("libdemo.so"); if (base) { Interceptor.attach(base.add(0x12345), { onEnter: function (args) { console.log("[nativeSign] arg0 = " + args[0]); console.log("[nativeSign] arg1 = " + args[1]); }, onLeave: function (retval) { console.log("[nativeSign] retval = " + retval); } }); } else { console.log("libdemo.so not loaded"); }这里有一个新手常踩的坑:用绝对地址偏移。如果固件每次加载的基址不同,直接写死地址一定会失败。正确做法是Module.findBaseAddress("libdemo.so")拿到当前进程里的实际基址,再加上偏移。而且要判断base是否为null,因为 so 并不一定在脚本注入时就已经加载。如果 so 是运行时才加载的,你可以用Module.load或Process.findModuleByName("libdemo.so")配合定时轮询。更优雅的方式是使用Interceptor.attach之前先通过Module.enumerateExports确认导出符号是否存在,再把符号名直接传给Module.getExportByName,比写死偏移要稳得多。4. 内存扫描与高级分析实战:从堆里把关键参数捞出来4.1 为什么要从内存层面去分析Hook 函数能解决大部分问题,但有些情况函数级 Hook 也不好使:目标 Native 代码把数据藏得很深,或者算法在非常底层的环境里,你没有明确的地址、没有明确的导出符号。这时候,内存就是最后一个战场。字符串、指针、关键配置,只要在运行时被加载到内存,它就一定存在于某个地址范围内。很多样本的密钥在静态代码里搜不到,因为它是运行时动态拼接、解密、生成之后放进内存的。用内存扫描可以直接搜索特征字节串,然后定位到具体地址,再往上翻看是谁在引用它。内存分析还有一个常用场景:你需要验证某个修改是否生效。比如你想测试一个字符数组在内存中被改掉之后,某个校验逻辑是否还成立,那就直接向该地址写入新值。这种修改不需要重新编译 App,也不用重新签名,效率非常高。4.2 快速上手 Memory.scan 扫描特征字符串Frida 提供了Memory.scan和Memory.scanSync两组 API。前者是异步回调,适合大规模扫描;后者是同步扫描,适合脚本里快速定位。我大多数场景用的是scanSync,因为它同步返回结果,代码写起来更直观。假设我们在授权测试环境中,要查找内存中是否出现某个字符串user_token。先把它转成十六进制75 73 65 72 5f 74 6f 6b 65 6e,然后对可读写的内存范围进行搜索。var pattern = "75 73 65 72 5f 74 6f 6b 65 6e"; // user_token var ranges = Process.enumerateRanges({ protection: "rw-", coalesce: true }); ranges.forEach(function (range) { Memory.scanSync(range.base, range.size, pattern).forEach(function (match) { console.log("[scan] found at " + match.address + " in " + range.base); }); });Process.enumerateRanges的protection参数是权限过滤,常见值有r--、rw-、r-x。字符串一般出现在可读写的内存中,所以用rw-最合适。coalesce: true会让相邻的内存区域合并,减少遍历次数。实际搜索时如果直接搜整个进程的rw-范围,在大型 App 里可能耗时很夸张。我的做法是先通过Process.enumerateModules()缩小范围,优先检查可疑模块,或者只搜索 Java 堆的那几个区域。还有一个经验:如果搜索不到目标字符串,考虑它可能是 UTF-16 编码,搜索时要带上00字节,比如75 00 73 00 65 00 72 00。4.3 从扫描结果到关键对象:读内存、改内存的完整思路扫描命中的地址只是起点,你通常需要进一步读取这个地址前后的数据,确认它属于哪个对象,然后决定是否对它进行改写。读取内存核心用Memory.readByteArray、Memory.readUtf8String和Memory.readPointer。readUtf8String最常用,适合直接看字符串;readByteArray多用于恢复二进制结构。一段实际操作的逻辑是这样:我们搜索到一个 8 字节的字符串verify=1,它在内存中的地址是0x7b4c2d10。我们继续读取这个地址附近 64 字节,看看里面还有什么字段,然后判断它是一个完整结构体的一部分,还是一个孤立零散的字符串。var addr = ptr("0x7b4c2d10"); console.log(hexdump(addr, { offset: 0, length: 64, header: true, ansi: false })); Memory.writeUtf8String(addr, "verify=0");hexdump是 Frida 自带的内存查看函数,输出效果接近十六进制编辑器,非常适合分析内存上下文。上面的Memory.writeUtf8String直接改写内存内容,在验证逻辑时会用到。这里有一个血的教训:改写内存之前一定要先备份原数据。我经常用Memory.readByteArray(addr, 16)先把原始字节读出来保存到变量里,测试完再恢复,否则一旦改动影响到 App 的某个全局状态,后续行为会非常诡异,排查半天也找不到原因。4.4 内存分析的高级技巧:指针回溯与对象定位当你在内存里定位到一个关键地址,但它是动态分配的对象,直接分析地址本身是不够的,需要回溯到引用它的父对象。Frida 提供了Memory.scan配合指针宽度搜索的方式:扫描所有指向某个地址的指针。在 64 位进程上,地址会以 8 字节小端序存储,搜索模式要对应当前 CPU 架构。你可以把目标地址转换成小端序字节序列,再对整个可读内存范围做指针扫描。function findPointerTo(target) { var targetBuf = target.toMatchPattern(); var ranges = Process.enumerateRanges({ protection: "r--", coalesce: true }); var results = []; ranges.forEach(function (range) { Memory.scanSync(range.base, range.size, targetBuf).forEach(function (match) { results.push(match.address); }); }); return results; }NativePointer.prototype.toMatchPattern是 Frida 提供的便捷方法,直接把指针转换成匹配模式字符串。这样扫出来的是所有“引用着目标地址”的指针,顺着这些指针往上翻,常常能找到对象的根节点或者容器结构。这个方法在分析 Java 对象背后对应的 Native 对象时尤其好用,很多 Java 层传给 Native 的数据,本质上就是通过 JNI 引用间接指向一块 Native 内存,指针回溯可以帮你把这个关系画出来。5. 排查问题:Hook老失败,先看这5个原因5.1 类找不到,ClassLoader 不对写过一段时间 Frida 的人都会遇到同一个报错:ClassNotFoundException。明明反编译里能看到这个类,为什么 Frida 里找不到?原因是目标类不是由系统默认的 ClassLoader 加载的,尤其是在插件化、热修复、多 dex 场景下,类加载器是自定义的。遇到这种情况,第一步先枚举一下当前进程里到底加载了哪些类,看目标类在不在列表里:Java.perform(function () { Java.enumerateLoadedClasses({ onMatch: function (className) { if (className.indexOf("com.demo.app") !== -1) { console.log(className); } }, onComplete: function () {} }); });如果类确实存在,只是加载时机晚于 Hook 时机,那就需要延迟 Hook:用setTimeout或者给 App 一点时间启动,再执行 Hook。如果类名存在但当前 ClassLoader 无法访问,可以用Java.enumerateClassLoaders枚举所有加载器,找到能加载目标类的那个,再用Java.classFactory.loader切换上下文。Java.classFactory.loader = loader;这行代码,在分析很多动态加载的插件和模块时是救命稻草。5.2 frida-server 连不上或者脚本加载后毫无响应这个问题的排查优先级最高。每次遇到这种报错,我第一件事就是对比 frida 客户端版本和 frida-server 设备端版本:frida --version adb shell "/data/local/tmp/frida-server --version"两个版本号必须完全对上,差一个子版本都可能出问题。版本没问题,再看端口:默认连接端口是 27042,如果这台设备之前还有别的工具占用或者代理过端口,也会导致连接失败。有时候设备上能看到frida-ps -U正常输出,但脚本一 attach 就卡住不执行。这种情况优先怀疑是 spawn 模式附加到了没有写进 AndroidManifest 的进程,或者目标 App 主动检测了注入帧从而静默停止。遇到这种我一般会先试试轻量级脚本,比如只打一行日志,确定链路通不通,再逐步加复杂度。5.3 Hook 之后 App 崩溃,十有八九是函数时序问题崩溃最常发生在三类场景:一是Java.use时机太早,类还没完成初始化就修改实现;二是 Hook 方法被高频调用,日志打印过多导致 UI 线程阻塞;三是你在implementation里没有正常调用原始方法,返回了一个类型不对的值。解决思路也很简单:不要让 Frida 脚本在主线程做太重的工作。日志量大的时候,在 JS 层做一次简单过滤,或者把日志通过send()发给 Python 侧处理。另一个技巧是在implementation里加try-catch,即使目标函数内部抛出异常,你的 Hook 逻辑也能正常结束。SignHelper.sign.implementation = function (input) { try { var result = this.sign(input); console.log("[sign] input = " + input); return result; } catch (e) { console.log("[sign] error: " + e); return null; } };5.4 多线程环境下的 Hook 回调异常Frida 的回调默认运行在目标进程内的某个线程上下文里。如果你在回调中调用了一个只能在特定线程调用的 API,就会导致崩溃或死锁。比如有些加密库要求状态只能由初始化线程访问,但 Frida 的onLeave是在函数返回时执行的,线程归属完全取决于目标函数在哪条线程上执行,轮不到你控制。遇到这种情况,不要直接在线程回调里做复杂操作。把数据收集下来,放到队列里,然后在主脚本线程统一处理。或者使用Frida的Java.scheduleOnMainThread把需要访问 UI 或主线程对象逻辑切到主线程。5.5 地址不对,加了偏移还是找不到函数Native 层 Hook 最常见的问题是模块基址对不上。原因多半是你把 64 位进程的 so 文件偏移和 32 位进程混用了。你从 IDA 里看到的偏移一般是相对 so 文件头的虚拟地址偏移,而Module.findBaseAddress("libdemo.so")返回的是它在内存中的加载地址,两者要一致,不能拿文件偏移直接加。另一个容易忽略的点是:so 可能在运行时被重新加载多次。你用Module.findBaseAddress拿到的只是其中一次加载的地址,如果后面又被加载一次,那地址就要重新获取。可以在Module.findBaseAddress之后再用Module.enumerateModules确认加载次数和地址。我把这几个常见问题整理成了一个表,方便你以后快速定位:现象最可能原因排查/解决提示类找不到ClassLoader 不对或类未加载枚举类加载器,切换classFactory.loader连接失败frida-tools 与 frida-server 版本不匹配两端版本对齐注入后 App 崩溃implementation没返回正确类型返回原始方法结果并加 try-catch脚本卡住不动注入时机太早改用 spawn 模式,加延迟启动Native 地址找不到基址获取错误或架构混用使用符号名定位,确认 32/64 位6. 把一次性脚本工程化:做成能反复执行的自动化工具6.1 从控制台粘贴脚本到 TypeScript 工程刚开始写 Frida 脚本,都是开控制台,一行行往里粘。脚本短无所谓,但脚本一旦超过两三百行,维护性就非常差。变量冲突、函数作用域混乱、字符串转义出错,都是常态。我的做法是把脚本迁到 TypeScript 工程里,用frida-compile打包。这样你可以获得完整的类型提示,特别是 Frida 提供的类型定义,比如NativePointer、NativeFunction、JavaInstance,写代码的时候大幅减少低级错误。一个典型目录结构是这样:frida-tool/ ├── agent/ │ └── index.ts ├── tools/ │ └── analyze.py ├── scripts/ │ └── hook.js └── package.jsonagent/index.ts里写所有 JavaScript 逻辑,然后用 frida-compile 把它编译成单文件hooks.js。编译产物可以直接塞给 Frida 使用,不用再维护一大堆散落的 JS 片段。TypeScript 脚本里一个常用的模式是导出方法给外界调用。比如你想把某个内存扫描工具做成可复用函数,用rpc.exports暴露出来:rpc.exports = { scanMemory(pattern: string): string[] { const results: string[] = []; const ranges = Process.enumerateRanges({ protection: "rw-", coalesce: true }); ranges.forEach(range => { Memory.scanSync(range.base, range.size, pattern).forEach(match => { results.push(match.address.toString()); }); }); return results; } };Python 侧可以直接调用这些方法,把分析工具封装成命令行接口。比如一段简单的 Python 脚本:连接 USB 设备、附加目标进程、加载编译好的脚本、调用scanMemory方法。import frida import sys def main(): device = frida.get_usb_device() session = device.attach("com.demo.app") with open("scripts/hooks.js", "r", encoding="utf-8") as f: source = f.read() script = session.create_script(source) script.load() pattern = "75 73 65 72 5f 74 6f 6b 65 6e" addresses = script.exports_sync.scan_memory(pattern) print("matched addresses:", addresses) session.detach() if __name__ == "__main__": main()这里用script.exports_sync是因为 rpc 方法被编译后,Python 可以通过同步方式调用。如果你需要异步调用,就改成script.exports.scan_memory加上await。6.2 把内存分析能力沉淀成常用命令工程化之后,你完全可以把它做成一个自己的“内存分析工具箱”。比如你给我一个目标样本,我通常会按这三步走:第一步,枚举模块和关键 so 的加载状态。先确定目标进程里有哪些模块是可疑的,哪些是系统库。这一步用Process.enumerateModules(),过滤出不在系统路径下的模块,重点关注/data/app/和/data/data/下的文件。第二步,扫描特征字符串。把目标 App 包名、已知密钥头、常见标记字符串做成一个字典,一次性扫多个 pattern。比如搜BEGIN RSA PRIVATE KEY、AES、SECRET等,快速定位敏感数据是否明文驻留在内存中。第三步,对命中的地址做上下文分析。用hexdump打印前后 64 字节,分析它是不是一个完整结构体。再用指针回溯找引用者,确认它属于哪个 Java 对象或者 Native 对象。这三步在工程化之后,基本可以做到一条命令完成。我自己的习惯是做成一个analyze.py,每次拿到新样本,先跑一遍模块扫描,再跑一遍特征字符串扫描,最后把命中的地址存成 JSON 文件。后续所有分析都基于这个 JSON 展开,不用反复重新扫。6.3 关于工程质量的一点个人经验脚本工程化不只是把代码从 JS 搬到 TS。我见过不少朋友把 Frida 脚本写成了“一次性胶水代码”,每次新需求都从旧脚本里复制粘贴,完全没有抽象。这种习惯在项目周期长、目标多的时候特别吃亏。我的建议是,把常用能力拆成独立函数:listModules、findBase、scanMemory、dumpMemory、traceJavaMethod。每一块都单独测试过,再组合使用。这样当你面对一个新样本时,不需要从头写脚本,而是把这些积木搭起来,可能十分钟就能拿到第一版分析结果。另一个经验是注意日志和数据的隔离。在目标进程里打印日志会影响性能,尤其高频函数。我一般不在 Frida 脚本里大量console.log,而是用send(message)把数据送回 Python 侧,由 Python 统一输出、存日志、统计。这样脚本本身更轻,也方便做自动化。最后,无论是写脚本还是做内存分析,都要时刻注意授权边界。Frida 的能力越强,越需要自我约束。我只在获得测试授权的应用上使用这些技术,任何越权尝试都可能带来不可控的后果。分析完成后,我也会把脚本和采集到的数据放在加密目录里,避免泄露敏感内容。写了这么久的 Frida 脚本,我个人最深的一个体会是:这个工具虽然强大,但真正让你效率倍增的,不是某个炫技操作,而是你能不能在正确的时机使用正确的 API。同样的Memory.scan,选对扫描范围和执行时机,几秒钟就出结果;选错了,扫到天荒地老也找不到。多积累一些排查经验,把常见的报错、常见的崩溃原因记下来,你就能把精力花在分析目标本身,而不是和工具较劲。