做过协议还原的人都经历过这个阶段:抓包拿到一组参数,定位到签名函数在 Java 层只是个 native 方法声明,实现全在 libxxx.so 里。打开 IDA 准备硬啃,结果发现关键路径被 OLLVM 混淆成上千个基本块,或者干脆跑在某个 VMP 虚拟机的解释循环里。这时候继续静态分析的时间成本已经失控了。
黑盒调用的思路是绕开算法本身:不还原它怎么算的,只在 PC 上造一个够用的执行环境,把 so 丢进去跑,传参数拿返回值。unidbg 是目前做这件事最成熟的开源方案,底层跑的是 Unicorn 引擎的 ARM 指令仿真,上层封装了 Android 的 Dalvik/ART 环境,把 JNI 调用链也桥接好了。这篇文章从 ELF 加载的底层机制讲起,到工程化部署中会碰到的各类补环境细节,把这条链路完整拆解一遍。
一、从 ELF 结构理解为什么能脱机跑
Android 的 .so 本质是一个 ELF 32/64 共享对象文件,和 Linux 桌面平台的 .so 在结构上同源。用 readelf -h 看头部信息,Type 字段是 DYN(位置无关),Machine 字段标识 EM_ARM 或 EM_AARCH64。这个文件被加载到内存后,动态链接器(Android 里是 linker/linker64)负责解析它的外部符号引用、重定位段修正、执行 .init_array 中的构造函数。
一个典型的 so 内部布局大致是:.text 段存机器指令,.rodata 段存只读数据(字符串常量、查找表),.data 段存已初始化的全局变量,.bss 段是零初始化的 BSS,.dynsym/.dynstr 是导出符号表,.rel.dyn/.rel.plt 是重定位表。unidbg 做的事情就是模拟这个"加载-重定位-初始化-执行"的完整生命周期,只不过执行环境从真机 CPU 换成了 Unicorn 的指令级仿真。
这里有个关键细节:ARM32 下函数指针的 bit0 标识指令集模式------0 是 ARM,1 是 Thumb。从 IDA 导出表里看到的偏移地址,如果是 Thumb 编译的(现代 Android NDK 默认 r21 之后全部 Thumb-2),实际调用时要把地址加 1 传给执行引擎,否则 Unicorn 会按 ARM 指令去解码 Thumb 编码的字节序列,直接跑飞。unidbg 内部做了这个适配,但如果你自己用 emulator.eBlock() 直接调地址,要注意这个陷阱。
二、黑盒调用的执行模型
unidbg 的架构可以分三层理解:
最底层是 Unicorn 引擎,它提供 ARM/ARM64/Thumb/Thumb2 的指令仿真能力,维护寄存器上下文(R0-R15/R0-R30、SP、LR、PC、CPSR)和一段虚拟地址空间。所有 so 的机器码都在这个空间里逐条执行。
中间层是系统调用桥接层。so 里如果调了 malloc/read/write/mmap 这类 C 库函数,最终会走到 SVC(ARM32)或 SVC #0(ARM64)指令,陷入 unidbg 的 syscall handler。handler 解析系统调用号(ARM32 下在 R7,ARM64 下在 X8),分发到对应的 Java 实现。这就是为什么 unidbg 不需要真实内核------SVC 指令被拦截后根本不会到操作系统,而是在 Java 层模拟返回。
最上层是 JNI/Dalvik 桥接层。当 so 里通过 JNIEnv 调用 FindClass、GetMethodID、CallStaticObjectMethod 这些函数时,调用路径是:so 里 ldr r3, r_env, #offset 取出函数指针 → blx r3 跳转到 unidbg 分配的 stub 地址 → stub 里一条 SVC 陷入 handler → handler 识别出这是 JNI 调用 → 路由到 Java 侧的 DvmClass/DvmObject 体系处理。
整个链路的关键设计是"按需补全":unidbg 不会预先实现所有系统调用和 JNI 函数,只有当 so 真正执行到某个函数时,handler 才去查有没有对应的实现。查不到就抛异常(典型的 "Unhandled syscall" 或 "Not implemented JNI" 错误),你去补就行。
三、环境搭建与依赖配置
unidbg 是 Java 项目,需要 JDK 11 以上(代码里有部分用到了 Java 11 的 var 语法和模块化特性)。构建工具推荐 Maven,因为 Gradle 在 fat-jar 打包时会碰到 Unicom 的 .so/.dylib 动态库冲突问题。
Maven 坐标:groupId 是 com.github.zhkl0228,artifactId 是 unidbg-android(这个会传递依赖 unidbg-api 和 unidbg-spring)。版本号目前主线在 0.9.8 附近,注意 0.9.x 系列 API 在持续变动,特别是 MemoryBlock 和 ByteArray 的接口签名。
如果目标 so 依赖了 Android SDK 23 以上的系统库(libc.so、libm.so、liblog.so、libz.so 等),通过 AndroidResolver 可以自动加载 unidbg 内置的桩实现。AndroidResolver(23) 的参数是 API Level,它会自动选择对应版本的系统库符号表。如果你的 so 依赖了非系统库(比如 libcrypto.so、libcurl.so),这些需要自己提供完整的 so 文件放入 resources 目录,通过 libraryResolver 的 findLibrary 方法做路径映射。
四、基础流程:从加载到取回结果
一个纯导出函数(非 JNI 注册)的黑盒调用流程分四步:
4.1 构建 Emulator 实例
AndroidEmulatorBuilder.for32Bit() 或 for64Bit() 创建模拟器。setProcessName() 设置 /proc/self/cmdline 的返回值------有些 so 会读这个路径做包名校验。build() 之后拿到 AndroidEmulator 实例。
紧接着必须设置 LibraryResolver:emulator.getMemory().setLibraryResolver(new AndroidResolver(23))。这一步让后续 dlopen("libc.so") 能解析到内置的 libc 桩。
如果 so 用到 JNI(绝大多数 Android native 库都会),还需要创建 DalvikVM:vm = emulator.createDalvikVM()。这一步在模拟器的地址空间里分配了一块 JNIEnv 结构体(函数指针表),模拟了 JNI 环境。调用 vm.setVerbose(true) 可以打印每次 JNI 调用的类名/方法名/参数,排查问题时必须打开。
4.2 加载目标 so 并触发初始化
调用 vm.loadLibrary(soFile, true),第二个参数 resolve 设为 true 表示立即解析并重定位。这个调用内部做了:读取 ELF 头部 → mmap 各段到虚拟地址空间 → 解析 .dynamic 段找到依赖库 → 递归加载依赖 → 应用重定位(R_ARM_RELATIVE / R_ARM_GLOB_DAT / R_ARM_JUMP_SLOT) → 如果 resolve=true 则执行 .init_array 中的构造函数。
loadLibrary 返回 DalvikModule 对象。紧接着必须调一次 dm.callJNI_OnLoad(emulator)。很多 so 在 JNI_OnLoad 里做的事包括:缓存 jclass/jmethodID/jfieldID 到全局变量、注册 native 方法表(RegisterNatives)、初始化查找表或加密上下文。如果跳过这一步,后续函数调用大概率因为全局变量未初始化而崩溃或者返回垃圾值。
4.3 定位函数地址
两种定位方式。第一种:函数在 .dynsym 导出表里有符号名,用 module.findSymbolByName("nativeSign") 拿到 Symbol,再取 address。第二种:符号被 strip 或没有导出(JNI 注册的函数名不会出现在导出表),需要用 IDA 找到函数偏移,地址 = module.base + offset。
这里 module.base 是 so 被加载后 .text 段在模拟器地址空间的起始位置。unidbg 每次加载地址可能不同(ASLR 模拟),所以必须动态计算。
4.4 执行调用
对于普通 C 导出函数,直接用 emulator.callFunction(address, args...)。unidbg 内部会按 AAPCS(ARM Architecture Procedure Call Standard)约定布置参数:前 4 个整型/指针参数依次放 R0-R3,浮点参数放 D0-D7,超出 4 个整型参数的部分压栈(栈从 SP 向下增长)。返回值在 R0。
如果参数是字符串指针,先用 memory.writeStackString("hello") 或 memory.malloc + write 在模拟器堆上分配空间并写入内容,拿到地址作为参数传入。返回值如果是指针(char*、byte\[\]),用 memory.readCString(addr) 或 memory.readByteArray(addr, len) 读回。
ARM64 下寄存器数量翻倍,AAPCS 规定前 8 个整型参数走 X0-X7,前 8 个浮点参数走 D0-D7。unidbg 内部根据当前架构自动选择调用约定。
五、补环境:黑盒调用真正的工作量
如果 so 只依赖 libc 的基础函数(memcpy、strlen、malloc 之类),AndroidResolver 的内置桩能直接跑通,几十行代码搞定。但稍微复杂一点的 so 就会踩到大量缺失接口。补环境的工作量通常占整个黑盒脚本 60% 以上的代码量。
5.1 系统属性读取
__system_property_get 是最常见的拦截点。这个函数在 libc.so 里导出,unidbg 默认给了一个空实现(直接返回 0)。so 里如果拿它读 ro.build.version.sdk、ro.product.model、persist.sys.root 等属性做环境判断,你需要通过 SyscallHandler 或 ModuleHook 覆写它的行为。
具体做法:通过 emulator.getSyscallHandler().addIOResolver() 注册拦截器,在回调里用 memory.readCString(emulator.getContext(RegContext.class).getIntByReg("r0")) 读出要查的属性名,根据属性名往 R1 指向的缓冲区写入预期值。注意 Android 系统属性有 92 字节的长度限制(PROP_VALUE_MAX),超出会截断。
5.2 线程与同步原语
pthread_create、pthread_mutex_lock/unlock、pthread_cond_wait 这些函数在 unidbg 里是空实现------unidbg 本身是单线程执行模型,不存在真正的并发。如果 so 里用 pthread_create 启动后台线程做初始化(比如起一个定时刷新 token 的循环),你需要 Hook 掉这个调用,或者把初始化逻辑改成同步执行。pthread_mutex_init/lock/unlock 直接返回 0 即可,单线程下没有竞争。
5.3 /proc 文件与文件系统访问
部分 so 会 fopen("/proc/self/maps") 读取内存映射做反调试检测,或者 access("/data/local/tmp/frida-server", 0) 检测 Magisk/Frida。在 unidbg 里这些文件不存在,fopen 返回 NULL。如果 so 的逻辑分支依赖这些文件存在,需要注册一个 IOHandler 模拟文件内容。AndroidResolver 模式下可以通过 setIOResolver 注册一个虚拟文件系统。
5.4 JNI 回调链补全
这是最耗时的一类。so 里的 native 方法可能回调 Java 层------典型模式是 FindClass 获取一个类对象,GetMethodID 拿方法,CallIntMethod/CallObjectMethod 调用。在 unidbg 里你需要用 vm.resolveClass("com/example/DeviceInfo") 注册一个虚拟的 DvmClass,然后通过 dvmClass.setMethodID 预设每个方法的返回值。
技巧:开 verbose 模式跑一遍,unidbg 会打印每一个未实现的 JNI 调用(类名、方法名、方法签名、参数),你按打印出来的信息逐个补 DvmMethod 的返回值。一个中等复杂度的 so 可能需要补 20-50 个 JNI 回调点。
5.5 时间、随机数与唯一标识
time()/gettimeofday()/clock_gettime() 返回值、random()/srand() 种子、getpid() 返回值------如果算法里混入了这些值(比如以当前秒数做盐值),补环境时必须和真机调用时刻对齐,否则结果不一致。稳妥做法是在调用前后各打印一次时间戳,确认 unidbg 里 clock_gettime 返回的是当前真实 Unix 时间而非零点。unidbg 默认已实现了这几个时钟调用,但 clock_gettime 的精度在 ARM64 下有一个已知的纳秒偏移 bug(0.9.8 之前),如果算法精度敏感需要验证。
六、JNI 注册型函数的调用方式
现代 Android NDK 编译的 native 方法大多走 RegisterNatives 动态注册,不在导出表里出现。这不代表不能黑盒调用------只要你在 IDA 里找到了 RegisterNatives 调用的参数,就能拿到函数地址。
IDA 中的查找方法:搜索 RegisterNatives 字符串(它是 JNI 函数表中偏移 0x158 位置的函数,对应 ARM32 JNIEnv 的 +216 偏移,ARM64 的 +1728 偏移),找到 so 里调用它的位置,交叉引用 xref 到 JNI_OnLoad。在调用点附近能看到 JNINativeMethod 结构体数组:每个结构体是 {char* name, char* signature, void* fnPtr},直接读出 fnPtr 偏移即可。
调用时有两种路径:第一种用 unidbg 的 Java 层 API------vm.resolveClass("com/example/Native").callStaticJniMethodObject(emulator, "sign(Ljava/lang/String;)Ljava/lang/String;", inputStr),unidbg 内部帮你走完整的 JNI 调用链(构造 JNIEnv、压栈参数、跳转到 fnPtr、处理返回值装箱)。第二种是直接 callFunction 传 JNIEnv 指针和 jclass 做前两个参数,效率更高(少一层 Java 桥接),但你需要自己构造参数。
方法签名字符串格式是 JNI Type Signature 规范:(参数类型列表)返回类型。比如 (ILjava/lang/String;)J 表示接收 int 和 String 返回 long。这个字符串必须和 so 里 RegisterNatives 时传的完全一致,unidbg 用它做方法路由匹配。
七、工程化部署与性能控制
当黑盒调用脚本要接入生产环境(比如做批量数据采集、协议自动化测试、兼容性验证),几个工程层面的问题必须处理。
关于实例复用:一个 Emulator + 一个已加载的 so,在多次调用之间会累积堆内存。如果每次调用都 new 一个 Emulator(重新加载 so),每次开销 200-400ms;复用实例的话单次调用 5-15ms。但复用的前提是 so 的状态机不累积副作用------有些 so 内部维护一个 session 计数器,调 1000 次之后溢出导致结果异常。这种情况下用定时重建实例 + 连接池的模式,比如每 500 次调用淘汰一个实例。
关于并发:Unicorn 引擎本身线程不安全。多线程并发调用同一个 Emulator 会崩溃(寄存器上下文互踩)。线程安全方案是给每个线程分配独立的 Emulator 实例(内存开销约 30-80MB/实例),通过对象池管理。
关于封装为 HTTP 服务:用 SpringBoot 或 Vert.x 起一个服务,启动时预热 2-4 个 Emulator 实例放入池子。每次请求从池中借一个实例,调用完归还。注意 JVM 默认堆大小不够,需 -Xmx 给足(单实例 so 较大时建议 4GB+)。监控指标关注:实例池等待队列长度、单次调用 P99 延迟、实例重建频率。
关于 64 位 so:for64Bit() 创建的模拟器走 ARM64 指令集,指针是 8 字节。参数传递从 R0-R3 变为 X0-X7,返回值从 R0 变为 X0。栈对齐要求 16 字节(SP 必须 16 字节对齐,AAPCS64 强制)。unidbg 内部做了对齐,但如果你在模拟器里手动写栈操作要注意。
八、排错实战手册
崩溃在 PC = 0x0 或 PC = 0x1:典型原因是函数偏移地址没加 Thumb bit(ARM32)或参数个数不匹配导致 LR 被踩。验证方法:在 callFunction 前打印目标地址和前后几条指令反汇编(unidbg 的 memory.code(0, address, 16)),确认是 Thumb 指令(前两位为 01)而非 ARM 指令(前两位为 00)。
Segmentation fault 且地址在 so 基址范围外:so 依赖了某个系统库(如 libcrypto.so)但 AndroidResolver 里没提供,导致 dlopen 返回 NULL,后续对返回指针的访问触发段错误。排查方式:开 verbose 日志看 dlopen 调用返回的是什么。
结果正确但偶尔不一致:大概率是时间戳、随机数、或者 /proc/self/stat 里的 utime/stime 在影响结果。把 clock_gettime 和 gettimeofday 的返回值在 unidbg 里固定成和真机一致的值。
64 位 so 跑起来结果全是 0:检查是否误用了 for32Bit(),以及参数指针是否用了 int(4 字节)而非 long(8 字节)。ARM64 地址超过 2^32 时,int 截断会导致指针指向错误位置。
JNI_OnLoad 阶段直接抛 Unimplemented JNI:这是 so 在初始化时就需要回调 Java 层获取某些值(典型场景:读取 Context.getPackageName()、获取 ActivityManager)。解决方法是 vm.resolveClass 注册对应类并预设返回值,用 vm.setJni(this) + 实现 AbstractJni 接口的 callStaticObjectMethod 做通用拦截。
九、方案边界与局限性
黑盒调用不是万能的。它的适用前提是你已经能确定函数入口地址和参数/返回值的 ABI 映射------这意味着你至少要做基本的 IDA 静态分析或 Frida 动态确认。算法本身如果是纯计算(输入确定则输出确定),黑盒调用是最优解。
不适用的场景包括:需要提取算法中的硬编码密钥用于重新实现(黑盒调用不暴露中间状态)、需要修改算法逻辑(比如把有效期校验绕过)、so 内部大量使用了多线程竞态条件且依赖真机的调度时序(unidbg 是单线程同步执行,没有真实调度)。
另外有一个工程层面的灰色问题:部分应用把设备指纹、广告 ID 等敏感信息的采集逻辑放在 native 层。在黑盒调用这类接口时,确保你的用途在授权测试/安全研究的合规范围内,避免触碰个人信息保护法规的红线。
最后说一句实践建议:拿到一个 so 先跑 unidbg 的 trace code 模式(emulator.traceCode(module.base, module.base + module.size)),它会输出每条指令的地址和助记符。用这个日志可以直接和 IDA 的流程图对照,几分钟内就能理清函数的调用路径、关键分支条件和参数传递方式。这比纯看静态反汇编效率高一个数量级------本质上是在做"动态 trace 辅助静态理解",虽然不还原完整算法,但对确定函数边界、参数布局、返回值格式这些黑盒调用必需的信息,已经完全够用了。