Android逆向实战:Frida动态Hook非标准加密算法的完整指南
1. 项目概述:当标准Hook工具遇上“野路子”算法
在Android逆向与安全分析的日常里,Frida早已成为我们手中的“瑞士军刀”,用它来Hook标准加密库(如OpenSSL、BouncyCastle)里的AES、RSA、MD5、SHA系列算法,流程几乎已经标准化了。你找到函数偏移,写个JavaScript脚本,挂上去,参数和返回值一目了然,整个过程行云流水。但现实往往比教科书复杂得多——当你兴致勃勃地打开一个目标应用,发现它的核心数据保护压根没用这些“名门正派”的算法,而是用了一套自研的、魔改的,或者从某个古老库扒出来的“非标准”算法时,那种感觉就像一拳打在了棉花上。
这就是“非标准算法场景”Hook要解决的问题。它可能是一个把字节顺序完全颠倒的“变异Base64”,一个用固定密钥做循环异或的“山寨RC4”,一个自己定义的哈希拼接函数,甚至是把几种简单算法组合起来的“四不像”。这些算法没有公开的、标准的函数签名,其实现可能藏在某个.so文件的犄角旮旯,或者被开发者用C++模板、内联汇编搞得面目全非。传统的、基于已知函数名的Hook方式在这里几乎失效。
这篇实战指南,就是专门为攻克这种“野路子”算法场景而写。无论你是移动安全研究员、应用逆向工程师,还是对Android Native层Hook有浓厚兴趣的开发者,如果你已经熟悉Frida的基本操作,但在面对“黑盒”算法时感到无从下手,那么这里的内容将为你提供一套完整的侦查、定位、分析与Hook的实战思路。我们将绕过那些标准算法的“康庄大道”,深入探索如何在一片看似混乱的指令和数据中,精准地抓住非标准算法的“七寸”,并让它在我们的脚本下原形毕露。
2. 核心思路与侦查定位策略
面对非标准算法,第一步也是最关键的一步,不是写Hook脚本,而是找到它。由于没有标准的函数名可供搜索,我们的侦查工作必须更细致、更依赖动态分析和逻辑推理。
2.1 目标特征分析与入口点推测
首先,我们需要明确什么是“非标准算法”的特征。通常,它们会表现出以下几点:
- 数据变换:输入一段明文(如字符串“123456”),输出一段看起来像乱码的数据(可能长度不变或变化)。
- 密钥参与:算法内部很可能使用了一个或多个硬编码在代码中的密钥,或者从网络、本地配置中动态获取的密钥。
- 集中调用:在应用内,加解密或哈希计算往往发生在特定的业务环节,例如登录请求的密码字段处理、本地敏感配置文件的读取、网络通信包体的封装等。
因此,我们的入口点可以从这些业务场景入手:
- 网络流量:使用抓包工具(如Charles, Fiddler, mitmproxy)拦截应用请求。寻找那些请求体或响应体中包含的、明显是经过编码或加密的字段(长得像Base64但解码失败,或是一串无规律的十六进制字符串)。记录下这些数据出现的位置和触发操作。
- 文件与数据库:检查应用的私有目录(
/data/data/包名/)或外部存储,寻找可能存储加密数据的文件或SQLite数据库。使用adb shell和cat、strings命令初步查看内容。 - 日志与系统调用:在目标应用运行时,通过
logcat过滤其日志,有时开发者会意外留下调试信息。更高级的方法是使用strace或Frida的Stalker来追踪系统调用,特别是文件读写(open,read,write)和密码学相关库(如libcrypto)的调用,但非标准算法可能不依赖这些库。
2.2 动态追踪与关键代码定位
确定了可疑的数据后,就需要在代码执行过程中“下桩”,找到处理这些数据的具体代码位置。这里Frida的动态插桩能力大放异彩。
2.2.1 基于内存访问模式的追踪
一个非常有效的策略是:追踪对已知明文或密文内存地址的访问。
- 获取数据内存地址:首先,你需要让目标数据出现在内存中。例如,让应用触发一次登录,此时密码明文会短暂地出现在内存的某个地方。你可以写一个Frida脚本,Hook诸如
strlen,memcpy,NSString.initWithString(iOS)或Java层的String.getBytes等方法,来捕获这些字符串对象的指针或内存地址。 - 使用MemoryAccessMonitor:Frida提供了一个强大的
MemoryAccessMonitorAPI。一旦你获得了目标数据的内存地址(或地址范围),就可以让Frida监控所有对该内存区域的读、写或执行访问。当非标准算法的函数读取这个明文进行加密,或者写入加密结果时,Frida会立即回调你的脚本,并给出访问该内存的指令地址(PC)和上下文。这个指令地址,极有可能就在目标算法函数内部或其调用的函数里。
// 示例:监控对特定地址范围的读取操作 const targetAddress = ptr("0x7fe8b1c2a000"); const size = 0x100; const monitor = new MemoryAccessMonitor({ base: targetAddress, size: size }); monitor.on('access', function (details) { console.log(`内存访问 at ${details.from} (PC): ${details.operation} to ${details.address}`); // details.from 就是调用者的代码地址,是逆向的关键线索 // 可以在这里打印调用栈,帮助定位函数 console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n')); }); monitor.enable();2.2.2 基于堆栈与回溯的定位
如果内存监控不够直接,或者你想了解函数的调用链,回溯(Backtrace)是另一个利器。你可以在疑似入口点(如某个处理网络数据的JNI函数、某个文件读写的Native函数)设置Hook,然后打印并分析调用栈。
// Hook一个可能的入口函数,例如某个JNI函数 let targetFunc = Module.findExportByName(null, "Java_com_example_app_NativeHelper_encryptData"); if (targetFunc) { Interceptor.attach(targetFunc, { onEnter: function(args) { console.log(`[+] encryptData 被调用`); // 打印当前线程的调用栈 let backtrace = Thread.backtrace(this.context, Backtracer.ACCURATE); console.log('调用栈:'); for (let i = 0; i < backtrace.length; i++) { let address = backtrace[i]; let symbol = DebugSymbol.fromAddress(address); console.log(` ${i}: ${address} - ${symbol}`); } } }); }通过分析调用栈,你可能会发现一个在多个不同业务场景下都被调用的公共函数,那很可能就是算法核心所在。
注意:动态追踪会产生大量日志,可能严重影响应用性能甚至导致崩溃。务必在测试环境进行,并考虑添加过滤条件,只监控关键线程或模块。
3. 非标准算法的识别与逆向分析
通过动态追踪,我们可能获得了一个或多个可疑的函数地址。接下来,就需要静动结合,深入这些函数内部,理解其逻辑。
3.1 静态分析与反汇编
使用反汇编工具(如IDA Pro, Ghidra, radare2)加载目标应用的.so文件(通常位于lib/目录下,如libnative-lib.so)。根据Frida动态获取的函数地址,在反汇编器中导航到对应位置。
分析要点:
- 函数概览:观察函数的开头(序言)和结尾(尾声),估算其栈帧大小、使用的寄存器。这有助于理解其调用约定。
- 循环结构:非标准算法几乎必然包含循环。寻找
for、while循环的特征(如计数器寄存器、条件跳转指令B.NE,BNE,CMP+Jxx)。循环体内部的操作是分析的重点。 - 算术与逻辑运算:重点关注
ADD,SUB,XOR,AND,OR,SHL/SHR(移位)等指令。异或(XOR)操作在简单加密和哈希中非常常见。 - 查表操作:一些算法会使用S盒(Substitution-box)。如果看到函数内部引用了一个大的静态数据数组(通常在
.rodata段),并且用输入值作为索引去查表,这很可能是一个替换步骤。 - 常量与密钥:在指令中直接出现的立即数,或者从某个固定内存地址加载的数据,可能就是算法的密钥或常量(例如,CRC32算法的多项式)。在反汇编器中标记这些数据。
3.2 动态验证与数据流跟踪
静态分析有时会因代码混淆或复杂度高而受阻。此时,需要用Frida进行动态验证,即“边运行边看”。
3.2.1 寄存器与内存状态监控
Hook目标函数,在函数入口(onEnter)和出口(onLeave)打印关键寄存器(如ARM下的R0-R3传递参数,X0在ARM64下作为第一个参数和返回值)和栈内存的内容。
let targetAddress = Module.findBaseAddress('libtarget.so').add(0x1234); // 假设的目标函数偏移 Interceptor.attach(targetAddress, { onEnter: function(args) { this.inputPtr = args[0]; // 假设第一个参数是输入数据指针 this.inputSize = args[1]; // 假设第二个参数是输入大小 console.log(`[>] 函数进入。输入指针: ${this.inputPtr}, 大小: ${this.inputSize}`); if (this.inputPtr && !this.inputPtr.isNull()) { console.log(hexdump(this.inputPtr, { length: this.inputSize.toInt32(), ansi: true })); } // 保存上下文,用于onLeave比较 this.savedRegisters = { x1: this.context.x1, x2: this.context.x2, // ... 保存其他感兴趣的寄存器 }; }, onLeave: function(retval) { console.log(`[<] 函数离开。返回值: ${retval}`); if (retval && !retval.isNull()) { // 假设返回值是指向输出数据的指针 let outputSize = 32; // 需要根据算法推断或动态获取输出大小 console.log(hexdump(retval, { length: outputSize, ansi: true })); } // 比较寄存器变化 if (this.context.x1 != this.savedRegisters.x1) { console.log(`寄存器X1变化: ${this.savedRegisters.x1} -> ${this.context.x1}`); } } });3.2.2 指令级追踪(Stalker)
对于极其复杂或混淆严重的函数,Frida的Stalker可以跟踪该函数内执行的每一条指令。这会产生海量数据,但能让你精确看到数据是如何在寄存器和内存间流动的。通常,你需要结合过滤条件,只跟踪涉及特定寄存器或内存地址的指令。
let targetFunc = ...; // 目标函数地址 Stalker.follow(Process.getCurrentThreadId(), { events: { // 可以收集call, ret, exec等事件 }, onReceive: function (events) { // 处理指令事件,可以反汇编并输出 console.log(Stalker.parse(events, { stringify: true })); } }); // 在合适的时候调用 Stalker.unfollow实操心得:
Stalker对性能影响巨大,且输出难以直接分析。它通常是最后的手段。更常见的做法是,先用简单的InterceptorHook函数入口/出口和内部可能的关键子函数(通过静态分析猜测),缩小范围后再对关键代码块使用Stalker。
3.3 算法逻辑归纳与模拟
通过静动结合的分析,你应该能归纳出算法的核心步骤,例如:
- 初始化:设置初始状态(如初始化变量、加载密钥)。
- 循环处理:对输入数据的每个字节或块进行一系列操作(异或、加减、查表、移位)。
- 最终处理:可能进行最后的混淆或格式转换(如输出Hex或Base64)。
尝试用Python或JavaScript模拟这个逻辑。用你在动态调试中捕获的已知输入-输出对作为测试用例。如果模拟结果与真实输出一致,恭喜你,你已经成功逆向了这个非标准算法。这个模拟脚本本身,也可以作为验证后续Hook是否正确的重要工具。
4. 定制化Hook脚本的编写与实践
成功逆向算法后,编写Hook脚本就变得有针对性了。我们的目标不仅仅是打印输入输出,可能还包括:动态修改密钥、篡改计算结果、或者直接替换算法逻辑。
4.1 基础Hook:捕获输入与输出
这是最直接的需求。假设我们已经找到了函数custom_encrypt,它接受一个输入缓冲区指针和长度,返回一个输出缓冲区指针(可能是新分配的)。
let libBase = Module.findBaseAddress('libtarget.so'); // 假设通过分析,我们知道 custom_encrypt 的偏移是 0xA760 let encryptFunc = libBase.add(0xA760); Interceptor.attach(encryptFunc, { onEnter: function(args) { console.log(`\n=== custom_encrypt 被调用 ===`); this.inputPtr = args[0]; this.inputLen = args[1].toInt32(); // 读取并打印输入 if (this.inputPtr && this.inputLen > 0) { let inputBytes = this.inputPtr.readByteArray(this.inputLen); console.log(`输入长度: ${this.inputLen}`); console.log(`输入(Hex): ${Array.from(new Uint8Array(inputBytes)).map(b => b.toString(16).padStart(2, '0')).join(' ')}`); console.log(`输入(ASCII): ${Memory.readUtf8String(this.inputPtr)}`); // 如果是字符串 } // 可以在这里保存输入,用于和输出对比 this.startTime = Date.now(); }, onLeave: function(retval) { let elapsed = Date.now() - this.startTime; console.log(`函数执行耗时: ${elapsed}ms`); // 读取并打印输出。需要知道输出长度,这可能需要逆向得知,或假设与输入等长。 let outputPtr = retval; let outputLen = this.inputLen; // 假设是分组加密,输出等长。实际情况可能不同! if (outputPtr && !outputPtr.isNull() && outputLen > 0) { let outputBytes = outputPtr.readByteArray(outputLen); console.log(`输出(Hex): ${Array.from(new Uint8Array(outputBytes)).map(b => b.toString(16).padStart(2, '0')).join(' ')}`); // 有时输出可能是Base64,可以尝试转换 // console.log(`输出(Base64): ${base64Encode(outputBytes)}`); } console.log(`=== 调用结束 ===\n`); } });4.2 进阶Hook:篡改算法行为
4.2.1 修改输入参数如果你想在加密前修改明文,可以在onEnter中操作。
onEnter: function(args) { this.originalInputPtr = args[0]; this.originalInputLen = args[1].toInt32(); let originalBytes = this.originalInputPtr.readByteArray(this.originalInputLen); // 例如,将输入的第一个字节改为0x41 ('A') originalBytes[0] = 0x41; // 分配新内存存放修改后的数据 this.modifiedInputPtr = Memory.alloc(this.originalInputLen); this.modifiedInputPtr.writeByteArray(originalBytes); // 替换原指针参数 args[0] = this.modifiedInputPtr; // 注意:如果函数内部会释放这个内存,你需要更复杂的策略,或者确保不破坏原逻辑。 }4.2.2 修改密钥或常量如果算法密钥是作为参数传入,修改方式同上。如果密钥是硬编码在函数内部的全局变量或常量,你需要先定位到这个变量的内存地址。
- 定位密钥地址:在静态分析中找到密钥数据所在的内存地址(例如,在IDA中看到
0x123450: DCB 0x12, 0x34, ...)。这个地址通常是相对于模块基址的偏移(RVA)。 - 计算绝对地址:在运行时,模块基址会变,但偏移不变。
密钥绝对地址 = Module.findBaseAddress('libtarget.so').add(密钥RVA)。 - Hook并修改:你可以直接修改这块内存。
let libBase = Module.findBaseAddress('libtarget.so'); let hardcodedKeyAddr = libBase.add(0x20500); // 假设密钥RVA是0x20500 let originalKey = hardcodedKeyAddr.readByteArray(16); // 假设密钥长16字节 console.log(`原始密钥: ${Array.from(originalKey).map(b=>b.toString(16).padStart(2,'0')).join(' ')}`); // 修改密钥 let newKey = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; hardcodedKeyAddr.writeByteArray(newKey); console.log(`密钥已修改为: ${Array.from(newKey).map(b=>b.toString(16).padStart(2,'0')).join(' ')}`);4.2.3 替换整个算法函数(函数Hook)如果你想完全绕过原算法,用自己的逻辑计算结果并返回,可以在onLeave中操作retval。但这要求你非常清楚函数的调用约定和内存管理责任(谁分配,谁释放)。
onLeave: function(retval) { // 假设原函数返回一个指向新分配内存的指针,里面是加密结果 // 1. 计算我们自己的结果 let myOutput = myCustomAlgorithm(this.inputPtr, this.inputLen); // 你的模拟算法 // 2. 分配内存存放我们的结果 let myOutputPtr = Memory.alloc(myOutput.length); myOutputPtr.writeByteArray(myOutput); // 3. 替换返回值 retval.replace(myOutputPtr); // 4. 注意:原函数返回的内存可能泄漏,取决于具体情况。在简单测试中可能忽略。 }4.3 处理复杂参数与结构体
非标准算法的参数可能不是简单的指针和整数,而是结构体。例如,一个算法上下文ctx,里面包含了状态、密钥、计数器等。
- 定义结构体:根据逆向分析,用Frida的
StructType定义对应的结构体。 - 解析参数:将指针转换为结构体对象,方便访问成员。
// 假设逆向分析得到如下结构体 const CustomCtx = new StructType({ 'state': 'uint32', 'key': 'uint8[16]', 'counter': 'uint64', 'buffer': 'pointer' }); Interceptor.attach(encryptFunc, { onEnter: function(args) { let ctxPtr = args[0]; // 第一个参数是 ctx 结构体指针 let inputPtr = args[1]; let inputLen = args[2]; // 将指针解析为结构体 let ctx = CustomCtx.wrap(ctxPtr); console.log(`算法状态: ${ctx.state}`); console.log(`内部密钥: ${Array.from(ctx.key).map(b=>b.toString(16).padStart(2,'0')).join(' ')}`); console.log(`计数器: ${ctx.counter}`); // 修改结构体成员 ctx.counter = 0x1000; // 篡改计数器 } });5. 实战难点排查与稳定性优化
在实际操作中,你会遇到各种预期之外的问题。下面是一些常见难点及其排查思路。
5.1 常见问题速查表
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
Frida脚本注入失败,提示Permission denied或进程崩溃 | 1. 目标应用有反调试/反Frida检测。 2. SELinux策略限制。 3. 非root设备上附加高权限进程受限。 | 1. 尝试使用frida -U --no-pause -f 包名在应用启动时注入,而非附加。2. 使用对抗检测的脚本(如绕过 fopen、readlink检测)。3. 对于系统应用,尝试在root环境下操作,或使用Magisk模块修改SELinux规则(风险高)。 4. 检查是否使用了正确的架构( frida -U -f 包名 --runtime=v8或--runtime=qjs)。 |
| Hook后应用功能异常或闪退 | 1. Hook的函数被频繁调用,日志刷屏导致阻塞。 2. 在 onEnter/onLeave中执行了耗时操作。3. 错误地修改了寄存器或内存,破坏了函数逻辑。 4. 没有正确处理函数调用约定(如ARM的 R0-R3传参,多余参数入栈)。 | 1. 在Hook回调中添加条件判断,只处理关心的调用(如特定线程、特定参数值)。 2. 将耗时的操作(如网络请求、复杂计算)放到 setImmediate中异步执行。3. 仔细检查对 this.context寄存器的修改,确保符合ARM/ARM64 ABI。4. 使用 NativeFunction创建函数指针时,确保参数和返回类型声明正确。 |
获取到的指针为NULL或读内存出错 | 1. 参数不是指针,或者你误解了参数顺序。 2. 指针在函数执行过程中才被赋值。 3. 内存页没有读取权限。 | 1. 在onEnter和onLeave中都打印参数,观察变化。使用ptr(args[n])进行安全转换。2. 尝试Hook函数内部更晚的代码位置(如果可能)。 3. 使用 Memory.protect()尝试修改内存权限(需谨慎,可能崩溃)。4. 使用 try-catch包裹读内存操作,避免脚本因单次错误而停止。 |
| 动态获取的函数地址每次运行都不同 | 1. ASLR(地址空间布局随机化)导致模块基址变化。 2. 函数在运行时通过 dlopen动态加载。 | 1.永远使用偏移量(RVA)。函数地址 = Module.findBaseAddress('模块名').add(函数偏移)。2. 对于动态库,监听 dlopen事件,等库加载后再Hook。Module.load('库名.so')后其基址才稳定。 |
| 算法逻辑复杂,静态分析困难 | 代码混淆、控制流扁平化、大量使用内联函数。 | 1.动态分析为主:在多个关键点下断点(Hook),观察数据流。 2.使用符号执行或模拟器(如Unicorn引擎)辅助分析代码片段,但这需要较高技能。 3.“黑盒”测试:如果目标只是获取输入输出关系,可以构造大量测试用例,用Frida批量调用目标函数并记录结果,尝试用机器学习或统计分析推测算法(适用于简单算法)。 |
5.2 脚本稳定性与性能优化技巧
条件化Hook与过滤:不要无差别地记录每一次调用。根据调用栈、线程ID、参数特征进行过滤。
Interceptor.attach(func, { onEnter: function(args) { let callingThreadId = this.threadId; if (callingThreadId != thisTargetThreadId) return; // 只Hook特定线程 if (args[1].toInt32() < 10) return; // 只处理输入长度大于10的调用 // ... 你的逻辑 } });异步与延迟执行:在回调中执行
console.log或复杂计算是同步的,会阻塞目标线程。使用setImmediate或setTimeout将其放入事件循环。onEnter: function(args) { let inputPtr = args[0]; let inputLen = args[1]; // 立即保存必要信息到`this`上下文中 this.snapshot = { ptr: inputPtr, len: inputLen }; // 将耗时的操作异步化 setImmediate(() => { let bytes = this.snapshot.ptr.readByteArray(this.snapshot.len); // 处理bytes,可以发送到PC端或进行复杂分析 send({ type: 'data', payload: Array.from(bytes) }); }); }使用
send()和recv()进行进程间通信:如果分析逻辑很重,最好在PC端的Python脚本中处理。Frida脚本只负责采集数据,通过send()发送给PC端,PC端处理完后再通过recv()回调发送指令。// agent.js onEnter: function(args) { let data = ...; send({ myData: data }); // 发送到PC端 } // pc端python def on_message(message, data): if 'payload' in message: # 处理message['payload'] # 可以发送指令回agent script.post({'type': 'command', 'operation': 'modify_key'})模块化与脚本管理:对于大型项目,将不同功能的Hook(如算法Hook、反调试绕过、UI监控)写成独立的JS文件,在主脚本中动态加载和管理,提高可维护性。
6. 从Hook到算法还原与复现
Hook的终极目的之一,往往是完全理解并能在独立环境中复现该算法。当你通过Hook收集到足够多的输入输出对,并动态观察了内部状态后,可以开始着手复现。
- 编写算法模拟代码:使用你熟悉的语言(Python、C、JavaScript)将分析出的算法步骤实现出来。用Hook收集到的数据作为测试向量。
- 交叉验证:在目标应用环境中,用你的模拟算法计算结果,与Hook捕获的真实结果对比。确保在所有测试用例下都完全一致。
- 处理边界情况:注意算法对输入长度、编码、填充等的处理。这些细节往往在Hook时容易被忽略,却是复现失败的主要原因。
- 生成独立库或工具:将复现的算法封装成独立的函数、类或库。这可以用于后续的批量解密、自动化测试或集成到其他安全工具中。
整个流程从动态侦查开始,到静态分析辅助理解,再到精准Hook验证和修改,最后完成算法复现,形成了一个完整的逆向工程闭环。面对非标准算法,耐心和细致的观察比任何高级工具都更重要。每一次成功的Hook和逆向,都是对底层系统理解的一次深化。
