Unicorn 模拟调用 SO 文件和 JNI 接口函数,包括 JNI_OnLoad
Unicorn 模拟调用 SO 文件和 JNI 接口函数,包括 JNI_OnLoad
在 Android 逆向工程中,很多时候我们需要在不依赖真机的情况下分析 SO 文件中的算法逻辑。Unicorn 作为一个轻量级的 CPU 模拟器框架,提供了底层的指令模拟能力。本篇将介绍如何使用 Unicorn 引擎加载 SO 文件、模拟 ARM 环境、调用 JNI_OnLoad 以及处理 JNI 接口函数。
Unicorn 引擎核心概念
Unicorn 是一个基于 QEMU 的跨平台 CPU 模拟器框架,它的核心能力包括:
- CPU 指令模拟:支持 ARM、ARM64、x86、x64、MIPS 等多种架构
- 内存管理:提供内存映射、读写操作,模拟进程的内存空间
- 寄存器操作:可以直接读写 CPU 寄存器的值
- 中断处理:支持系统调用和异常中断的模拟
- 多语言绑定:提供 Python、C/C++、Java 等语言的 API
与 Unidbg 不同,Unicorn 是一个更底层的框架,它不提供 JNI 环境模拟、SO 加载等高级功能。这意味着使用 Unicorn 模拟调用 SO 文件需要手动处理 ELF 加载、内存布局、JNI 环境搭建等细节,但同时也带来了更大的灵活性和可控性。
# 安装 Unicorn Python 绑定
pip install unicorn
使用 Python 加载 ELF/SO 文件到 Unicorn 内存空间
ELF 文件解析
SO 文件本质上是一个 ELF(Executable and Linkable Format)格式的文件。要加载 SO 文件,首先需要解析其 ELF 结构,提取代码段、数据段等信息。
可以使用 lief 库来解析 ELF 文件:
pip install lief
import lief
# 解析 SO 文件
binary = lief.parse("lib/armeabi-v7a/libnative.so")
# 获取代码段和数据段信息
for segment in binary.segments:
print(f"Segment: {segment.virtual_address:#x}, size: {segment.virtual_size:#x}, "
f"type: {segment.type}")
# 获取导出符号
for symbol in binary.exported_symbols:
print(f"Export: {symbol.name} @ {symbol.value:#x}")
# 获取导入符号
for symbol in binary.imported_symbols:
print(f"Import: {symbol.name}")
映射到 Unicorn 内存
解析完 ELF 文件后,需要将各个段映射到 Unicorn 的内存空间中:
from unicorn import *
from unicorn.arm_const import *
import lief
def load_so(uc, so_path, base_addr=0):
"""将 SO 文件加载到 Unicorn 内存空间"""
binary = lief.parse(so_path)
# 映射 LOAD 段
for segment in binary.segments:
if segment.type == lief.ELF.Segment.TYPE.LOAD:
addr = base_addr + segment.virtual_address
size = segment.virtual_size
# 对齐到页边界
mem_size = (size + 0xFFF) & ~0xFFF
uc.mem_map(addr, mem_size, UC_PROT_ALL)
# 写入段数据
data = bytes(segment.content)
if len(data) > 0:
uc.mem_write(addr, data)
print(f"Loaded segment at {addr:#x}, size={mem_size:#x}")
# 处理重定位
for rel in binary.pltgot_relocations:
if rel.symbol is not None and rel.symbol.name:
sym_name = rel.symbol.name
rel_addr = base_addr + rel.address
# 记录需要解析的导入符号
print(f"Relocation: {sym_name} at {rel_addr:#x}")
return binary
模拟 ARM 环境的初始化
创建 ARM 模拟器
from unicorn import *
from unicorn.arm_const import *
# 创建 ARM 32 位模拟器
uc = Uc(UC_ARCH_ARM, UC_MODE_ARM)
# 映射内存区域
# 栈空间:1MB
STACK_ADDR = 0x100000
STACK_SIZE = 0x100000
uc.mem_map(STACK_ADDR, STACK_SIZE, UC_PROT_ALL)
# SO 文件加载区域
SO_ADDR = 0x200000
SO_SIZE = 0x1000000 # 16MB
uc.mem_map(SO_ADDR, SO_SIZE, UC_PROT_ALL)
# JNI 环境数据区
JNI_ADDR = 0x80000000
JNI_SIZE = 0x100000
uc.mem_map(JNI_ADDR, JNI_SIZE, UC_PROT_ALL)
# 设置栈指针(SP = R13)
sp = STACK_ADDR + STACK_SIZE - 0x1000
uc.reg_write(UC_ARM_REG_SP, sp)
# 设置链接寄存器(LR = R14)为结束地址
end_addr = 0x999999
uc.reg_write(UC_ARM_REG_LR, end_addr)
初始化寄存器状态
ARM 函数调用遵循 ATPCS(ARM-Thumb Procedure Call Standard)调用约定。调用 JNI 函数时,需要正确设置参数寄存器:
# ARM 寄存器映射
# R0 - 第一个参数 / 返回值
# R1 - 第二个参数
# R2 - 第三个参数
# R3 - 第四个参数
# R4-R11 - 被调用者保存的寄存器
# R12 (IP) - 临时寄存器
# R13 (SP) - 栈指针
# R14 (LR) - 链接寄存器
# R15 (PC) - 程序计数器
# 初始化通用寄存器
for i in range(4, 11):
uc.reg_write(UC_ARM_REG_R4 + (i - 4), 0)
模拟调用 JNI_OnLoad
JNI_OnLoad 是 SO 文件被加载时由系统自动调用的函数,用于动态注册 JNI 方法。它的原型为:
jint JNI_OnLoad(JavaVM *vm, void *reserved);
要模拟调用 JNI_OnLoad,需要构造完整的 JNI 环境参数。
构造 JavaVM 和 JNIEnv 结构
JNI_OnLoad 的第一个参数是 JavaVM* 指针,第二个参数是保留参数(通常为 NULL)。关键在于 JavaVM 内部需要有一个 GetEnv 函数指针,返回 JNIEnv*。
import struct
# JNI 函数表的函数指针数量
JNI_FUNC_COUNT = 232
# 分配 JNI 函数表内存
jni_func_table_addr = JNI_ADDR + 0x1000
jni_func_table_size = JNI_FUNC_COUNT * 4
uc.mem_write(jni_func_table_addr, b'\x00' * jni_func_table_size)
# 分配 JNIEnv 内存
# JNIEnv 指向一个指针,该指针指向函数表
jnienv_ptr_addr = JNI_ADDR + 0x2000
# JNIEnv 第一项是指向函数表的指针
uc.mem_write(jnienv_ptr_addr, struct.pack('<I', jni_func_table_addr))
# 构造 JavaVM 结构
# JavaVM 内部有一个 InvokeInterface 和 GetEnv 等函数指针
javavm_addr = JNI_ADDR + 0x3000
# JavaVM 结构中的函数指针表
javavm_funcs_addr = JNI_ADDR + 0x4000
# GetEnv 是第4个函数(索引3)
getenv_addr = JNI_ADDR + 0x5000 # GetEnv 的实现地址
# 构造 JavaVM 调用接口
javavm_funcs_data = b'\x00' * 4 * 3 # 前3个函数指针置空
javavm_funcs_data += struct.pack('<I', getenv_addr) # GetEnv
javavm_funcs_data += b'\x00' * 4 * 4 # 剩余函数指针置空
uc.mem_write(javavm_funcs_addr, javavm_funcs_data)
# JavaVM 指向函数指针表
uc.mem_write(javavm_addr, struct.pack('<I', javavm_funcs_addr))
实现 GetEnv 函数
GetEnv 函数需要将 JNIEnv 指针写入调用者提供的地址:
# GetEnv(vm, env, version) 实现
# R0 = JavaVM*, R1 = JNIEnv**, R2 = version
getenv_code = [
# 将 JNIEnv* 写入 R1 指向的地址
0x4602, # mov r2, r1 ; R2 = &env
0x4491, # add r1, r2, #4 ; 暂存
0x4B05, # ldr r3, [pc, #20] ; 加载 jnienv_ptr_addr
0x6013, # str r3, [r2] ; *env = jnienv_ptr
# 返回 JNI_VERSION_1_6 (0x00010006)
0x4B04, # ldr r3, [pc, #16] ; 加载 JNI_VERSION
0x4770, # bx lr ; 返回
# 数据区
0x00010006, # JNI_VERSION_1_6
jnienv_ptr_addr, # JNIEnv* 指针
]
# 将 GetEnv 代码写入内存
getenv_code_bytes = b''
for instr in getenv_code:
getenv_code_bytes += struct.pack('<H', instr)
uc.mem_write(getenv_addr, getenv_code_bytes)
调用 JNI_OnLoad
from unicorn import UcError
# 获取 JNI_OnLoad 的地址
jni_onload_addr = SO_ADDR + jni_onload_offset
# 设置参数
# R0 = JavaVM* (javavm_addr)
# R1 = void* reserved (0)
uc.reg_write(UC_ARM_REG_R0, javavm_addr)
uc.reg_write(UC_ARM_REG_R1, 0)
# 设置 PC 为 JNI_OnLoad 入口
uc.reg_write(UC_ARM_REG_PC, jni_onload_addr)
try:
# 执行直到返回
uc.emu_start(jni_onload_addr, end_addr, timeout=5*UC_SECOND_SCALE)
# 读取返回值
ret = uc.reg_read(UC_ARM_REG_R0)
print(f"JNI_OnLoad 返回: {ret:#x}")
if ret == 0x00010006:
print("JNI_OnLoad 成功,版本 JNI_VERSION_1_6")
except UcError as e:
pc = uc.reg_read(UC_ARM_REG_PC)
print(f"执行错误 at PC={pc:#x}: {e}")
处理 JNI 函数的返回值
JNI_OnLoad 内部通常会调用 FindClass、RegisterNatives 等 JNI 函数。我们需要在 JNI 函数表中实现这些关键函数。
实现 FindClass
# FindClass(env, className) 的模拟实现
# 找一个空闲地址存放实现代码
findclass_addr = JNI_ADDR + 0x6000
findclass_impl = [
# 模拟实现:返回一个假的 jclass 指针
0x2001, # movs r0, #1 ; 返回假 jclass
0x4770, # bx lr ; 返回
]
findclass_bytes = b''
for instr in findclass_impl:
findclass_bytes += struct.pack('<H', instr)
uc.mem_write(findclass_addr, findclass_bytes)
# 将 FindClass 函数指针写入 JNI 函数表
# FindClass 是 JNI 函数表中的第6个函数(索引5)
findclass_offset = jni_func_table_addr + 5 * 4
uc.mem_write(findclass_offset, struct.pack('<I', findclass_addr))
实现 RegisterNatives
# RegisterNatives(env, clazz, methods, nMethods) 模拟实现
register_natives_addr = JNI_ADDR + 0x7000
register_natives_impl = [
# 简单返回 JNI_OK (0)
0x2000, # movs r0, #0
0x4770, # bx lr
]
register_bytes = b''
for instr in register_natives_impl:
register_bytes += struct.pack('<H', instr)
uc.mem_write(register_natives_addr, register_bytes)
# RegisterNatives 是 JNI 函数表中的第215个函数(索引214)
reg_offset = jni_func_table_addr + 214 * 4
uc.mem_write(reg_offset, struct.pack('<I', register_natives_addr))
读取 JNI 注册的方法信息
在 JNI_OnLoad 中,RegisterNatives 会传入一个 JNINativeMethod 数组,包含方法名、签名和函数指针:
typedef struct {
const char* name; // 方法名
const char* signature; // 方法签名
void* fnPtr; // Native 函数指针
} JNINativeMethod;
我们可以通过 hook RegisterNatives 来获取这些信息:
# 实现带日志的 RegisterNatives
# 在 RegisterNatives 实现中,读取并打印方法信息
# R0=env, R1=clazz, R2=methods, R3=nMethods
通过读取 R2 指向的内存,可以解析出 SO 文件中动态注册的所有 Native 方法及其对应的函数地址,这对逆向分析非常重要。
内存映射和寄存器状态管理
内存布局规划
合理的内存布局是模拟执行成功的关键。以下是推荐的内存布局:
MEMORY_LAYOUT = {
"stack": (0x00100000, 0x00100000), # 1MB 栈空间
"so_base": (0x00200000, 0x01000000), # 16MB SO 加载区
"jni_env": (0x80000000, 0x00100000), # 1MB JNI 环境数据
"heap": (0x90000000, 0x01000000), # 16MB 堆空间
"hook_code": (0x08000000, 0x00100000), # 1MB hook 代码区
}
保存和恢复寄存器状态
在模拟执行多个函数时,需要在函数调用之间保存和恢复寄存器状态:
def save_registers(uc):
"""保存所有通用寄存器的状态"""
regs = {}
for i in range(16):
regs[f'r{i}'] = uc.reg_read(UC_ARM_REG_R0 + i)
regs['cpsr'] = uc.reg_read(UC_ARM_REG_CPSR)
return regs
def restore_registers(uc, regs):
"""恢复寄存器状态"""
for i in range(16):
uc.reg_write(UC_ARM_REG_R0 + i, regs[f'r{i}'])
uc.reg_write(UC_ARM_REG_CPSR, regs['cpsr'])
常见问题
缺少依赖库
当 SO 文件依赖其他库时,会导致解析导入符号失败。解决方法:
- 使用
lief解析所有导入符号,找出缺失的库 - 手动实现或 stub 缺失的导入函数
- 将依赖的 SO 文件也加载到内存空间中
# 检查导入依赖
binary = lief.parse("libnative.so")
for sym in binary.imported_symbols:
print(f"需要导入: {sym.name} from {sym.library}")
地址冲突
如果多个 SO 文件的加载地址发生重叠,会导致内存写入冲突:
# 动态计算可用地址
def find_free_region(uc, size, min_addr=0x100000, max_addr=0xFFFFFFFF):
"""在 Unicorn 内存空间中查找可用的内存区域"""
# 简单实现:从 min_addr 开始逐页检查
addr = min_addr
while addr + size <= max_addr:
try:
uc.mem_map(addr, size, UC_PROT_ALL)
return addr
except UcError:
addr += 0x10000 # 跳过64KB
raise RuntimeError("找不到可用的内存区域")
Thumb 模式切换
ARM 处理器支持 ARM 和 Thumb 两种指令集。SO 文件中的函数可能使用 Thumb 指令,需要设置 CPSR 的 T 位:
# 切换到 Thumb 模式
cpsr = uc.reg_read(UC_ARM_REG_CPSR)
cpsr |= 0x20 # 设置 T 位
uc.reg_write(UC_ARM_REG_CPSR, cpsr)
# Thumb 模式下,PC 的最低位为1表示 Thumb
uc.reg_write(UC_ARM_REG_PC, target_addr | 1)
使用纯 Unicorn 模拟调用 SO 文件和 JNI 函数虽然工作量大,但它提供了完全的控制能力,适合需要精确控制执行环境和进行底层分析的场景。对于更高级的需求,可以考虑使用基于 Unicorn 的封装框架如 Unidbg 或 AndroidNativeEmu。