
Frida 在安全测试圈里早就不是新名词了但真正能把它讲明白、讲透的教程其实不多。绝大多数人第一次接触 Frida都是因为做 Android 应用安全评估时遇到同一个问题静态分析看了半天反编译代码还是搞不清某个函数在真实运行环境里到底被谁调用、传了什么参数、返回了什么结果。Frida 就是来解决这类问题的它以动态插桩为核心让你能把 JavaScript 代码塞进目标进程里实时查看和修改函数行为。这篇文章是 Frida 学习系列的第一篇我会把基本概念、核心组件、工作原理和最简单的上手路径一次讲清楚适合刚准备入门的测试工程师、逆向爱好者和对 App 内部运行机制好奇的开发者。1. 为什么需要 Frida从一次进程是黑盒的排查说起1.1 静态分析解决不了的问题在做移动端安全测试或者分析某个第三方库的行为时往往会陷入一个困境反编译出来的代码看起来逻辑清晰但你不知道它是不是真的按这条路走。比如一个 App 在启动时会加载某个 so 文件反编译能看到里面导出了几十个函数可是哪几个函数被调用了、调用的顺序是什么、参数是动态拼接还是从配置文件读取这些信息静态代码给不了你完整的答案。更麻烦的是有些目标是经过混淆和加固的。函数名被抹掉、字符串被加密、控制流被拍平静态分析工具的输出基本没法直接读。这时候唯一的办法就是让程序先跑起来然后到进程内部去观察它、干预它。以前做这件事的手段很笨拙改 smali 重新打包、用调试器 attach 上去打断点或者提前在源码里埋日志。改包的方式遇到签名校验就崩调试器又容易被反调试机制盯上每走一步都要和对方的防护策略搏斗。动态插桩的思路跟这些都不一样。它不需要你提前在目标代码里埋任何东西也不需要重新打包它只在你想要的时间点把一段你自己的代码塞进已经运行的进程里然后让这段代码替你去观察和操作目标。Frida 就是做这件事的工具里生态最成熟、社区最活跃的那一个。1.2 动态插桩到底是什么用一个生活中的例子来理解插桩。你在一栋大楼里办公想知道大家每天在电梯口等多久但你不可能拆了电梯重新装一个带统计功能的。现实的做法是无非两种一种是在电梯按钮旁边贴个登记表让每个人自己填这叫静态埋点另一种是请一个观察员坐在电梯口拿着秒表记录动态插桩就是后者。区别在于Frida 这个观察员不需要实体存在它是一段被注入进程的代码。注入之后它可以做到的事情包括但不限于查看进程里加载了哪些模块so、dll、动态库拦截某个函数的入口和出口记录参数、返回值、调用堆栈在函数执行前修改参数或者在返回前改写返回值直接调用进程内部的函数逼它执行某个逻辑分支读写目标进程的内存数据这些操作全部在你电脑上通过一小段 JavaScript 脚本控制目标进程本身完全感知不到运行逻辑被加料了。你不需要在目标环境里搭复杂的开发工具链只需要一个frida-server稍后细说在目标机器上跑起来。Frida 的另一个巨大优势是跨语言、跨平台。PC 端你可以操作 Windows、Linux、macOS 上的进程移动端支持 Android 和 iOS。控制端的语言有 Python、Node.js 等选择注入段统一用 JavaScript 写逻辑。这意味着学一遍基础 API后面在哪个平台都能复用大部分经验。2. Frida 的组件划分和一次注入的完整链路2.1 认识四个核心角色Frida 的生态里有几个东西经常容易混淆新手下载安装时很容易懵。先花两分钟把这几个角色分清名称运行位置做什么frida-tools你的电脑提供命令行工具比如frida、frida-ps、frida-trace本质是 Python 写的客户端frida 的编程绑定你的电脑Python 模块frida、Node.js 模块frida让你写代码控制注入和通信frida-server或 frida-gadget目标设备/进程一个守护进程负责接收电脑端的命令在目标进程里完成注入JavaScript Runtime目标进程内部Frida 把自己的 JS 引擎编译进注入段你的脚本就跑在这个运行时里frida-tools里的命令行工具可以看作是用 Python 绑定写好的现成脚本它们覆盖了很多高频操作不需要你亲自写代码就能先用起来。等你需要更定制化的逻辑时再直接用 Python 或 JS 写自己的脚本。目标机器上那个组件具体选哪个取决于你的场景。Android 需要 root 访问权限时通常用frida-server它需要以 root 身份启动因为它要能 ptrace 任意进程并注入代码。iOS 上有时用frida-server有时用frida-gadget集成进 App 中。Windows/Linux/macOS 本机调试则不需要额外起 serverfrida 会直接通过系统调试接口注入目标进程。2.2 从电脑到目标进程的一条完整链路理解整个工作流程对后面排查问题非常有帮助。一次典型的 Frida 操作链路是这样的你运行一条命令frida -U -f com.example.app电脑端的 frida 客户端通过 USB 或者网络向目标设备上的 frida-server 发起连接请求frida-server 收到指令后以指定方式让目标进程启动-f会冷启动一个 App或者附加到已运行的进程注入机制生效frida 把自己的核心 agent包含 V8/QuickJS 引擎加载进目标进程agent 启动后会创建一个用于通信的通道等待接收 JavaScript 脚本你写的脚本被发送到 agent在目标进程内解释执行脚本里注册的 hook、定时器开始工作执行结果通过通信通道实时回传给你电脑端这里有个容易被忽略的点你的 JavaScript 脚本并不是在电脑上运行它的实际运行环境是目标进程内部的内存空间。这一点会带来一些看似奇怪的现象比如脚本里定义的全局变量和进程内已有的变量是隔离的又比如你在脚本里做耗时运算可能会影响目标进程的性能表现。2.3 为什么注入语言选了 JavaScript 而不是 Python 或 C刚接触 Frida 的人几乎都会问我做安全测试最熟的是 PythonFrida 也支持 Python为什么写 hook 逻辑非得用 JavaScript原因有三个都挺实际。第一JavaScript 的运行时足够轻量可以被编译成一个 agent 模块直接注入进程。Python 解释器想塞进一个 Android 进程里体积和依赖都是灾难。JS 引擎不一样V8 和 QuickJS 都能做到相对独立的嵌入式集成。第二JS 天然是事件驱动的。hook 操作本身就是注册回调函数被调用时触发一个回调函数返回时再触发另一个回调。这种模型用 JavaScript 表达很自然Interceptor.attach里传onEnter和onLeave两个函数语义清晰。第三Frida 的 JS API 设计得足够强大把底层的指针操作、内存读写、Java 虚拟机交互全部封装成了简单的调用。你不需要真的去管 ARM 汇编里怎么切换寄存器只需要告诉 Frida我要拦截这个地址把参数作为args[0]传给我。这种抽象让没有底层开发经验的人也能快速上手。当然JS 封装也带来一个代价你不能在脚本里直接用原生类型拿到对象内容所有结果都要经过 Frida 的桥接层处理。后面实战部分我会具体演示这种转换的写法。3. 环境搭建装对版本比什么都重要3.1 电脑端安装 frida 和 frida-tools电脑端的安装方式很简单前提是 Python 3 环境正常。我用 Linux 比较多这里以 pip 安装为例pip install frida-tools这条命令会把frida-tools以及它依赖的fridaPython 模块一起装上。我建议你在一个干净的虚拟环境里安装避免和系统里的其他 Python 包发生冲突。装完之后验证一下frida --version正常情况下会输出类似于16.x.x或17.x.x的版本号。这个版本号非常关键等一下配frida-server时要严格对准。这里额外说一下Frida 的版本迭代节奏相当快每次大版本更新都可能带来 API 调整。早些年Java.perform是高频写法到 Frida 16 之后仍然能用但社区开始推荐使用Java.performNow以及更现代的 Promise 风格的 API。我实际体验下来的感觉是旧脚本不一定立刻报错但控制台会出现大量弃用警告甚至某些旧 API 的行为已经变了。所以如果你是刚入门直接学当前最新稳定版的写法别在古董教程上浪费时间。3.2 目标机器上部署 frida-server以最常见的 Android 场景为例。从 frida 官方仓库下载对应架构的frida-server文件常见的架构有arm64、arm、x86_64等一般现代手机都是arm64。把文件推送进设备adb push frida-server /data/local/tmp/ adb shell在 adb shell 里给他加执行权限并启动chmod 755 /data/local/tmp/frida-server su /data/local/tmp/frida-server 关键点frida-server的版本必须和电脑端frida版本完全一致连小版本号都不能差。主版本一致但次要版本不一致连接的时候经常报unable to connect to remote frida-server或者连上了但是脚本执行莫名失败。这个坑在我的经验里是新手遇到最多的问题之一。哪怕差一个 patch 版本我也遇到过握手失败的情况。3.3 验证环境frida-ps 应该输出什么启动完frida-server之后回到电脑端执行frida-ps -U这个命令的意思是通过 USB 连接-U列出目标设备上的进程。看到一长串进程列表说明连接链路已经通了。还可以用-ai参数列出已安装的应用方便核对应用的包名因为后面附加进程的时候要用到frida-ps -Uai输出里会显示应用名称和包名。比如某个应用显示com.example.demo这就是后面 attach 用的标识。如果你是在 Linux 本机调试一个进程就不需要 frida-server直接frida-ps就能列本机进程。这一点在分析 Linux 下的 C 程序、恶意软件样本或者自己编译的小 demo 时非常方便。3.4 关于 Frida 17版本变化带来的几个注意点前面提到的热词里有 frida 17 和 Android、Python 同时出现这个组合是有原因的。从 Frida 16 到 17社区讨论最多的是对 Python 和 Node 版本最低要求的提高一些老机器上因为系统 Python 版本过旧upgrade 到 17 之后反而装不上。我个人的建议是电脑端 Python 尽量保持 3.10、3.11 或者更高使用虚拟环境安装避免包依赖冲突一旦确定某个 frida 版本能正常工作就别急着追新除非你明确需要新版本的功能在实际项目里稳定复现比功能新更重要。毕竟你最后要交付的是分析结果而不是一个最新版本的标签。4. 第一次写脚本从附加进程到函数拦截4.1 最小脚本附加和枚举模块环境通了以后写一个最基础的 Frida 脚本感受一下链路。用 Python 客户端来加载脚本结构比较清晰import frida import sys # 目标包名可以通过 frida-ps -Uai 查到 target com.example.demo # 附加到目标进程 session frida.get_usb_device().attach(target) script session.create_script( // 枚举进程加载的所有模块 Process.enumerateModules().forEach(function(module) { console.log(module.name base module.base); }); ) script.load() sys.stdin.read()这段脚本做了什么Process.enumerateModules()遍历当前进程加载的所有模块然后逐个打印模块名和加载基址。基址这个东西在后续计算偏移地址时是关键输入base offset就是某个函数在内存里的实际地址。注意这里的console.log输出会回到你的电脑终端而不是目标进程的 logcat。如果你用frida命令行工具而非 Python 代码旁边那个 REPL 窗口会实时显示这些输出。4.2 最常见的函数拦截格式Interceptor.attach拦截一个函数的基本格式是固定的也是后面所有高级操作的地基Interceptor.attach(Module.getExportByName(null, 函数名), { onEnter: function(args) { ... }, onLeave: function(retval) { ... } });三个部分拆开来讲第一部分定位函数。Module.getExportByName(null, 函数名)会在所有模块里搜索导出函数拿到它的绝对地址。如果你确定这个函数在某个具体 so 里可以把第一个参数换成模块名例如Module.getExportByName(libfoo.so, foo)查找速度更快也更准确。第二部分onEnter。函数即将执行时触发。args是参数数组args[0]是第一个参数args[1]是第二个参数。注意这里的参数类型是基于 ARM64 调用约定的指针类型的参数表示为一个 NativePointer 对象你需要调用.readUtf8String()之类的方法去读取内容。第三部分onLeave。函数返回值之后触发。retval代表返回值你可以在它返回给调用者之前修改它。比如把返回的整数改成 0或者把返回的字符串整个换掉。举一个具体的例子假如目标 so 里有一个导出函数int calculate_discount(int price, float rate)我想看看每次调用传进来什么价格和折扣以及最终折扣结果Interceptor.attach(Module.getExportByName(libmall.so, calculate_discount), { onEnter: function(args) { var price args[0].toInt32(); var rate args[1].toInt32() / 100.0; console.log(calculate_discount( price , rate )); }, onLeave: function(retval) { console.log( retval.toInt32()); } });这个例子就是社区里高频出现的frida 拦截原有技能效果函数格式的技术原型——本质是理解函数的签名、参数的内存表示方式和返回值修改手段。不管是分析游戏里的效果结算还是分析 App 内的价格计算拦截函数的标准格式都不会变。toInt32()是把 NativePointer 或 JS 数值转成 32 位有符号整数。如果参数是字符串指针就要用args[i].readUtf8String()如果是字节数组要用args[i].readByteArray(length)。类型判断错误是新手最常遇到的报错来源比如把指针直接传给toInt32()得到的是地址而非值语义完全不同。4.3 调用目标进程的内部方法拦截只是观察层有时候你不仅要看还要主动去调用内部方法刺激某条逻辑跑一遍。Frida 在 Android 上最常见的工具是Java.perform或者它的快速版本Java.performNow。Java.performNow(function() { // 获取类 var MainActivity Java.use(com.example.demo.MainActivity); // 调用静态方法 MainActivity.helper(hello from frida); // 实例化对象 var obj MainActivity.$new(); obj.doSomething(); });Java.use是进入 Java 世界的入口拿到类之后调用静态方法、实例化对象、甚至修改字段都变得很自然。这里有个重要细节所有对 Java 对象的引用只是包装对象你不能直接把 JS 对象传进去当一个 Java 参数必须通过 Frida 的桥接完成类型转换。字符串参数可以数组得用Java.array(int, [1,2,3])自定义对象则要Java.cast。这套 API 的原理是 Frida 在目标进程中找到一个可用的 Java 虚拟机通过 JNI 接口然后动态注册自己的对象引用。所以脚本必须运行在已经加载了 Java 虚拟机的环境里Java.performNow会等待这个条件满足。4.4 一个完整的端到端示例把上面的概念串起来展示一个完整的脚本。假设我关注的 App 里有一个类com.demo.LoginManager里面有一个方法String login(String username, String password)我想做的是调用时打印用户名和密码不管原方法返回什么把返回值改成frida_mock_tokenJava.perform(function() { var LoginManager Java.use(com.demo.LoginManager); LoginManager.login.implementation function(username, password) { console.log(username username); console.log(password password); // 可以选择不调用原方法直接返回假数据 return frida_mock_token; }; });用.implementation 是替换整个方法实现这是另一种和Interceptor.attach并列的拦截模式。区别在于Interceptor.attach主要用于原生层函数保留原逻辑只是在前后加观察点Java.use(...).method.implementation用于 Java 层方法可以完全替换原逻辑也可以先调用原方法再修改结果这个例子演示了 Frida 最爽的一个特性不需要知道内部细节只需要按类名和方法签名就能精准命中目标。整个过程中 App 自己完全不知道方法被替换过。5. 实战中高频出现的几个坑和排查思路5.1 版本不一致到底会触发什么报错很多新手拿到一个frida-server就往上推和电脑端版本对不上然后来问我为什么连不上。常见的几个现象unable to connect to remote frida-server说明远端连接建立失败优先排查frida-server是否在跑、端口是否可达、版本是否匹配连接成功了但script.load()执行到一半超时多半是 agent 脚本和 server 版本不兼容IO 管道建立异常附加成功但执行 JS 时报ReferenceError: X is not defined考虑是不是 Frida 版本对应的 API 名称有变化我的处理思路是第一步永远先看版本把两端拉齐。第二步看 App 架构是arm64还是armeabi-v7a选错架构的 frida-server 根本跑不起来或者附加时直接崩溃。第三步看系统 Android 版本Android 高版本对 ptrace 和进程附加的限制更多要求 frida-server 版本也要足够新。5.2 附加不到进程是 root 权限还是保护机制Android 上附加进程失败的另一个高频原因是权限不够。部分 app 以非 root 身份运行的隔离进程frida-server没有足够的 ptrace 权限附加就会被拒绝。这种情况下用su启动 frida-server 一般能解决但有些场景依然会被 seccomp 限制。还有一个现实问题不少目标应用会主动检测调试器、检查内存里是否有 frida 的痕迹比如检测默认端口 27042或者查找 frida-agent 的特征字符串这就是反 frida的由来。从合规研究的角度我一般建议先和对方确认测试授权再做对抗研究如果是学习目的可以自己写一个 demo App 来研究检测技术的原理而不是直接拿目标应用练手。这个边界大家要拎清楚。5.3 脚本加了却毫无输出检查这几个地方假设脚本加载成功但拦截的回调一直没触发。最先检查的是函数地址是否正确。用Module.getExportByName找不到时frida控制台会打印 null但我见过不少人不看控制台只盯着自己的逻辑白白浪费半小时。其次要检查函数的符号是不是被 strip 掉了。很多 release 版 so 文件不会保留完整的导出符号导出表里只剩 JNI 接口几个入口。这时候纯靠函数名就找不到得从 ida/ghidra 里静态分析出偏移地址再用Module.findBaseAddress(libfoo.so)加偏移算出真实地址。最后是时机问题。如果目标 so 还没被加载getExportByName自然找不到。要么等模块加载完毕再 hook要么用Module.load主动加载或者在 IPC 里监听模块加载事件。Android 里很多 so 是到特定功能触发后才 dlopen你 hook 早了没用hook 晚了第一波调用已经过去了。5.4 脚本性能和行为副作用不可忽略最后提醒一点Frida 注入之后目标进程不再是原来的运行状态你的每个 hook 回调都会在进程内执行 JS 代码性能和副作用都会反映到目标 App 上。比如你在onEnter里做了一大堆滚动字符串输出hook 的函数又被高频调用App 很可能肉眼可见地卡顿甚至触发 ANR。所以正式分析时我通常这套实践尽量少打印必要的信息汇总后一次性输出拦截点宁少勿多定位到关键函数再 hook用Script.postMessage把数据批量发回电脑端不要全走console.log临时修改逻辑的 hook 用完立即卸载别留着干扰后续步骤Frida 学习的进阶路径其实很陡第一步能清楚理解它往进程里塞了一个 JS 引擎这件事后面所有的 API 学习都会变得顺理成章。我自己刚接触时也纠结过不少时间在环境问题上把版本和管理机制这些前置概念理清之后后面的路就好走了。这一篇先把基本概念和最小链路打通第二篇我会接着写更贴近真实场景的函数追踪与数据提取技巧。