一、为什么需要调试 Native 层 许多应用将核心逻辑放在 Native 层(.so 文件),包括加密算法、签名校验、反调试检测等。Java 层只做简单调用,真正的安全防护逻辑都在 C/C++ 代码中。要深入分析这些逻辑,必须掌握 IDA Pro 调试 so 文件的技术。
IDA Pro 是逆向工程领域最强大的反汇编和调试工具,支持 x86、ARM、ARM64 等多种架构。
Native 层常见的保护逻辑 ┌─────────────────────────────────────────────────────────┐ │ 典型的 Native 层安全保护架构 │ ├─────────────────────────────────────────────────────────┤ │ │ │ Java 层(薄壳调用) │ │ └─ System.loadLibrary("native-lib") │ │ └─ native boolean checkSignature(); ← JNI 入口 │ │ │ │ Native 层(核心保护) │ │ ├─ JNI_OnLoad() │ │ │ ├─ 注册 native 方法 │ │ │ ├─ 启动反调试检测线程 │ │ │ └─ 校验 so 文件完整性(防篡改) │ │ ├─ checkSignature() │ │ │ ├─ 获取 APK 签名 → PackageManager JNI 调用 │ │ │ ├─ 对比硬编码 MD5/SHA256 │ │ │ ├─ 校验结果藏入全局变量 │ │ │ └─ 通过 /proc/self/maps 检测 Frida 注入 │ │ ├─ encrypt() / decrypt() │ │ │ ├─ 白盒 AES 实现(密钥嵌入查找表) │ │ │ ├─ 自定义 S-Box 混淆 │ │ │ └─ 反动态分析:检测断点(软件断点修改代码) │ │ └─ anti_debug() 线程 │ │ ├─ 定时检查 /proc/self/status TracerPid │ │ ├─ ptrace(PTRACE_TRACEME) 防附加 │ │ └─ 检测 /proc/self/maps 中的 frida/ida 特征 │ │ │ └─────────────────────────────────────────────────────────┘
二、环境配置 IDA 版本与 Android 版本兼容性 IDA 7.0 → Android 4.0 - 9.0 (API 14-28) IDA 7.5 → Android 4.0 - 11 (API 14-30) IDA 7.7 → Android 4.0 - 13 (API 14-33) IDA 8.0 → Android 4.0 - 14 (API 14-34)
Android Server 部署 adb shell getprop ro.product.cpu.abi adb push dbgsrv/android_server64 /data/local/tmp/ adb shell chmod 755 /data/local/tmp/android_server64 adb shell su -c /data/local/tmp/android_server64 adb forward tcp:23946 tcp:23946
若使用 Android 12+(API 31+),android_server 需较高版本(IDA 7.7+)才能兼容,因为 SELinux 策略和 ptrace 限制的变化。
Android Server 启动问题排查 adb shell su -c "id" adb shell su -c "netstat -anp | grep 23946" adb shell su -c "kill -9 <pid>" adb shell su -c "setenforce 0"
三、附加进程并设置断点 附加流程详解 步骤:
IDA Pro → Debugger → Select Debugger → Remote ARM Linux/Android debugger
Debugger → Process options:
Hostname: 127.0.0.1
Port: 23946
Debugger → Attach to process → 在弹出的进程列表中找到目标进程,点击 OK
在弹出的对话框中选择 “Same”(保持与当前 IDA 数据库相同的映像)
进程暂停后,在 Modules 窗口找到目标 .so 文件,双击分析其符号
在反汇编窗口中找到函数,按 F2 设置断点
按 F9 继续运行,等待断点触发
以调试模式启动应用 adb shell am start -D -n com.example/.MainActivity jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8700 adb shell am start -n com.example/.MainActivity frida -U -f com.example.app -l preload.js --no-pause
Android 调试器断点机制 IDA 软件断点实现原理: 原始指令(ARM64): 0x1000: SUB SP, SP, #0x20 ; 函数入口第一指令 设断点后(IDA 修改内存中的指令): 0x1000: BRK #0 ; 触发异常(断点指令) ... (原始保存的指令存放在 IDA 内部) 执行流程: 1. CPU 执行到 0x1000 → 执行 BRK #0 → 触发 SIGTRAP 2. 内核暂停进程 → 通知调试器(IDA/android_server) 3. IDA 收到断点命中 → 恢复原始指令 → 单步执行 → 重新设置断点 4. 用户看到 PC 停在断点处 反调试检测原理: - 代码检查自身是否包含 BRK 指令 - 代码计算自身校验和(CRC)并与预期值比较 - 以异常处理捕获 SIGTRAP 来判断是否有调试器
四、分析 JNI 函数表 每个 Native 方法在动态注册时都会通过 RegisterNatives 或静态注册的 JNI_OnLoad 绑定。在 IDA 中搜索关键结构:
Java_com_example_ClassName_methodName typedef struct { const char * name; const char * signature; void * fnPtr; } JNINativeMethod;
在 IDA 中搜索 JNINativeMethod 结构体的交叉引用,可追踪到 JNI_OnLoad → RegisterNatives 的调用链,还原 Java 方法和 Native 函数的映射关系。
使用 IDA Python 脚本自动定位 JNINativeMethod import idaapiimport idcimport idautilsdef read_string (ea ): """从地址读取 C 字符串""" result = "" while True : b = idc.get_wide_byte(ea) if b == 0 : break result += chr (b) ea += 1 return result def find_jni_native_methods (): """搜索 JNINativeMethod 结构体数组""" results = [] for seg in idautils.Segments(): seg_name = idc.get_segm_name(seg) if seg_name not in ['.rodata' , '.data.rel.ro' ]: continue seg_end = idc.get_segm_end(seg) ea = seg while ea < seg_end - 24 : ptr1 = idc.get_qword(ea) ptr2 = idc.get_qword(ea + 8 ) ptr3 = idc.get_qword(ea + 16 ) if idc.is_loaded(ptr1) and idc.is_loaded(ptr2): name = read_string(ptr1) sig = read_string(ptr2) if name and len (name) > 0 and len (name) < 256 : if sig and '(' in sig and ')' in sig: seg3 = idc.get_segm_name(ptr3) if seg3 and '.text' in seg3: results.append({ 'struct_addr' : ea, 'name' : name, 'signature' : sig, 'fnPtr' : ptr3, 'fnName' : idc.get_name(ptr3) }) print (f"[+] JNI Method: {name} ({sig} ) → 0x{ptr3:x} " ) ea += 24 continue ea += 8 return results methods = find_jni_native_methods() print (f"Found {len (methods)} dynamically registered JNI methods" )
五、实战:Crack 一个 Native License 校验 假设目标 so 中有一个校验函数:
; 典型的 license 校验逻辑 BL check_signature ; 调用签名校验 CMP R0, #0 ; 比较返回值 BEQ license_failed ; 若 0 则跳转到失败 ; ... 正常流程 ... license_failed: MOV R0, #0 ; 返回 0 表示失败 POP {PC}
完整的 ARM32/ARM64 指令对照 ARM32 常见指令:
; ARM32 函数调用与返回 PUSH {R4-R7, LR} ; 保存寄存器到栈 MOV R0, #1 ; R0 = 1(第一个参数) MOV R1, R2 ; R1 = R2(第二个参数) BL 0x1234 ; 调用函数(带返回地址到 LR) CMP R0, #0 ; 比较 R0 和 0 BEQ label ; 相等则跳转 BNE label ; 不等则跳转 BGT label ; 大于则跳转 BLT label ; 小于则跳转 LDR R0, [R1, #8] ; R0 = *(R1 + 8) STR R0, [SP, #0x10] ; *(SP + 0x10) = R0 POP {R4-R7, PC} ; 恢复寄存器并返回 ; 常见 NOP 编码 ; ARM32 NOP: 0xE1A00000 (MOV R0, R0) ; Thumb NOP: 0xBF00
ARM64 常见指令:
; ARM64 函数调用与返回 STP X29, X30, [SP, #-0x20]! ; 保存 FP, LR 到栈 MOV X0, #1 ; X0 = 1(第一个参数) MOV X1, X2 ; X1 = X2(第二个参数) BL 0x1234 ; 调用函数 CMP X0, #0 ; 比较 X0 和 0 B.EQ label ; 相等则跳转 B.NE label ; 不等则跳转 LDR X0, [X1, #8] ; X0 = *(X1 + 8) STR X0, [SP, #0x10] ; *(SP + 0x10) = X0 LDP X29, X30, [SP], #0x20 ; 恢复寄存器并返回 RET ; 返回 ; ARM64 NOP: 0xD503201F
绕过方法:
Patch NOP :将 BEQ license_failed 改为 NOP(Edit → Patch program → Change byte,将跳转指令改为 NOP,ARM32 下 NOP 为 0xE1A00000)
修改寄存器 :在 CMP R0, #0 之后暂停,将 R0 改为 1
修改判断条件 :将 BEQ(Branch if EQual)改为 BNE(Branch if Not Equal)
实战:ARM64 Patch 操作指南 ; 原始 ARM64 代码 .text:00000000000010A0 BL check_signature ; 调用签名校验 .text:00000000000010A4 CBZ X0, license_failed ; X0==0 则跳转失败 .text:00000000000010A8 MOV X0, #1 ; 返回成功 .text:00000000000010AC RET .text:00000000000010B0 license_failed: .text:00000000000010B0 MOV X0, #0 ; 返回失败 .text:00000000000010B4 RET ; Patch 策略1: 将 CBZ 改为 NOP ; CBZ 编码: 0xB40000xx (xx 是偏移) ; NOP 编码: 0xD503201F ; 操作:Edit → Patch program → Change byte ; 将 0x10A4 处的 4 字节改为 0xD503201F ; Patch 策略2: 将 CBZ 改为 CBNZ(条件反转) ; CBZ: Branch if Zero → CBNZ: Branch if Not Zero ; 修改 opcode bit 即可 ; 或者直接把 MOV X0, #0 改为 MOV X0, #1(返回成功) ; Patch 策略3: 修改 MOV 立即数(最简单) ; 将 license_failed 处的 MOV X0, #0 改为 MOV X0, #1 ; MOV X0, #0: 0xD2800000 ; MOV X0, #1: 0xD2800020
使用 Keypatch 插件快速 Patch import idaapiimport idcfrom keystone import *def patch_instruction (ea, assembly, arch=KS_ARCH_ARM64, mode=KS_MODE_LITTLE_ENDIAN ): """使用 keystone 汇编并 patch 指令到 IDA""" ks = Ks(arch, mode) encoding, count = ks.asm(assembly) encoded_bytes = bytes (encoding) for i, byte in enumerate (encoded_bytes): idc.patch_byte(ea + i, byte) print (f"Patched 0x{ea:x} : {assembly} → {encoded_bytes.hex ()} " )
修改后的二进制保存 IDA 中的 Patch 默认只影响 IDA 内存中的映像,不会自动保存原文件。 要生成永久修改的 so 文件: 方法1:IDA → Edit → Patch program → Apply patches to input file → 直接修改磁盘上的 so 文件 方法2:使用 Python 脚本导出修改后的文件 idaapi.get_bytes(start, size) 读取修改后的内容 → 写回文件 方法3:运行时 Patch(更隐蔽) 使用 Frida 脚本在运行时修改内存中的指令 每次应用重启都需要重新执行
六、IDA 高级分析技巧 6.1 函数识别与重命名 import idcimport idaapidef rename_jni_functions (): """查找静态注册的 JNI 函数并自动重命名""" for func_ea in idautils.Functions(): func_name = idc.get_func_name(func_ea) if func_name.startswith("Java_" ): parts = func_name.split("_" ) demo = parts[1 :] comment = f"JNI: {'/' .join(demo[:2 ])} → {demo[-1 ]} " idc.set_func_cmt(func_ea, comment, 1 ) print (f" {func_name} : {comment} " )
6.2 交叉引用追踪 IDA 快捷键: Ctrl+X → 查看光标处符号的交叉引用(谁在使用这个地址/函数/数据) Ctrl+Enter → 跳转到交叉引用的第一个位置 示例分析流程: 1. 在 .rodata 段中找到一个可疑字符串(如 "AES/CBC/PKCS7Padding") 2. Ctrl+X 查看谁引用了这个字符串 3. 跳转到引用处,通常是对 Cipher.getInstance() 的调用 4. 从调用处继续 Ctrl+X 向上追溯,找到加密函数的完整调用链
6.3 结构体定义与导入 struct JNINativeMethod { char* name; // offset 0 , 8 bytes (64 -bit) char* signature; // offset 8 , 8 bytes void* fnPtr; // offset 16 , 8 bytes }; struct JNIEnv { void* functions; // offset 0 , 指向函数表 };
6.4 跟踪 JNI API 调用 在 IDA 中,JNI 函数调用通常通过 JNIEnv* 的函数指针表间接调用:
; ARM64 JNI 调用模式 LDR X8, [X19] ; X19 = JNIEnv*, X8 = *JNIEnv = 函数表基址 LDR X8, [X8, #0x380] ; X8 = 函数表[0x380/8] = GetStringUTFChars BLR X8 ; 调用 env->GetStringUTFChars(env, jstr, NULL)
可以通过 IDA Python 脚本定位特定的 JNI 函数调用:
def find_jni_call (jni_func_offset ): """查找对特定 JNI 函数的调用""" pattern = f"LDR.*\\[X.*,#0x{jni_func_offset:x} \\]" ...
6.5 内存 dump 分析 def dump_memory_region (start, size, filename ): """dump 调试进程的内存到文件""" data = idaapi.dbg_read_memory(start, size) if data: with open (filename, 'wb' ) as f: f.write(data) print (f"Dumped {size} bytes from 0x{start:x} to {filename} " )
七、IDA 调试常用快捷键
快捷键
功能
F2
设置/取消断点
F7
单步进入(Step Into)
F8
单步跳过(Step Over)
F9
继续运行(Continue)
F4
运行到光标处(Run to Cursor)
Ctrl+F2
终止调试
Ctrl+S
查看段信息
Ctrl+E
导出数据
Space
切换图形/列表视图
G
跳转到地址
N
重命名符号
;
添加注释
:
添加可重复注释
Alt+T
搜索文本
Alt+B
搜索二进制模式
Ctrl+P
查看函数入口点
Shift+F9
结构体窗口
Shift+F12
字符串窗口
Ctrl+F12
函数列表
shift+F4
查看命名列表
ctrl+shift+w
查看所有字符串
Tab / Shift+Tab
反汇编/伪代码切换(如果安装了 Hex-Rays Decompiler)
八、与 Frida 协同调试 IDA 和 Frida 各有优势,结合使用能达到最佳效果:
┌────────────────────────────────────────────────────────┐ │ IDA + Frida 协同工作流 │ ├────────────────────────────────────────────────────────┤ │ │ │ IDA 静态分析 so │ │ ↓ │ │ 找到关键函数 → 记录函数偏移 │ │ ↓ │ │ Frida 动态 Hook 关键函数 │ │ ├─ 获取运行时参数和返回值 │ │ ├─ 解密内存中的数据 │ │ └─ 绕过反调试检测 │ │ ↓ │ │ IDA 动态调试 │ │ ├─ 在 Frida 绕过的保护下安心调试 │ │ ├─ 单步执行查看算法细节 │ │ └─ 还原算法到高级语言 │ │ │ └────────────────────────────────────────────────────────┘
Frida 辅助 IDA 调试脚本 Interceptor .attach (Module .findExportByName ("libc.so" , "ptrace" ), { onEnter : function (args ) { if (args[0 ].toInt32 () == 0 ) { console .log ("[*] ptrace(PTRACE_TRACEME) blocked" ); } }, onLeave : function (retval ) { if (this .context .x0 == 0 ) { retval.replace (0 ); } } }); Interceptor .attach (Module .findExportByName ("libc.so" , "fopen" ), { onEnter : function (args ) { var path = Memory .readUtf8String (args[0 ]); this .is_proc_status = path && path.indexOf ("/proc/" ) !== -1 && path.indexOf ("status" ) !== -1 ; }, onLeave : function (retval ) { if (this .is_proc_status ) { console .log ("[*] fopen(/proc/.../status) hooked" ); } } }); var target_mod = Process .findModuleByName ("libtarget.so" );if (target_mod) { console .log ("[+] libtarget.so base: " + target_mod.base ); } console .log ("[*] Waiting 5 seconds for IDA to attach..." );var start = Date .now ();while (Date .now () - start < 5000 ) { } console .log ("[*] Done waiting" );
面试常考问题 Q1:JNI 静态注册和动态注册的区别?如何分别定位?
A:静态注册函数名遵循 Java_包名_类名_方法名 格式,直接在 IDA 的 Exports 表中就能找到。动态注册通过 RegisterNatives 在 JNI_OnLoad 中绑定,函数名不固定,需要通过分析 JNINativeMethod 数组或 Hook RegisterNatives 来还原映射关系。在 IDA 中定位动态注册的方法:(1) 先找到 JNI_OnLoad;(2) 在 JNI_OnLoad 中找对 RegisterNatives 的调用指令(通常是 BLR X8 其中 X8 来自 JNI 函数表偏移 0x390 处);(3) 回溯 RegisterNatives 的第三个参数(X2),它指向 JNINativeMethod 数组,数组中每个元素包含 name、signature 和 fnPtr 三个指针。
Q2:ARM32 和 ARM64 在逆向时的注意事项?
A:ARM32 使用 32 位寄存器(R0-R12, SP, LR, PC),ARM64 使用 64 位寄存器(X0-X30, SP, PC)。两种架构的函数调用约定不同:ARM32 前 4 个参数用 R0-R3 传递,ARM64 前 8 个参数用 X0-X7 传递。此外,ARM64 中 LR 是 X30,而 ARM32 中 LR 是 R14。ARM64 的函数序言/尾声使用 STP/LDP 指令操作栈(与 ARM32 的 PUSH/POP 不同)。在 IDA 中分析时必须加载正确架构版本,否则寄存器映射错误导致分析完全不可用。另外,ARM64 的数据访问必须是 8 字节对齐的,未对齐访问会导致异常(不同于 ARM32 允许的未对齐 LDR/STR)。
Q3:如何在 IDA 中处理被混淆的 Native 代码?
A:常见手段包括:使用 IDA Python 脚本进行去混淆(如常量传播、死代码消除);利用 Frida 等工具 Hook 关键函数获取运行时真实参数和返回值;结合 Unicorn 或 QEMU 进行模拟执行;在 IDA 中使用 Keypatch 插件进行指令补丁分析。针对控制流平坦化:(1) 在 IDA 中识别状态变量;(2) 使用脚本记录每个 case 块的地址和处理内容;(3) 分析 case 之间的跳转关系,手动重建原始控制流。针对不透明谓词(Opaque Predicate):使用符号执行引擎(如 angr)自动识别永不成立的条件分支,标记为死代码。
Q4:IDA 调试时遇到 “The debugger could not attach to the selected process” 怎么办?
A:可能原因和解决方法:(1) android_server 未以 root 权限运行——使用 adb shell su -c /data/local/tmp/android_server64;(2) SELinux 阻止 ptrace——adb shell su -c setenforce 0 临时关闭;(3) 应用本身已经附加了调试器——检查是否有 Frida 或 gdbserver 在占用;(4) Android 10+ 的 ptrace 限制——某些 ROM 限制了非系统进程的 ptrace 权限,需要使用 adb shell am start -D 以调试模式启动;(5) android_server 版本过旧——升级到与设备 Android 版本匹配的 IDA 版本。可以先用 adb shell su -c "cat /proc/$(pidof com.target.app)/status | grep TracerPid" 检查进程当前是否已被调试。
Q5:如何在 IDA 中还原被加密的字符串常量?
A:加固/混淆的应用常将字符串存储在 so 的加密状态,运行时解密后使用。还原方法:(1) 找到解密函数——查找对 .rodata 段的可疑引用,尤其是 for 循环 + XOR 操作的模式;(2) 在解密函数出口处设断点——记录解密后的字符串内容;(3) 使用 Frida Hook 解密函数——拦截输入(加密字符串)和输出(解密后字符串),建立映射表;(4) 在 IDA 中写脚本批量解密——如果用 IDA Python 实现了解密算法,可以直接对 .rodata 段中的所有加密字符串批量解密并添加注释;(5) 使用 Unicorn 模拟执行——对解密函数进行模拟执行,输入加密数据,得到解密结果——这对于算法复杂但逻辑独立的小函数非常适合。