越来越多 App 把核心逻辑下沉到 Native 层(C/C++),编译成 .so 文件。Java 层什么都看不到——但 Frida 同样可以 Hook。
加密算法、签名计算越来越多放在 SO 里
Java 层只传递加密后的结果,无法在 Java 层拿到明文
VMP 加固的代码运行在 Native 的私有虚拟机中
反调试、Root 检测逻辑通常在 Native 实现
如图所示,.so 是标准 ELF 格式文件,关键段:
| 段名 | 内容 | 逆向关注点 |
|---|---|---|
.text | 可执行代码 | Hook 函数实现 |
.rodata | 只读数据(字符串常量等) | 找 key/salt |
.dynsym | 动态符号表(导出函数列表) | 定位函数地址 |
.got / .plt | 函数跳转表 | PLT Hook 点 |
// 函数有导出符号(未被 strip)varaddr=Module.findExportByName("libcrypto.so", "AES_encrypt");console.log("AES_encrypt @ "+addr);
// 先找到模块基址,再加上偏移量// 偏移量从 IDA Pro / Ghidra 静态分析获得varbase=Module.findBaseAddress("libnative.so");varoffset=0x1234; // IDA 中的函数地址varfuncAddr=base.add(offset);console.log("目标函数 @ "+funcAddr);
// 枚举模块所有导出符号,找包含关键字的Module.enumerateExports("libnative.so").forEach(function(exp) {if (exp.name.includes("sign") ||exp.name.includes("encrypt")) {console.log(exp.name+" @ "+exp.address); }});
varaddr=Module.findExportByName("libcrypto.so", "AES_encrypt");Interceptor.attach(addr, {onEnter: function(args) {// args[0], args[1], args[2] 是函数参数(指针)console.log("[AES_encrypt] 调用");// 读取第一个参数(输入数据指针)// AES_encrypt(const unsigned char *in, unsigned char *out, const AES_KEY *key)varinPtr=args[0];varlen=16; // AES 块大小console.log("[AES_encrypt] 输入:");console.log(hexdump(inPtr, { length: len }));// 保存参数供 onLeave 使用this.outPtr=args[1]; },onLeave: function(retval) {// 读取输出数据(加密结果)console.log("[AES_encrypt] 输出:");console.log(hexdump(this.outPtr, { length: 16 })); }});
// 完全替换函数实现varcheckRootAddr=Module.findExportByName("libsecurity.so", "checkRootStatus");// 保存原始函数,供新实现调用varoriginalCheck=newNativeFunction(checkRootAddr, 'int', []);Interceptor.replace(checkRootAddr, newNativeCallback(function() {console.log("[checkRoot] 被调用,强制返回 0");return0; // 0 = 未 Root },'int', // 返回值类型 [] // 参数类型列表));
// 读取字符串varstrAddr=ptr("0x7f001234");console.log(Memory.readUtf8String(strAddr));console.log(Memory.readCString(strAddr)); // C 风格字符串// 读取整数console.log(Memory.readS32(addr)); // 有符号 32 位console.log(Memory.readU32(addr)); // 无符号 32 位console.log(Memory.readU64(addr)); // 64 位// 读取字节数组varbytes=Memory.readByteArray(addr, 32);console.log(hexdump(bytes));// 写入(危险!修改前确保有写权限)Memory.protect(addr, 16, 'rwx'); // 先改权限Memory.writeU32(addr, 0x12345678); // 再写值Memory.writeByteArray(addr, [0x90, 0x90]); // NOP 指令
// hexdump 是 Native 层调试最常用工具Interceptor.attach(targetAddr, {onEnter: function(args) {// 完整 hexdumpconsole.log(hexdump(args[0], {offset: 0, // 起始偏移length: 64, // 读取字节数header: true, // 显示地址列ansi: false// 终端颜色(不支持时关掉) })); }});
| 指令 | 含义 | 示例 |
|---|---|---|
MOV | 赋值 | MOV X0, #0 → X0 = 0 |
LDR | 从内存读取 | LDR X0, [X1] → X0 = *X1 |
STR | 写入内存 | STR X0, [X1] → *X1 = X0 |
BL | 调用函数 | BL 0x1234 → call 0x1234 |
RET | 函数返回 | RET |
CMP | 比较 | CMP X0, #0 |
B.EQ | 相等时跳转 | B.EQ label |
ADD | 加法 | ADD X0, X0, #4 |
SUB | 减法 | SUB SP, SP, #16 |
STP/LDP | 保存/恢复寄存器对 | STP X29, X30, [SP, #-16]! |
寄存器约定(ARM64): X0-X7 传递参数和返回值;X8 = 间接结果地址;SP = 栈指针;X29 = 帧指针;X30 = 链接寄存器(返回地址)
// Hook SSL_read / SSL_write 拿到明文Java.perform(function() {// 找到 libssl.sovarSSL_read=Module.findExportByName("libssl.so", "SSL_read");varSSL_write=Module.findExportByName("libssl.so", "SSL_write");if (SSL_read) {Interceptor.attach(SSL_read, {onLeave: function(retval) {varlen=retval.toInt32();if (len>0) {// args[1] 是数据缓冲区vardata=Memory.readByteArray(this.context.x1, len);console.log("[SSL_read] "+len+" bytes:");console.log(hexdump(data, { length: Math.min(len, 256) })); } },onEnter: function(args) {this.context= {x1: args[1] }; } }); }if (SSL_write) {Interceptor.attach(SSL_write, {onEnter: function(args) {varlen=args[2].toInt32();if (len>0) {vardata=Memory.readByteArray(args[1], len);console.log("[SSL_write] "+len+" bytes:");console.log(hexdump(data, { length: Math.min(len, 256) })); } } }); }});
| 问题 | 原因 | 解决方案 |
|---|---|---|
findExportByName 返回 null | 符号被 strip 掉 | 用 IDA 静态分析找偏移地址 |
| Hook 成功但 onEnter 不触发 | 可能有多个同名 so | Process.enumerateModules() 找到正确的 |
| hexdump 读取崩溃 | 指针无效或长度越界 | 先用 ptr.isNull() 判空 |
Process terminated | App 检测到 Hook | 见第 09 篇反检测方案 |
下一篇:砸壳全攻略