安卓So层Hook实战:Frida逆向分析底层函数与加密算法
2026/8/12 10:57:40 网站建设 项目流程

1. 项目概述:为什么So层Hook是安卓安全研究的“深水区”

在安卓应用逆向与安全分析的圈子里,Hook技术就像是研究者的“手术刀”,能够动态地修改和监控应用的行为。我们常说的Hook,根据目标的不同,主要分为Java层Hook和Native层(So层)Hook。Java层Hook,比如用Xposed或者Frida去Hook一个Activity的onCreate方法,相对直观,因为Java代码逻辑清晰,方法签名明确。但当你面对一个核心算法被编译进.so动态库、关键验证逻辑藏在C/C++代码里的应用时,Java层的“手术刀”就钝了,你必须潜入更底层的“深水区”——So层。

So层Hook之所以被称为“深水区”,是因为它直接与操作系统和硬件架构对话。这里没有Java虚拟机的保护伞,内存地址、寄存器、指令集(ARM/ARM64/x86)成了你每天要打交道的对象。一个.so文件,本质上是编译后的机器码,Hook它意味着你要在二进制指令的海洋里,精准地找到那个目标函数,并改变它的执行流程。这不仅仅是技术难度的提升,更是对研究者底层功底和调试耐心的巨大考验。Frida作为一个动态插桩工具,其强大之处在于它提供了一套相对统一的API,让我们能够用JavaScript(或Python)这种高级语言,去完成对底层二进制世界的“遥控”,极大地降低了So层Hook的入门门槛和操作复杂度。这篇文章,就是带你从理论到实战,完整地走一遍用Frida进行安卓So层Hook的流程,无论你是想分析加密算法、绕过安全检测,还是单纯对底层技术充满好奇,都能在这里找到可落地的答案。

2. So层Hook的核心原理与Frida的角色

要理解So层Hook,必须先抛开Java世界面向对象的那套思维,进入系统编程和进程内存的视角。

2.1 So库与进程内存空间

一个安卓应用在运行时,其进程内存空间是结构化的。Java代码运行在Dalvik或ART虚拟机中,而So库(如libnative-lib.so)则是由系统链接器(Linker)加载到进程的特定内存区域。这个区域通常包含代码段(.text,存放可执行指令)、数据段(.data/.bss,存放全局/静态变量)等。当应用调用一个So库中的原生函数时,实际上是通过一个存储在内存中的函数指针,跳转到对应代码段的指令地址开始执行。

2.2 Hook的本质:拦截与重定向

Hook的本质,就是改变这个执行流程。传统的方式,比如基于LD_PRELOAD的库劫持,或者直接修改内存中的指令(Inline Hook),其核心思想都是:找到目标函数在内存中的地址,修改该地址处的指令,使其跳转到我们自定义的代码(Hook函数)去执行,在我们自定义的代码执行完毕后,可以选择再跳转回原函数继续执行,或者完全替换其逻辑。

这个过程涉及几个关键步骤:

  1. 定位(Find):确定目标函数在内存中的绝对地址。这需要解析ELF(So文件格式)文件头,计算符号偏移,并加上So库加载的基地址。
  2. 备份(Backup):保存目标地址处即将被覆盖的原始指令,以便后续恢复或继续执行。
  3. 注入(Inject):将跳转指令(例如,ARM架构的BBL指令,x86的JMP指令)写入目标地址,使其指向我们Hook函数的地址。
  4. 执行(Execute):在Hook函数中,我们可以读取、修改函数的参数和返回值,记录调用信息,或者完全改变函数行为。

2.3 Frida如何简化这一切

手动完成上述步骤是极其繁琐且容易出错的,尤其是处理不同CPU架构的指令集差异和地址对齐问题时。Frida的价值就在于它封装了这些底层细节。

  • 注入引擎:Frida通过frida-server(在目标设备上运行)将自身注入到目标进程中,获得在该进程内存空间中执行代码的能力。
  • JavaScript绑定:它提供了一个强大的JavaScript运行时环境。我们写的Hook脚本是JavaScript,但Frida在背后通过其原生(C)模块,将这些JS调用转换为对目标进程内存的实际操作。
  • Interceptor API:这是So层Hook的核心。Interceptor.attach(targetAddress, callbacks)这个API,看上去很简单,但Frida在背后默默完成了所有脏活累活:它计算并应用了正确的跳转指令,管理了原始指令的备份和上下文(寄存器、栈)的保存与恢复。我们只需要关心onEnter(函数进入时)和onLeave(函数离开时)这两个回调函数里要做什么。
  • 内存操作APIMemory.readByteArray,Memory.writeUtf8String,Memory.alloc等API,让我们能够像操作JavaScript数组一样,安全地读写目标进程的任意内存地址,这对于分析函数参数(尤其是结构体指针)至关重要。

注意:Frida的便利性建立在对其内部机制的一定信任上。在极端追求稳定性和隐匿性的场景下(如对抗某些强检测的环境),可能需要回归到手写Inline Hook或PLT Hook,但Frida绝对是学习、研究和快速验证想法的最佳工具。

3. 实战环境搭建与目标分析

工欲善其事,必先利其器。一个稳定的实验环境是成功的第一步。

3.1 环境准备清单

  1. 测试设备/模拟器

    • 推荐模拟器:对于初学者,雷电模拟器、夜神模拟器是不错的选择,它们自带Root权限,方便调试。本文示例使用雷电模拟器(Android 7.1,64位)。确保在模拟器设置中开启VT(虚拟化技术)以获得最佳性能。
    • 真机:如果需要测试更真实的设备环境或特定架构(如ARM v8-A),一台已Root的安卓手机是必要的。重要提示:Root手机会带来安全风险,请仅在用于研究的专用设备上操作。
  2. Frida生态安装

    • PC端(开发机):通过Python的pip安装Frida客户端和工具包。
      pip install frida-tools
      安装后,命令行应能识别fridafrida-ps等命令。
    • 目标端(安卓设备/模拟器):这是关键一步。你需要下载与目标设备架构匹配的frida-server
      • 在PC上执行adb shell getprop ro.product.cpu.abi查看设备架构(常见输出:arm64-v8a,armeabi-v7a,x86_64)。
      • 前往Frida的GitHub Releases页面,下载对应版本的frida-server-xx.x.x-android-xx.xz。例如,对于64位ARM设备,应下载android-arm64版本。
      • 解压得到frida-server文件,通过adb推送到设备并赋予执行权限:
      adb push frida-server /data/local/tmp/ adb shell "chmod 755 /data/local/tmp/frida-server"
    • 运行Frida服务端:在设备上启动服务端。建议使用&让它在后台运行,并重定向输出到空设备以避免阻塞shell。
      adb shell su cd /data/local/tmp ./frida-server &
    • 验证连接:在PC上执行frida-ps -U,如果能看到设备上的进程列表,说明连接成功。
  3. 目标应用与分析工具

    • 选择一个包含So库的简单应用作为练习目标。你可以自己用Android Studio写一个包含JNI调用的Demo App,这样你完全清楚内部逻辑,便于验证Hook效果。
    • 反编译工具jadx-gui用于快速查看Java代码和So库的JNI函数映射。
    • 逆向分析工具IDA ProGhidra用于静态分析So库,理解函数逻辑和确定Hook点。radare2也是一个强大的免费选择。

3.2 目标应用分析:定位Hook点

假设我们有一个简单的Demo应用,它有一个libnative-lib.so,其中导出了一个JNI函数Java_com_example_demo_MainActivity_stringFromJNI,这个函数内部调用了另一个内部函数int add(int a, int b),我们最终目标是Hook这个内部的add函数。

分析步骤如下:

  1. Java层定位:用jadx-gui打开APK,找到调用原生方法的Java类。你会看到类似native String stringFromJNI();的声明。记下完整的类名和方法名。
  2. So库导出函数:So库中供Java调用的JNI函数名称是固定的格式。使用readelf -s libnative-lib.so | grep Java或直接在IDA中查看导出函数表,就能找到Java_com_example_demo_MainActivity_stringFromJNI的地址。但这通常不是我们的最终目标,因为JNI函数封装了复杂的JNIEnv操作。
  3. 深入So内部:用IDA加载libnative-lib.so,找到Java_com_example_demo_MainActivity_stringFromJNI函数,进行反编译。在伪代码中,你很可能看到它调用了某个内部函数,比如add(x, y)。这个add函数在导出表中是不可见的(局部符号),我们需要通过偏移地址来定位它。
  4. 计算绝对地址:Hook需要的是函数在内存中运行时的绝对地址。公式是:函数绝对地址 = So库加载基地址 + 函数在文件内的偏移地址
    • 基地址:在Frida中,可以通过Module.findBaseAddress('libnative-lib.so')动态获取。
    • 偏移地址:在IDA中,函数add的地址显示的是文件偏移地址(例如0x1234)。在IDA开头的Segments视图里,查看.text代码段的虚拟地址(VirtAddr,例如0x1000)。那么函数在内存中的相对虚拟地址(RVA)约为0x1234。更精确的做法是直接使用IDA中函数所在的虚拟内存地址(VA),但需要确认IDA的加载基址是否与运行时一致。一个稳妥的方法是:偏移地址 = IDA中显示的add函数地址 - IDA中.text段的起始虚拟地址

实操心得:对于未导出函数,除了计算偏移,还有一个更常用的方法——模式匹配(Pattern Search)。在IDA中找到add函数开头的一段独特的机器码序列(例如 “01 0A 40 F9 02 0B 40 F9”),在Frida脚本中使用Memory.scanSync在So库的代码段内搜索这个模式,来动态定位函数地址。这种方法不依赖于固定的偏移,兼容性更好,尤其是在So库有多个版本或经过混淆时。

4. Frida So层Hook脚本编写实战

理论铺垫完毕,现在让我们动手写一个完整的Frida JavaScript脚本,来Hook上面提到的内部add函数。

4.1 基础Hook脚本框架

首先,我们编写一个基础的脚本,它能够附加到目标进程,并Hook指定的函数。

// hook_so_add.js Java.perform(function () { console.log("[*] Script loaded. Starting SO hook..."); // 1. 指定目标库名称 var libName = "libnative-lib.so"; // 2. 获取SO库的加载基地址 var baseAddr = Module.findBaseAddress(libName); if (baseAddr) { console.log("[+] Found", libName, "at", baseAddr); } else { console.log("[-] Failed to find", libName); return; } // 3. 计算目标函数绝对地址(假设我们已经通过IDA分析得到偏移) // 假设 add 函数的文件偏移是 0x1234, .text段VirtAddr是 0x1000 // 那么 RVA = 0x1234 - 0x1000 = 0x234 var offsetAdd = 0x234; // 这是你需要根据自己分析修改的值 var addAddr = baseAddr.add(offsetAdd); // baseAddr + offset console.log("[*] Calculated add function address:", addAddr); // 4. 使用Interceptor进行Hook Interceptor.attach(addAddr, { // onEnter: 当函数被调用时执行,args是传入的参数列表 onEnter: function (args) { console.log("\n[=== add Function Called ===]"); // 在ARM64上,前几个整型参数通常通过寄存器X0, X1, X2...传递,Frida帮我们抽象成了args[0], args[1]... // 我们需要根据函数签名来理解args。对于 int add(int a, int b): this.arg0 = args[0].toInt32(); // 第一个int参数 this.arg1 = args[1].toInt32(); // 第二个int参数 console.log(` arg0 (a): ${this.arg0}`); console.log(` arg1 (b): ${this.arg1}`); // 我们可以修改参数 // args[1] = ptr(100); // 例如,把第二个参数强制改为100 }, // onLeave: 当函数返回时执行,retval是返回值 onLeave: function (retval) { // 返回值可能是指针或整型,根据函数返回类型转换 var result = retval.toInt32(); console.log(` Original return value: ${result}`); // 我们可以修改返回值 var newRetval = this.arg0 + this.arg1 + 1000; // 例如,给结果加1000 console.log(` Modified return value: ${newRetval}`); retval.replace(ptr(newRetval)); // 替换返回值 console.log("[=== add Function Leave ===]\n"); } }); console.log("[+] Hook for 'add' has been installed."); });

脚本解析与关键点

  • Java.perform:确保我们的代码在Frida的Java运行时中执行,这是访问Frida API所必需的。
  • Module.findBaseAddress:动态获取基地址,这是实现跨版本兼容的关键。
  • Interceptor.attach:核心Hook API。第一个参数是目标函数的绝对地址NativePointer类型)。
  • onEnter中的this:在回调函数中,this指向一个对象,用于在onEnteronLeave之间传递信息。我们将参数保存到this上,以便在onLeave中使用。
  • argsretval:它们都是NativePointer类型。需要使用.toInt32(),.toInt64(),.readCString()等方法将其转换为可读的值。如何转换完全取决于被Hook函数的原始签名
  • retval.replace():用于修改函数的返回值。

4.2 处理复杂参数与结构体

现实中的函数参数远不止整型那么简单。经常需要处理字符串、数组、结构体指针。

示例:Hook一个参数为字符串的函数假设函数void logMessage(char* msg)

Interceptor.attach(logMessageAddr, { onEnter: function (args) { // args[0] 是一个指向C风格字符串(char*)的指针 var messagePtr = args[0]; // 从指针读取UTF-8字符串 var message = messagePtr.readUtf8String(); console.log(`[logMessage] Input: ${message}`); // 甚至可以修改字符串内容 // 首先分配一块新内存,写入新字符串 var newMessage = "Hooked: " + message; var newPtr = Memory.allocUtf8String(newMessage); // 替换原指针(注意:这要求函数内部不会对原指针进行free等操作,否则可能崩溃) args[0] = newPtr; }, onLeave: function (retval) { // void函数,无返回值 } });

示例:读取结构体成员假设函数int processUser(User* user),其中User是一个结构体,包含int id; char name[32];

// 首先,我们需要知道结构体在内存中的布局(偏移量) // 假设通过逆向分析得知:id 在偏移0处,name 在偏移4处(32位系统int占4字节) Interceptor.attach(processUserAddr, { onEnter: function (args) { var userPtr = args[0]; // User* 指针 // 读取id (4字节整型) var userId = userPtr.add(0).readInt(); // 从指针位置+0偏移处读int // 读取name (以0结尾的字符串) var userName = userPtr.add(4).readUtf8String(); // 从指针位置+4偏移处读字符串 console.log(`Processing User - ID: ${userId}, Name: ${userName}`); } });

注意事项:处理指针和结构体是So层Hook中最容易导致应用崩溃的地方。务必确保:

  1. 你对目标函数的调用约定(如ARM的AAPCS64)和参数传递方式有基本了解。
  2. 你读取的内存地址是有效的、可读的。尝试读取未映射的内存会引发崩溃。
  3. 修改指针或内存内容时,要考虑原函数的内存管理逻辑(谁分配,谁释放),避免内存泄漏或重复释放。

4.3 使用“模式搜索”动态定位未导出函数

对于没有导出符号的内部函数,前面计算偏移的方法很脆弱。更健壮的方法是搜索函数特征码。

function hookAddByPattern() { var libName = "libnative-lib.so"; var baseAddr = Module.findBaseAddress(libName); if (!baseAddr) return; // 在IDA中查看add函数的开头机器码,例如 ARM64 可能是 F8 5F BC A9 F6 57 01 A9 ... // 我们取前8-12个字节作为特征码。注意要去掉地址部分。 var pattern = "F8 5F BC A9 F6 57 01 A9"; // 将十六进制字符串模式转换为字节数组 var patternBytes = pattern.split(" ").map(function (b) { return parseInt(b, 16); }); // 扫描整个.text段(通常具有可执行权限) // 首先找到代码段 var module = Process.getModuleByName(libName); var ranges = module.enumerateRanges('r-x'); // 查找可读可执行的内存范围(即代码段) ranges.forEach(function (range) { console.log(`[*] Scanning range: ${range.base} - ${range.base.add(range.size)}`); // 使用Memory.scanSync进行同步扫描(对于大范围,异步scan更佳) var results = Memory.scanSync(range.base, range.size, pattern); if (results.length > 0) { console.log(`[+] Found pattern at: ${results[0].address}`); // 假设第一个结果就是我们的目标函数 var addAddr = results[0].address; // 安装Hook(代码同上) Interceptor.attach(addAddr, { onEnter: function(args) { /* ... */ }, onLeave: function(retval) { /* ... */ } }); } }); } Java.perform(hookAddByPattern);

5. 高级技巧与实战问题排查

掌握了基础Hook后,我们来看看如何应对更复杂的场景和常见问题。

5.1 挂钩(Hooking)JNI函数本身

有时,我们可能需要直接Hook JNI函数,例如Java_com_example_MainActivity_stringFromJNI。方法与Hook内部函数类似,但参数是JNIEnv和jclass/jobject。

var jniFuncAddr = Module.findExportByName("libnative-lib.so", "Java_com_example_demo_MainActivity_stringFromJNI"); Interceptor.attach(jniFuncAddr, { onEnter: function(args) { // args[0] 是 JNIEnv* // args[1] 是 jclass 或 jobject (调用者) console.log("[+] JNI function called!"); // 可以通过JNI API调用原始方法,但需谨慎 }, onLeave: function(retval) { // retval 是 jstring 等Java对象引用 var javaString = Java.vm.getEnv().getStringUtfChars(retval, null); console.log(`JNI function returned: ${javaString}`); } });

5.2 处理多线程与同步问题

So库函数可能被多个线程同时调用。Frida的Interceptor回调本身是线程安全的,但你的JavaScript代码如果操作共享资源(比如一个全局的日志数组),就需要考虑同步。

// 一个简单的线程安全计数器示例 var callCount = 0; var lock = new Lock(); // Frida 提供的简单锁 Interceptor.attach(targetAddr, { onEnter: function(args) { lock.acquire(); callCount++; var currentThreadId = Process.getCurrentThreadId(); console.log(`[Thread-${currentThreadId}] Call #${callCount}`); this.threadId = currentThreadId; lock.release(); }, onLeave: function(retval) { lock.acquire(); console.log(`[Thread-${this.threadId}] Leave`); lock.release(); } });

5.3 绕过简单的Frida检测

一些安全加固的应用会检测Frida的存在。常见检测手段包括:

  • 检测端口:默认的Frida-server监听在27042端口。可以尝试使用-l 0.0.0.0:9999参数启动frida-server,改变监听端口。
  • 检测进程名/文件:检查/proc/self/maps/proc/self/task/pid/fd中是否包含frida字符串。可以在编译frida-server时重命名相关特征。
  • 检测线程名:Frida会注入一些特定名称的线程(如gmain,gdbus)。这需要修改Frida源码或使用更隐蔽的注入方式。

在脚本层面,一个简单的对抗是避免使用太有特征的模块名和函数名。

5.4 常见问题排查速查表

问题现象可能原因排查步骤与解决方案
TypeError: cannot read property 'add' of null未找到So库基地址。1. 确认库名拼写正确,包括后缀.so
2. 确认目标进程已加载该库(Process.enumerateModules())。
3. 对于系统库,可能需要等待其延迟加载,使用setTimeout延迟Hook。
Error: access violation accessing 0x...访问了无效的内存地址。1. 检查计算出的函数地址是否正确。
2. 在读写内存前,使用Memory.isReadable(address)检查。
3. 确认参数索引(args[0],args[1])是否符合函数调用约定。
Hook后应用立即崩溃指令覆盖错误或上下文破坏。1.最常见原因:Hook了函数的前几条指令,但这些指令包含重要的PC相对寻址(如adrp)。Frida的Interceptor通常能处理,但某些特殊指令序列可能导致问题。尝试Hook函数稍后的位置(如addAddr.add(4)),但需确保不会跳过关键参数设置。
2. 在onEnter/onLeave中错误地修改了寄存器或栈内容。
脚本注入成功,但无日志输出Hook点错误,或函数未被调用。1. 使用console.log(Module.findExportByName(...))验证函数地址是否有效。
2. 在脚本开头打印所有加载的模块,确认目标库已加载。
3. 尝试Hook一个已知的、肯定会调用的导出函数(如strlen)来测试脚本基础功能。
frida-ps -U能看到进程,但脚本无法附加应用具有反调试或ptrace保护。1. 尝试使用frida -U --no-pause -f com.example.app在应用启动时注入。
2. 某些加固会防止附加,需要更底层的绕过手段,这超出了基础Frida的范围。

5.5 性能考量与脚本优化

  • 减少console.log:频繁的日志输出会极大拖慢目标进程,甚至导致检测。在稳定后,考虑将日志写入文件或仅在需要时开启。
  • 避免阻塞操作:不要在onEnter/onLeave回调中执行网络请求、复杂文件IO等耗时操作,这会阻塞目标线程。
  • 使用Interceptor.detachAll:在脚本结束时,或需要重新Hook时,清理之前的所有Hook,避免残留。

6. 从Hook到主动调用:完成行为闭环

Hook是观察和修改,但有时我们需要“主动”让目标函数执行,例如调用一个加密函数来生成我们需要的密文。这需要用到NativeFunction

假设我们已经知道了add函数的地址和签名(int (int a, int b))。

Java.perform(function () { var baseAddr = Module.findBaseAddress("libnative-lib.so"); var addAddr = baseAddr.add(0x234); // 目标函数地址 // 创建一个NativeFunction对象 // 第一个参数:函数地址(NativePointer) // 第二个参数:返回值类型('int', 'pointer', 'void'...) // 第三个参数:参数类型数组(['int', 'int']) // 第四个参数:ABI(调用约定),通常用'default',对于iOS/某些特殊场景可能需要'sysv'或'win64' var addFunc = new NativeFunction(addAddr, 'int', ['int', 'int'], 'default'); // 现在,我们可以像调用普通JS函数一样调用它! var result = addFunc(5, 3); console.log(`Active call add(5, 3) = ${result}`); // 输出 8 // 甚至可以和Hook结合:先Hook,然后在Hook回调里主动调用原函数 var originalAdd = null; Interceptor.attach(addAddr, { onEnter: function(args) { console.log(`Intercepted call: ${args[0].toInt32()} + ${args[1].toInt32()}`); // 保存原函数引用(如果需要绕过Hook执行原逻辑) originalAdd = addFunc; }, onLeave: function(retval) { // 如果想在Hook后仍然执行原逻辑并获取结果,可以这样做: // var trueResult = originalAdd(this.savedArg0, this.savedArg1); // console.log(`True result was: ${trueResult}`); } }); });

主动调用的核心挑战在于确定函数签名(参数类型、个数、顺序)和调用约定。这通常需要结合逆向分析(看它如何被其他代码调用)和文档(如果是公开API)来确定。一个错误的签名会导致栈不平衡,必然崩溃。

7. 实战案例:逆向一个简单的加密函数

让我们综合运用以上知识,完成一个小目标:逆向一个So库中的encrypt函数,该函数接收一个字符串,返回加密后的十六进制字符串。

  1. 静态分析(IDA):打开So库,找到encrypt函数。分析发现其签名可能是char* encrypt(const char* input)。内部调用了malloc分配内存,并返回指针。
  2. 编写Hook脚本
    Java.perform(function() { var encryptAddr = Module.findExportByName('libcrypto.so', 'encrypt'); // 假设是导出函数 Interceptor.attach(encryptAddr, { onEnter: function(args) { this.input = args[0].readUtf8String(); console.log(`[encrypt] Input: ${this.input}`); // 保存输入,用于后续分析 }, onLeave: function(retval) { // 返回值是一个新分配的char*指针 var output = retval.readUtf8String(); console.log(`[encrypt] Output: ${output}`); // 记录输入输出对,用于分析加密逻辑 console.log(`Pair: "${this.input}" -> "${output}"`); // 注意:原函数内部malloc了内存,调用者需要负责free。 // 我们这里只是读取,不要尝试free(retval)。 } }); });
  3. 动态收集数据:运行Hook脚本,触发应用的各种加密操作,收集大量的(明文,密文)对。
  4. 分析算法:根据输入输出对,尝试推断加密算法。例如,如果输出长度固定,可能是哈希(MD5, SHA1);如果输出长度与输入相关,可能是AES/ DES CBC模式;可以尝试输入有规律的数据(如全零、递增序列)观察输出变化。
  5. 主动调用验证:一旦你推测出算法(比如是简单的XOR),就可以用NativeFunction主动调用encrypt,或者更彻底地,用JavaScript/ Python重新实现该算法,验证其正确性。

这个从Hook观察,到分析理解,再到主动调用或重实现的过程,就是So层逆向分析的完整闭环。它赋予你的不仅仅是对某个应用的破解能力,更是对底层代码逻辑的深刻洞察力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询