Frida动态代码插桩框架:从原理到实战的移动安全与逆向工程指南

📅 2026/8/18 0:06:04
Frida动态代码插桩框架:从原理到实战的移动安全与逆向工程指南
1. 从“黑盒”到“白盒”为什么我们需要Frida在移动安全、逆向工程甚至是一些自动化测试的场景里我们经常会遇到一个让人头疼的问题面对一个编译好的、没有源代码的应用程序我们如何知道它在运行时内部发生了什么它调用了哪个函数传递了什么参数返回了什么结果修改了哪个内存地址传统的静态分析工具如IDA Pro, Ghidra能帮我们看到代码的“骨架”但程序运行时的“血肉”——那些动态加载的库、即时编译的代码、运行时构造的对象——却像是一个黑盒。Frida的出现就是为了把这个黑盒变成白盒。它本质上是一个动态代码插桩Dynamic Code Instrumentation框架。你可以把它想象成一个超级显微镜和手术刀的结合体。显微镜让你能实时观察程序内部任何你感兴趣的代码点的执行情况函数调用、参数、返回值而手术刀则允许你在程序运行时动态地修改这些代码的逻辑插入你自己的代码。这听起来有点像调试器但Frida比传统调试器更轻量、更灵活、更“脚本化”。你不需要在庞大的IDE里设置复杂的断点而是用几行JavaScript或Python脚本就能实现对目标进程的精准“窥探”和“操控”。无论是Android、iOS、Windows、macOS还是Linux上的进程Frida都能提供统一的操作接口。我最初接触Frida是为了分析一个Android应用的网络加密协议。当时用抓包工具只能看到一堆乱码静态分析又因为代码混淆而举步维艰。直到用了Frida我直接在加密函数被调用时“拦下”它打印出传入的明文和密钥一切豁然开朗。这种“所见即所得”的能力是其他工具难以比拟的。2. Frida的核心架构与工作原理Agent与注入要理解Frida怎么用得先明白它是怎么工作的。Frida的架构是经典的客户端-服务器C-S模型但它的“服务器”跑在目标进程里。2.1 核心组件拆解Frida-Core (Server端) 这是Frida的引擎核心。当我们要分析一个目标进程比如一个Android App时Frida会通过某种方式后面会讲将一小段代码我们称之为frida-server或frida-gadget注入到目标进程中。这段代码就在目标进程内部创建了一个“服务器”它负责监听来自外部的连接和指令。Frida-Python/Node.js等 (Client端) 这是我们在自己电脑上使用的控制端。我们编写Python或Node.js脚本通过这些脚本与目标进程内的frida-server进行通信。我们发送JavaScript代码过去frida-server负责在目标进程内执行这些JS代码并将结果返回给我们的控制端脚本。注入引擎 (Injector) 这是把frida-server送进目标进程的关键。在Android上通常通过adb shell以root权限启动一个守护进程frida-server然后我们的控制端脚本通过USB或网络连接到设备上的这个服务再由它去注入到具体的App进程中。在非越狱的iOS或一些加固环境中则可能需要将frida-gadget一个动态库直接打包进目标应用。2.2 工作流程简述启动与连接 在电脑上你运行一个Python脚本。脚本通过frida.get_usb_device()或frida.get_device_manager().enumerate_devices()找到连接的设备比如Android手机。附加或启动进程 脚本使用device.attach(“进程名”)来附加到一个正在运行的进程或者用device.spawn([“可执行文件路径”])启动一个新进程并附加。创建会话与脚本 附加成功后会创建一个Session对象。然后你通过session.create_script(js_code)方法将你写好的JavaScript监控代码我们叫它Frida脚本加载到目标进程中。执行与交互 调用script.load()你的JS代码就在目标进程的上下文中执行了。它开始Hook挂钩你指定的函数监听事件。你的Python脚本可以通过定义script.on(‘message’, callback)来接收JS脚本发回的消息如打印的日志、函数参数等。整个过程你的JavaScript代码就像一颗被植入目标程序大脑的“芯片”可以读取和影响它的“思维”。注意注入需要权限。在Android上分析系统应用或其它应用通常需要root权限。在iOS上则需要越狱环境。对于非越狱iOS或某些情况可以使用需要修改应用包体的frida-gadget方式但这超出了基础范围。3. 环境搭建与“Hello World”从零到第一次Hook理论说再多不如动手一试。我们以最常用的Android Python环境为例走通一个完整的流程。3.1 环境准备清单一台已Root的Android设备或模拟器 这是最简单的方式。我推荐使用官方Android Studio自带的x86_64系统镜像的模拟器并通过adb root命令获取root权限避免真机风险。Python环境 确保你的电脑安装了Python 3.7或以上版本。安装Frida客户端工具 在电脑的命令行中执行pip install frida-tools这个命令会同时安装fridaPython绑定库和frida-ps、frida-ls-devices等命令行工具。下载并运行frida-server去Frida的GitHub Releases页面找到与你的frida-tools版本匹配的frida-server。注意设备架构通常是android-arm64或android-x86_64。下载后解压得到frida-server文件。通过adb push frida-server /data/local/tmp/将其推送到设备。adb shell进入设备shell然后执行cd /data/local/tmp chmod 755 frida-server ./frida-server 现在设备上的frida-server就在后台运行了。3.2 第一个脚本Hooklibc.so的strlen函数我们的目标是Hook一个几乎每个程序都会用的基础库函数——C标准库libc.so里的strlen用来计算字符串长度。首先在设备上随便启动一个App比如计算器com.android.calculator2。然后在电脑上新建一个Python脚本hook_strlen.pyimport frida import sys # JavaScript监控代码 jscode Java.perform(function () { // 1. 获取要Hook的模块这里是libc.so的基地址 var libc Module.findBaseAddress(libc.so); if (libc) { console.log([*] libc.so base address: libc); // 2. 计算strlen函数的绝对地址。 // 在真实场景中我们通常用Module.findExportByName或Module.getExportByName // 这里为了演示绝对地址计算假设我们知道偏移量实际中需要分析获取 // 我们先使用更通用的方法 var strlen_addr Module.findExportByName(libc.so, strlen); if (strlen_addr) { console.log([*] strlen address: strlen_addr); // 3. 使用Interceptor.attach来Hook这个地址 Interceptor.attach(strlen_addr, { // 函数被调用时onEnter onEnter: function (args) { // args[0]是第一个参数即字符串指针 // 将它读出来注意编码。这里假设是UTF-8字符串 try { var str Memory.readUtf8String(args[0]); console.log([*] strlen called with: \ str \); // 我们可以把参数保存到上下文里供onLeave使用 this.inputStr str; } catch (e) { console.log([*] Failed to read string at: args[0]); } }, // 函数返回时onLeave onLeave: function (retval) { // retval是返回值是一个NativePointer对象 console.log([*] strlen returned: retval); // 可以打印之前保存的输入 if (this.inputStr) { console.log( Input was: \ this.inputStr \); } } }); } else { console.log([-] Could not find strlen export.); } } else { console.log([-] libc.so not loaded in this process.); } }); def on_message(message, data): if message[type] send: print(f[*] Message from script: {message[payload]}) else: print(message) # 主逻辑 def main(): # 连接到USB设备 try: device frida.get_usb_device() except Exception as e: print(f[-] Failed to get USB device: {e}) sys.exit(1) print(f[*] Connected to device: {device}) # 附加到目标进程这里用计算器举例你需要换成你设备上正在运行的应用 target_process com.android.calculator2 try: pid device.spawn([target_process]) # 如果应用未启动用spawn session device.attach(pid) device.resume(pid) # spawn后需要恢复进程 print(f[*] Attached to process: {target_process} (PID: {pid})) except Exception as e: # 如果应用已在前台运行通常用attach try: session device.attach(target_process) print(f[*] Attached to existing process: {target_process}) except Exception as e2: print(f[-] Failed to attach/spawn process: {e2}) sys.exit(1) # 创建并加载脚本 script session.create_script(jscode) script.on(message, on_message) print([*] Loading script...) script.load() # 保持脚本运行等待输入退出 print([*] Script loaded. Press Enter to stop...) sys.stdin.read() # 清理 session.detach() if __name__ __main__: main()运行这个脚本python hook_strlen.py然后在手机的计算器里进行一些操作比如输入数字。你会在电脑的控制台上看到源源不断的输出显示strlen函数被调用了以及它处理的字符串内容可能是一些内部路径、标签文本等。恭喜你完成了第一次Hook这个例子虽然简单但包含了Frida脚本的核心要素Module对象查找模块和函数地址Interceptor.attach进行挂钩onEnter和onLeave回调处理参数和返回值以及Memory对象来读写进程内存。4. 深入JavaScript API操控进程的瑞士军刀Frida的强大很大程度上体现在其提供给JavaScript环境的丰富API上。这些API是你与目标进程交互的桥梁。4.1 模块与内存操作Module MemoryModule 用于枚举已加载的模块Module.enumerateModules()、查找模块基址Module.findBaseAddress(‘模块名’)、查找导出函数地址Module.findExportByName(‘模块名’, ‘函数名’)或符号地址Module.getExportByName。踩坑点 模块名不一定是你想的那个。在Android上系统库通常是libc.so、libart.so、libdvm.so等。第三方库则包含其完整名称如libnative-lib.so。使用Module.enumerateModules()打印出来看看最保险。Memory 用于读写目标进程的内存。核心方法有Memory.readByteArray(address, size): 读取一段内存为字节数组。Memory.writeByteArray(address, bytes): 将字节数组写入内存。Memory.readUtf8String(address),Memory.readUtf16String(address): 读取C风格字符串。Memory.alloc(size): 在目标进程分配内存非常有用。Memory.protect(address, size, protection): 修改内存页保护属性如改为可写可执行‘rwx’。经验 读写内存时一定要注意地址对齐和大小端问题。对于指针使用ptr(“0x1234”)来构造NativePointer对象。4.2 函数拦截与调用Interceptor NativeFunctionInterceptor 动态插桩的核心。Interceptor.attach(targetAddress, callbacks): Hook一个地址。callbacks对象包含onEnter和onLeave方法。onEnter的args是一个包含参数的数组onLeave的retval是返回值。Interceptor.replace(targetAddress, replacement): 直接替换函数实现。replacement是一个NativeCallback函数。重要技巧 在onEnter中可以通过this.context访问CPU寄存器如this.context.pc,this.context.lr(ARM链接寄存器)。在onLeave中可以通过修改retval的值来改变函数返回值retval.replace(newValue)。NativeFunction 用于在目标进程的上下文中创建一个新的本地函数指针或者调用一个已知地址的函数。// 调用一个已知函数例如int func(const char* str, int len) var funcAddr Module.findExportByName(libtarget.so, my_func); var myFunc new NativeFunction(funcAddr, int, [pointer, int]); var result myFunc(Memory.allocUtf8String(test), 4); console.log(Result:, result);踩坑点 调用约定stdcall,cdecl,fastcall等必须正确。Frida通常能自动处理但在一些特殊平台或优化情况下可能需要指定。参数和返回值的类型标识符如‘int’,‘pointer’,‘float’要写对。4.3 针对Java层的操作Java API对于Android的Java/Kotlin层Frida提供了另一套强大且直观的API封装在Java对象下。Java.perform(fn): 所有Java层操作必须包裹在这个回调函数中以确保在正确的线程上下文中执行。Java.use(“className”): 获取一个Java类的包装对象用于Hook其静态方法或创建实例。Java.perform(function() { var StringClass Java.use(java.lang.String); // Hook构造函数 StringClass.$init.overload(java.lang.String).implementation function(str) { console.log(String created with: str); // 调用原构造函数 return this.$init(str); }; // 调用静态方法 var result StringClass.valueOf(123); console.log(valueOf result: result); });Java.choose(“className”, callbacks): 在堆上枚举已存在的某个类的所有实例并对每个实例执行回调。这在寻找特定对象时极其有用。Java.choose(com.example.SecretManager, { onMatch: function(instance) { console.log(Found instance: instance); // 可以调用实例的方法 var secret instance.getSecret(); console.log(Secret is: secret); }, onComplete: function() { console.log(Search complete.); } });Java.enumerateLoadedClasses(callbacks): 枚举所有已加载的类。对象转换与字段操作Java.cast(obj, className): 将对象转换为特定类型。通过instance.fieldName.value读写字段。注意私有字段也可以访问。实现Implementation与重载Overload Hook成员方法时需要指定重载参数类型列表。overload方法用于选择正确的函数签名implementation属性用于替换实现。var CryptoClass Java.use(com.example.Crypto); CryptoClass.encrypt.overload(java.lang.String, java.lang.String).implementation function(key, data) { console.log(Encrypt called. Key: key , Data: data); // 可以修改参数或返回值 var originalResult this.encrypt(key, data); // 调用原方法 console.log(Original result: originalResult); return HookedResult; // 返回假数据 };核心技巧 如果不知道重载签名可以先不写.overload(...)直接设置.implementationFrida会打印出所有可用的重载让你选择。或者用Java.use(“ClassName”).methodName.implementations查看。5. 实战用例剖析破解一个简单的登录校验让我们用一个更贴近实战的例子来串联上述API。假设我们有一个Android应用其登录逻辑在本地有一个校验函数com.example.app.AuthHelper.checkPassword(String input)返回boolean。我们的目标无论输入什么密码都让登录成功。5.1 信息搜集与分析首先我们需要定位这个类和方法。如果已经有APK可以用jadx-gui等反编译工具静态分析找到关键的类和方法名。如果只有安装包我们可以用Frida动态探索。探索脚本 (explore.js):Java.perform(function() { // 枚举所有已加载的类寻找包含Auth字样的 Java.enumerateLoadedClasses({ onMatch: function(className) { if (className.toLowerCase().indexOf(auth) ! -1) { console.log([*] Found class: className); // 尝试获取类并列出其方法 try { var clazz Java.use(className); var methods clazz.class.getDeclaredMethods(); console.log( Methods:); for (var i in methods) { console.log( methods[i].toString()); } } catch (e) { // 可能类不可用忽略 } } }, onComplete: function() { console.log([*] Class enumeration complete.); } }); });通过这个脚本我们可能找到com.example.app.AuthHelper类及其checkPassword方法。5.2 编写Hook脚本确认目标后编写Hook脚本 (hook_login.py及其JS部分)// hook_login.js Java.perform(function() { console.log([*] Starting login bypass script...); var AuthHelper Java.use(com.example.app.AuthHelper); // Hook checkPassword 方法 AuthHelper.checkPassword.overload(java.lang.String).implementation function(input) { console.log([*] checkPassword called! Input: \ input \); // 关键打印调用栈看看是谁调用了这个函数有助于理解逻辑 console.log(Java.use(android.util.Log).getStackTraceString(Java.use(java.lang.Exception).$new())); // 调用原方法看看正常返回什么 var originalResult this.checkPassword(input); console.log([*] Original checkPassword result: originalResult); // 我们的破解永远返回 true var hackedResult true; console.log([*] Returning HACKED result: hackedResult); return hackedResult; }); // 有时候校验可能是静态方法 // AuthHelper.checkPasswordStatic.overload(java.lang.String).implementation function(...){...}; console.log([*] Hook installed successfully.); });5.3 处理可能的问题方法重载 如果checkPassword有多个重载例如一个接收String一个接收String, Context你需要用overload指定正确的签名或者Hook所有重载。返回值类型 确保implementation函数的返回值类型与原方法一致。这里是boolean。调用原方法 在implementation里通过this.checkPassword(input)可以调用原方法。注意this指的是当前对象实例对于实例方法。如果是静态方法需要用AuthHelper.checkPassword.call(null, input)或直接AuthHelper.checkPassword(input)。绕过证书绑定SSL Pinning 如果登录请求走HTTPS且应用做了证书绑定即使绕过本地校验网络请求也会失败。这时需要额外Hook网络库如OkHttp,HttpURLConnection的证书验证相关方法。这又是一个常见的Frida用例可以使用现成的脚本如frida-multiple-unpinning。5.4 运行与验证将JS代码嵌入Python脚本如第3节所示附加到目标应用进程触发登录操作。你会在日志中看到Hook生效并且应用应该会成功登录即使密码错误。这个例子展示了Frida在安全评估和逆向中的典型用途理解程序逻辑、定位关键点、动态修改行为。6. 高级技巧与疑难杂症处理掌握了基础我们来看看一些能提升效率和解决复杂问题的进阶技巧。6.1 脚本的持久化与通信消息传递 JS脚本中可以用send({type: ‘log’, payload: data})发送任意JSON序列化数据到Python端。Python端通过script.on(‘message’, callback)接收。这适合传输大量或结构化的数据。RPC远程过程调用 这是Frida的杀手锏之一。你可以在JS脚本中暴露函数给Python端调用。// JS端 rpc.exports { add: function (a, b) { return a b; }, getSecretData: function () { var result ... // 在目标进程内执行一些操作 return result; } };# Python端 script.load() api script.exports print(api.add(2, 3)) # 调用JS端的add函数返回5 secret api.get_secret_data() # 注意方法名在Python中变为蛇形命名这使得你的Python控制端脚本可以主动向目标进程“查询”状态而不仅仅是被动接收消息。6.2 处理加固与反调试很多应用会使用加固技术来防止逆向和调试它们也可能检测Frida。检测Frida 常见检测点包括检查进程名、端口默认27042、映射文件、特定环境变量、加载的库等。对抗手段改名 将frida-server改名成其他名字如/system/bin/surfaceflinger。改端口 启动frida-server时指定非默认端口./frida-server -l 0.0.0.0:8080连接时在Python脚本中指定device frida.get_device_manager().add_remote_device(‘192.168.1.100:8080’)。使用定制编译的Frida 修改Frida源码中的特征字符串再编译。Hook反检测代码本身 用Frida去Hook那些检测Frida的函数让它们总是返回“未检测到”。这有点“我Hook我自己”的味道但往往有效。你需要先静态分析或动态追踪找到检测代码的位置。应对加固 对于函数抽取、代码混淆等Frida Hook的时机很重要。通常需要在类被加载之后、方法被首次执行之前进行Hook。Java.perform内部的操作是线程安全的并且会等待VM初始化完成。对于Native层的加固可能需要等到特定解密函数执行完毕、真实代码被映射到内存后再进行Hook这需要更精细的时机控制。6.3 性能考量与稳定性Hook过多函数 尤其是在频繁调用的函数如Log.d上做复杂的Hook操作会显著拖慢目标进程甚至导致卡顿或崩溃。尽量精确Hook并在不需要时及时detach。内存泄漏 在JS的onEnter中创建的对象如通过Java.use创建的包装器如果被长期引用可能导致内存不被释放。对于短期Hook这不是大问题。对于长期驻留的脚本要注意管理对象生命周期。异常处理 JS脚本中的异常如果不被捕获可能导致整个脚本停止工作。务必用try-catch包裹可能出错的代码块并通过send或console.log输出错误信息。6.4 工具链与生态frida-trace 命令行工具可以快速追踪函数调用。例如frida-trace -U -i “open” com.example.app会追踪目标App中所有对open函数的调用。非常适合初次探索。Objection 基于Frida的运行时移动安全评估工具集成了很多常用命令如android hooking list classes,ios keychain dump可以交互式操作极大提升效率。各种现成脚本 GitHub上有大量针对特定应用或通用功能的Frida脚本如解密算法还原、协议抓取、游戏修改等学习这些脚本是快速提升的途径。7. 从用例到思维Frida还能做什么通过上面的登录破解例子我们看到了Frida在安全测试中的用途。但其应用远不止于此。自动化测试 模拟极端情况如Hook网络返回注入特定的错误数据来测试App的健壮性。Hook文件读写模拟存储空间不足。协议分析 对于使用自定义加密协议的网络应用直接Hook加密/解密函数打印输入输出可以快速还原协议格式而不需要逆向复杂的算法逻辑。游戏修改 修改游戏内的金币、血量、分数等数值。通常需要找到存储这些数值的类或函数。隐私合规检测 Hookandroid.location.LocationManager、android.telephony.TelephonyManager等API监控应用在何时、以何种频率收集了用户的敏感信息。原生库Native Library分析 对于*.so文件中的C/C函数Frida同样可以Hook。这对于分析核心算法、加密实现至关重要。你需要熟悉目标平台的ABI和汇编基础。动态脱壳 一些加固方案会在内存中解密出原始的DEX或so代码。Frida可以在解密完成后将内存中的完整代码DUMP到文件中实现脱壳。Frida不是一个“一招鲜”的工具它提供的是底层的能力。你能用它做什么取决于你对目标系统的理解和你自己的想象力。它把程序的运行时变成了一个你可以编程操控的沙盒。这种能力让逆向工程和安全研究从单纯的静态“考古”变成了动态的“交互式实验”。我个人最深的体会是学习Frida最大的障碍不是API的复杂度而是逆向思维和调试耐心。很多时候你花了大量时间找到的Hook点可能不对或者Hook后程序崩溃了。这就需要你仔细分析崩溃日志、检查参数类型、确认调用约定甚至单步跟踪汇编指令。这个过程很磨人但每一次成功的Hook都像解开一道谜题带来巨大的成就感。从简单的strlen到复杂的OLLVM混淆过的Native函数再到整个App的业务逻辑流转Frida让你能够一层层剥开软件的“外壳”直抵核心。