安卓So层无导出函数Hook实战:从特征定位到Arm64偏移计算
在移动安全领域,对抗加固和混淆的技术始终是一场攻防双方的拉锯战。当开发者将关键函数隐藏在So层并抹去导出表信息时,传统的Hook方法往往束手无策。本文将深入探讨如何结合静态分析与动态调试技术,精准定位无导出函数的内存地址,并针对Arm32/Arm64架构差异提供完整的Frida Hook解决方案。
1. So层Hook技术基础
1.1 有导出与无导出函数的本质区别
在ELF文件格式中,导出表(.dynsym节)就像函数的"通讯录",记录了符号名与地址的映射关系。有导出函数可以直接通过Module.findExportByName定位,而无导出函数则如同"隐形人",需要更精细的追踪手段:
// 典型的有导出函数声明 extern "C" void exposed_func() { LOGD("This function is exported"); } // 典型的无导出函数声明 __attribute__((visibility("hidden"))) void hidden_func() { LOGD("This function is hidden"); }两种函数在IDA中的表现差异明显:
| 特征 | 有导出函数 | 无导出函数 |
|---|---|---|
| IDA视图 | 显示函数名 | 显示为sub_XXXX |
| 符号表可见性 | .dynsym节可见 | 仅.symtab节可能保留 |
| 定位方式 | 直接名称查询 | 需特征匹配或偏移计算 |
1.2 Frida的Native Hook原理
Frida通过动态二进制插桩(DBI)技术实现So层Hook,其核心流程分为三步:
- 基址定位:获取目标So模块的加载基地址
- 地址计算:基址 + 函数偏移 = 绝对内存地址
- 插桩执行:通过Interceptor替换原函数执行流
// 基本Hook模板 Interceptor.attach(targetAddress, { onEnter: function(args) { console.log("Entering function at:", this.returnAddress); }, onLeave: function(retval) { console.log("Returning:", retval); } });2. 无导出函数定位技术
2.1 静态特征分析法
当函数没有导出符号时,我们需要在IDA中通过以下特征进行人工定位:
- 字符串引用:查找函数内使用的独特字符串
- 交叉引用:分析调用该函数的上级函数
- 指令模式:识别特定的汇编指令序列
- 参数特征:观察寄存器/栈的使用方式
以实际案例说明,假设我们需要定位一个校验函数:
- 在IDA Strings窗口搜索"Verification failed"等关键字符串
- 右键跳转到引用该字符串的代码位置
- 向上追溯函数入口点(通常以STP/LDP指令开头)
2.2 动态行为追踪法
当静态分析受阻时,可结合动态调试:
// 监控So模块的所有函数调用 const module = Process.getModuleByName('libtarget.so'); const ranges = module.enumerateRanges('r-x'); ranges.forEach(range => { Memory.scan(range.base, range.size, "ff 43 ? d1", { onMatch: function(address, size) { console.log("Found potential function prologue at:", address); } }); });常用Arm64函数特征码:
| 指令类型 | 特征字节码 | 说明 |
|---|---|---|
| 函数开头 | ff 43 ? d1 | STP指令常见组合 |
| 字符串引用 | ? ? ? 58 | LDR指令加载字符串 |
| 函数结尾 | c0 03 5f d6 | RET指令 |
3. Arm64架构下的偏移计算
3.1 基址获取与偏移修正
在Android系统中,So模块的加载地址会受ASLR影响每次不同:
// 获取模块基址的正确方式 const moduleBase = Module.findBaseAddress('libtarget.so'); console.log("Module base:", moduleBase); // 计算绝对地址(Arm64需注意指针长度) function calculateAbsoluteOffset(offset) { return moduleBase.add(offset); }不同架构的指针处理差异:
| 架构 | 指针长度 | Frida指针类型 | 地址计算方式 |
|---|---|---|---|
| Arm32 | 4字节 | NativePointer | ptr.add(offset) |
| Arm64 | 8字节 | UInt64/NativePointer | ptr.add(offset) |
| Thumb | 2/4字节 | 需+1处理 | ptr.add(offset|1) |
3.2 实战案例:Hook加密函数
假设我们需要Hook一个Arm64下的AES加密函数:
- 在IDA中确定函数偏移为0x7A3C
- 处理Thumb模式下的地址对齐
const encryptFuncOffset = 0x7A3C; const moduleBase = Module.findBaseAddress('libcrypto.so'); // Arm64不需要Thumb模式处理 const absoluteAddr = moduleBase.add(encryptFuncOffset); Interceptor.attach(absoluteAddr, { onEnter: function(args) { console.log("AES key:", args[0].readByteArray(32)); console.log("Input data:", args[1].readByteArray(args[2].toInt32())); }, onLeave: function(retval) { console.log("Output cipher:", retval.readByteArray(16)); } });4. 对抗加固的高级技巧
4.1 动态加载So的Hook时机
许多加固方案会延迟加载关键So,需要监听模块加载事件:
const dlopen = Module.findExportByName(null, "dlopen"); Interceptor.attach(dlopen, { onEnter: function(args) { const soName = args[0].readCString(); if (soName.includes("libtarget.so")) { this.shouldHook = true; } }, onLeave: function(retval) { if (this.shouldHook) { setTimeout(() => { hookHiddenFunctions(); }, 500); // 等待初始化完成 } } });4.2 指令修复技术
某些加固会修改函数入口指令,需要原始指令恢复:
function hookWithFixup(targetAddr, originalOpcodes) { const originalFunc = new NativeFunction(targetAddr, 'void', []); // 备份原始指令 const backup = Memory.alloc(Process.pageSize); backup.writeByteArray(originalOpcodes); Interceptor.replace(targetAddr, new NativeCallback(() => { console.log("Before original function"); originalFunc(); console.log("After original function"); }, 'void', [])); return { restore: () => { Memory.protect(targetAddr, originalOpcodes.length, 'rwx'); targetAddr.writeByteArray(originalOpcodes); } }; }5. 完整实战:Hook校验函数
综合应用上述技术,我们来看一个完整的无导出函数Hook案例:
静态分析阶段:
- 使用IDA定位到校验函数偏移为0x8F24
- 发现函数引用字符串"Invalid license"
- 确认函数原型为:bool verify(const char* input)
动态Hook脚本:
function hookLicenseCheck() { const soName = "libsecurity.so"; const checkOffset = 0x8F24; const moduleBase = Module.findBaseAddress(soName); if (!moduleBase) { console.error("Module not loaded, waiting..."); return false; } const checkFunc = moduleBase.add(checkOffset); console.log("License check function at:", checkFunc); Interceptor.attach(checkFunc, { onEnter: function(args) { this.input = args[0].readCString(); console.log(`Verify called with: ${this.input}`); }, onLeave: function(retval) { console.log(`Original return: ${retval.toInt32()}`); retval.replace(1); // 强制验证通过 } }); return true; } // 处理延迟加载情况 if (!hookLicenseCheck()) { const dlopen = Module.findExportByName(null, "dlopen"); Interceptor.attach(dlopen, { onEnter: function(args) { const soName = args[0].readCString(); if (soName && soName.includes("libsecurity.so")) { this.targetSo = soName; } }, onLeave: function(retval) { if (this.targetSo) { setTimeout(hookLicenseCheck, 300); } } }); }- 运行效果验证:
- 输入无效许可证时原始返回0
- Hook后强制返回1绕过验证
- 控制台输出完整调用日志
在实际对抗更复杂的加固方案时,可能需要结合更多技术:
- 内存扫描:搜索特征字节序列定位关键函数
- 指令跟踪:使用Stalker分析执行流程
- 环境检测:绕过反调试和Frida检测机制
通过本方案的技术组合,即使面对没有导出符号的So层函数,也能实现精准定位和可靠Hook。不同Android版本和芯片架构可能需要调整偏移计算方式,建议在实际设备上验证后再部署到生产环境。