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 内部通常会调用 FindClassRegisterNatives 等 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 文件依赖其他库时,会导致解析导入符号失败。解决方法:

  1. 使用 lief 解析所有导入符号,找出缺失的库
  2. 手动实现或 stub 缺失的导入函数
  3. 将依赖的 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。