1. 项目概述:为什么一上来就要用unidbg跑这个so
做爬虫或者安全的人应该都有这种感觉:一到客户端逆向,最头疼的不是Java层那点逻辑,而是native层的加密参数。就拿某生鲜电商App(下面就叫它“目标App”吧,避免点名带节奏)来说,登录、下单、搜索每一个请求的sign值,都是一个几十位字符串的拼接串传进so,再吐一个固定长度的hex结果。你想在服务端重新计算这个签名,就必须把这套native逻辑完整复现出来。
目标App的这批请求签名算法,主体逻辑全部封装在一个libxxx-sign.so里。Java层只做了拼参和简单的排序,真正的核心——拼接规则、加盐、算法表、甚至最后的hash处理——全在native里。用IDA打开这个so,Ali防护壳先拦一道,动态调试一开进程就退,几个反调试点一路踩下来,人还没到核心函数就已经被折磨得够呛。
这个时候unidbg就非常合适了。unidbg是一个纯Java实现的安卓模拟执行框架,它的思路简单粗暴:在PC的JVM上直接模拟出一台基于ARM指令集的安卓虚拟机,把so文件吃进去,不用真机、不用模拟器、不用root,甚至连安卓系统镜像都不需要。你只需要写一段Java代码,加载so、创建JNI环境、调用你关心的native函数,它就把结果原样返回给你。
这篇内容就是围绕“用unidbg跑通目标App的sign算法”这一个具体场景展开的,从环境搭建、so加载、JNI调用到环境补全、问题排查,全程都是我自己踩坑踩出来的经验。适合有一定Java基础、接触过一点安卓逆向、但还没彻底玩明白unidbg的开发者。看完之后,你应该能独立把一个纯native签名函数在本地跑起来,并且知道面对一个陌生的so该从哪里下手。
2. 核心原理与运行环境准备:先搞懂它到底在干嘛
2.1 unidbg的模拟执行机制到底是怎么一回事
很多人第一次用unidbg的时候会有一个疑问:它跟JNI直接调so有什么区别?答案是区别大了去了。传统的JNI加载so是让目标系统(比如Linux服务器)直接dlopen这个so文件,但so编译的目标平台是Android的arm64,CPU指令、系统调用、动态链接库全都不一样,直接当成普通动态库加载必然崩。
unidbg的做法是模拟。它底层用的是Unicorn引擎,这是一个基于ARM指令集的CPU模拟器,能把arm64的机器指令一条一条翻译执行。再加上对JNI环境的完整模拟,对Linux内核常见系统调用的模拟,对dlopen、dlsym这些动态链接过程的模拟,于是so在里面跑的时候,它自己觉得“我在一台arm64的Android机器上跑”,实际上整个执行环境都只是一个JVM进程。
你说它是不是真的能做所有事?也不完全是。如果so里用了特别冷门的系统调用、特殊的硬件指令,或者某个驱动设备节点,模拟器就会抛异常。但只要目标是“算法分析”和“结果复算”,unidbg基本都能覆盖到——因为绝大多数签名算法只用到了内存拷贝、字符串拼接、加密库和少量文件/时间读取。
这套机制带来的直接好处是什么?就是可控性极强。真实机器上so跑起来是一个黑盒,你在外面只能看到最终返回值;unidbg里每一步都是可以打断、可以挂钩、可以读内存的。你可以hook任意native函数、在任意位置打印backtrace、修改函数参数、甚至直接把某一段算法代码访问的内存dump出来。在对抗加固和混淆的时候,这种“透视线下执行”的能力比在真机上动态调试不知道高到哪儿去了。
2.2 环境搭建与基础依赖
unidbg的使用其实是标准的Java工程,不扯花活。我建议直接用Maven或者Gradle建一个最普通的Java项目,然后引入unidbg的坐标。我用的是0.9.x版本,API比较稳定,网上老教程里那种emulator.createDalvikVM(null)写法在新版本里已经不推荐了。
环境清单大概是这样:
| 组件 | 版本/内容 | 说明 |
|---|---|---|
| JDK | 11+ | 8也能跑,但部分新API需要11 |
| 构建工具 | Maven 3.6+ 或 Gradle 6+ | 随意 |
| unidbg | 0.9.x | 核心依赖 |
| so文件 | arm64-v8a / armeabi-v7a | 从APK里提取 |
| 可选 | IDEA / CLion / Ghidra | 配合静态分析 |
Maven依赖写法很简单,核心就一个:
<dependency> <groupId>com.github.zhkl0228</groupId> <artifactId>unidbg-android</artifactId> <version>0.9.7</version> </dependency>拉下来的依赖会连带带出unicorn、hookzz这些底层库。这里提醒一下Windows用户,Unicorn底层是有本地dll的,如果运行时提示找不到unicorn.dll,把项目里自动解压出来的native库目录手动加到PATH里就行。Linux服务器上跑倒是很少遇到这个问题,所以我个人只推荐在Linux上跑正式的任务,Windows上调试代码完全没问题,但批量跑数还是Linux更稳。
版本这块有个经验:unidbg迭代非常快,不同版本的AndroidEmulatorBuilder、AndroidResolver这些类方法都有细微差异。如果你参考的是网上的老代码,跑不起来是正常的,先看你的实际API,别硬抄。哪怕是我下面贴的代码,你在自己的版本上也可能要微调一两个参数——这是unidbg入门的第一个坑,也是最正常的坑。
3. 实操:把目标so调用链原样跑起来
3.1 从APK里提取so并定位目标函数
第一步,把APK解包。用apktool或者直接改后缀为zip再解压都行,进入lib/arm64-v8a/目录,找到那个目标so。一般来说这种核心算法的so命名都比较有辨识度,比如libsign.so、libsecmain.so、libencrypt.so,目标App这个叫libxxx-sign.so。
拿到so之后先静态看一眼导出函数。如果壳比较浅,readelf -s或者IDA的Exports窗口就能看到Java层调用的native方法:
readelf -s libxxx-sign.so | grep Java_正常会看到类似这样的导出函数:
[123] FUNC GLOBAL DEFAULT 2 Java_com_example_sign_SignUtils_nativeSign看到这个基本就明确了:Java层有个类com.example.sign.SignUtils,native方法叫nativeSign。这样一来JNI描述符就清晰了,我们只需要在unidbg里模拟出这个类,然后调用静态JNI方法。
有些so是动态注册的,导出表里可能没有Java_开头的函数名。这种情况也别慌,先看JNI_OnLoad里是否调用了RegisterNatives,如果是,就需要在unidbg里也把JNI_OnLoad先调起来,拿到注册方法后,再通过DvmClass去callStaticJniMethod。这种动态注册在商业App里其实更常见,目标App也不例外,所以接下来我们两条路都走一遍。
3.2 最朴素的unidbg调用Demo
先来一个最基础、能跑的完整例子。目标App的sign函数签名从静态分析来看是这么一个Java方法:
public static native String nativeSign(String input);对应的JNI签名是(Ljava/lang/String;)Ljava/lang/String;。如果走静态注册,那么unidbg的调用代码写起来是这个样子:
import com.github.unidbg.AndroidEmulator; import com.github.unidbg.LinuxResolver; import com.github.unidbg.Module; import com.github.unidbg.arm.backend.DynarmicFactory; import com.github.unidbg.linux.android.AndroidEmulatorBuilder; import com.github.unidbg.linux.android.AndroidResolver; import com.github.unidbg.linux.android.dvm.DalvikVM; import com.github.unidbg.linux.android.dvm.DvmClass; import com.github.unidbg.linux.android.dvm.DvmObject; import com.github.unidbg.memory.Memory; public class SignInvoker { private final AndroidEmulator emulator; private final VM vm; private final DvmClass signClass; public SignInvoker() { // 模拟64位arm环境 emulator = AndroidEmulatorBuilder.for64Bit() .addBackendFactory(new DynarmicFactory(true)) .build(); Memory memory = emulator.getMemory(); // 模拟Android 7系统,注意这里API级别影响so里的系统调用 memory.setLibraryResolver(new AndroidResolver(23)); vm = emulator.createDalvikVM(); // 加载so DalvikModule dm = vm.loadLibrary("xxx-sign", true); // 调用JNI_OnLoad,很多so的初始化逻辑都在这 dm.callJNI_OnLoad(emulator); // 构建Java层的类镜像 signClass = vm.resolveClass("com/example/sign/SignUtils"); } public String sign(String input) { DvmObject<?> result = signClass.callStaticJniMethod(emulator, "nativeSign(Ljava/lang/String;)Ljava/lang/String;", input); return (String) result.getValue(); } public static void main(String[] args) { SignInvoker invoker = new SignInvoker(); String output = invoker.sign("app_id=1001&device_id=abc123×tamp=1700000000"); System.out.println("Sign result: " + output); } }这个Demo跑通之后,你的第一个里程碑就到手了:so被加载进模拟器,函数被调用,返回值在控制台里打印出来了。如果运气不好,报错是必然的,不要怀疑代码写错了,绝大多数情况是环境补全不到位。但先能跑起来一个函数,后面的事情都好说。
3.3 动态注册场景下的两段式处理
刚才说了,目标App其实走的是JNI动态注册。这种情况下直接解析com/example/sign/SignUtils再callStaticJniMethod,大概率得到的是UnsatisfiedLinkError或者方法找不到。原因很简单:JNI_OnLoad还没把方法注册表填充好,或者JNI_OnLoad里的初始化逻辑因为环境缺失而失败了。
动态注册的标准处理流程是:
- 加载so,先不用管导出函数;
- 主动调用
JNI_OnLoad,触发RegisterNatives; - 注册完成之后,再resolveClass,此时类里的native方法已经挂上去了。
调到JNI_OnLoad可以直接用dm.callJNI_OnLoad(emulator),但问题在于很多公司的JNI_OnLoad里有回调Java的初始化代码,比如读取Asset内容、检查签名、获取包名、初始化全局变量等。这些回调用到Java层的东西,如果你没有对应实现,就会抛一个找不到方法或者空指针的异常。
我的做法很简单:在unidbg里注册一个全局的伪Java宿主类,把这些回调用到的关键方法通过vm.setJni(this)和@JniMethod标注的方式补出来。这个细节具体在第四节说,但你在跑到这步的时候至少要意识到:JNI_OnLoad不是白调的,它可能拉出你一整串环境依赖。
3.4 代码里几个值得注意的设计点
说几个在实际调用中容易踩的位置:
第一,AndroidResolver(23)里的数字不是随便写的。它决定了so里dlopen、open、syscall这些操作去哪一层模拟。很多so会根据系统版本走不同的分支,如果你设置的太高或太低,可能会走到错误的分支里。我的习惯是先试23(Android 7),不行就试28、30,逐级测试,看哪个能跑通且结果正确。
第二,DynarmicFactory不是必备的。默认的Unicorn后端兼容性最好,Dynarmic对某些新指令支持更好,跑得更快,但对个别so反而有诡异的问题。我写代码的时候通常第一版用默认后端,如果遇到莫名其妙的指令异常,再试着切到Dynarmic跑一下。两种后端可以随时换,代码逻辑不用动,这是unidbg做得好的地方。
第三,so里如果还依赖了其他so,加载顺序要处理对。有些算法so会调用libc.so、libz.so、甚至自家另一个libcommon.so的函数。unidbg内置了常见的系统库模拟,但自家另外的so你得手动再load一次,并且要让vm.loadLibrary按依赖顺序执行。顺序错了,dlsym就找不到符号,函数调用直接崩。
4. 补环境与对抗检测:让so以为自己在真机上
4.1 常见的环境检测点分类
unidbg把so跑起来只是第一步,跑出来的值对不对才是关键。第一次跑通之后我拿到了一串返回的hex字符串,但和真机上抓包抓到的同一入参签名完全对不上。这种情况十有八九就是so做了环境检测——它“感知”到自己不在Android真机上,于是走了错误分支或者返回了随机值。
我整理了一下,目标App这类成熟App常见检测点大概分这么几类:
| 检测类型 | 常见实现 | unidbg应对 |
|---|---|---|
| 文件系统检测 | 读/proc/self/maps、/proc/cpuinfo、存在性检查 | 用虚拟FileSystem返回伪造内容 |
| 系统属性检测 | System.getProperty、设备型号、厂商 | 在Java层hook或重写属性获取 |
| 时间戳校验 | 关注当前时间、开机时长、进程启动时间 | 固定时间戳到指定值 |
| Java层回调用法 | 拿包名、签名、设备id、读取SharedPreferences | 通过自定义Jni方法逐一补 |
| 系统调用检测 | getpid、kill、ptrace、syscall拦截 | hook对应native函数 |
| native自校验 | 检测so自身内存指纹、检查断点 | 抹除断点、hook校验函数 |
另一个随时要想起来的是:实际上so不只是“检测环境”,它还依赖环境数据。比如你这函数需要读取某张加密图片,或者某份内置资源文件,你不能只“骗过”检测,你得真的让它在虚拟文件系统里能打开这些文件、读到正确的bytes。说不准一下子拿到一堆buf,解析完才是真正的算法密钥。
4.2 文件系统与系统属性的补全思路
unidbg里文件系统模拟的核心是FileSystem和SimpleFileSystem。你可以把so实际会读取的文件,硬编码成一个虚拟目录树。举个例子,如果so里写死了要读/data/data/com.example.sign/shared_prefs/config.xml,你就可以在初始化逻辑里手动放一份内容进去:
emulator.getFileSystem().addFile("/data/data/com.example.sign/shared_prefs/config.xml", "<xml>fake config</xml>".getBytes());这个方法简单粗暴,但非常实用。问题在于你并不知道它到底读了哪些文件。我的排查手段是:先把所有文件读取操作的位置都hook住,或者干脆让unidbg在遇到“Open失败”的时候把路径打印出来。跑一次,看日志,发现缺哪个文件再补哪个,比提前猜效率高得多。unidbg自带的日志打印只需要打开AbstractLogHandler,它会很详细地输出打开文件、系统调用、内存映射这样的操作日志。
系统属性部分更坑。安卓的System.getProperty、Build.MODEL、Build.BRAND这些值,在JNI环境下so能通过JNI_OnLoad回调Java层去拿,也能在native层通过__system_property_get直接拿。所以补法是两路并行:Java层通过实现一个JniHandler来兜底,native层则直接hook__system_property_get函数。
4.3 时间戳固定与Java宿主方法补全
时间戳也是一个大坑。目标App的sign算法里直接拼了System.currentTimeMillis()进去,按理说这个值在unidbg跑的时候应该会返回JVM的当前时间——而so内部可能同时有多套时间来源互通一致性校验。如果它们相互之间不一致、或者和token过期时间对不上,算法会拒绝计算。
我的处理方式很统一:在创建VM之后,把时间固定成某一个时间点,再手动设置系统时钟为对应月份日期,避免所有跟日期有关的逻辑错乱。
emulator.createDalvikVM(); // 固定时间 emulator.getSyscallHandler().setTimeProvider(() -> 1700000000000L);这个写法只是其中一种,不同版本可能方法名有区别。关键是思路:让so在任意时刻获取到的时间都是同一个固定值。调试的时候这个值会让你的签名结果有可重复性,排查起来特别方便。
Java宿主方法的补全则要更细致一些。unidbg允许你通过实现JniHandler接口,用@JniMethod注解来给so提供Java层方法。举个例子,so回调一个获取设备ID的Java方法,你就可以在控制器里写:
@JniMethod private String getDeviceId() { return "a1b2c3d4e5f67890"; }只要这个类被vm.setJni(this)注册进去了,so调用它的时候就会走你的模拟实现,不会再报“无法找到Java方法”的错。这里注意:方法名、返回类型、参数列表必须和so实际调用的一致,差一个字符都会重新报错。
所以整个补环境的过程其实是滚雪球:跑起来,报错,看日志,补一个,再跑起来,再报错,再补。大概循环个十轮二十轮,环境就稳定下来了。不要指望一步到位,那是没做过的才会有的天真想法。
4.4 用自吐与HookZz定位到底缺了什么
如果你连“它到底缺了什么”都不知道,那就得用更主动的定位手段。unidbg内置了一套HookZz的集成,专门用于native函数级hook。我经常用它在关键加密函数入口和出口处打印参数和返回值,直接看到so内部调了什么加解密算法、传进去了什么key。
一个比较常用的hook例子:
emulator.attach().addHook(new ReplaceCallback( "decrypt_data", new ReplaceCallback.Callback() { @Override public void postCall(UnidbgPointer pointer, int argsCount) { // 读取返回值 System.out.println("decrypt_data returns: " + pointer.toInt()); } }));你要是第一次用,可以不用写这么细,先用自带的方式把native函数参数拉出来,观察它是调了MD5、AES还是自定义表。等看到关键函数被调起来、参数能对上,最终返回值也就不远了。
5. 常见问题与排查技巧实录
5.1 高频报错速查表
我把这段时间里反复踩过的坑整理成一个表格,每次新环境跑不起来就对照一下,基本命中率70%。
| 报错现象 | 根本原因 | 解决方案 |
|---|---|---|
UnsatisfiedLinkError: nativeSign | 函数名/签名写错,或JNI_OnLoad没执行 | 检查JNI描述符,动态注册场景先调JNI_OnLoad |
dlopen failed: library "libz.so" not found | 依赖库缺失 | 按顺序加载依赖so,或设置正确的libraryResolver |
JNI_OnLoad crash / SIGSEGV | so在JNI_OnLoad里触发了未模拟的环境 | 先注释掉callJNI_OnLoad,手动只注册需要的方法 |
RuntimeException: getSystemProperty failed | 系统属性缺失 | hook__system_property_get,返回fake值 |
| 函数返回结果为null或固定值 | 环境检测通过但数据依赖未满足 | 检查文件读取、资源读取,补齐FileSystem |
OutOfMemoryError | 64位so内存占用高 | 调大JVM堆内存,或改用Dynarmic后端 |
| 纯Native崩溃无日志 | 栈深层有异常指令 | 打开全量日志,配合backtrace定位崩溃点 |
5.2 从零到一的排查顺序建议
很多人跑不通就慌了,在代码里瞎试。我建议你按一套固定顺序来排查:
第一步,确认函数签名。用IDA或Ghidra确认导出函数名和参数数量;动态注册的话就去JNI_OnLoad里看RegisterNatives表。这一步错了后面全白干。
第二步,最小化调用。把代码砍到最少,只加载so、创建VM、调用一个最简单的不依赖外部数据的函数(比如so里导出的一个版本号函数),先验证跑通基本执行链路。版本号函数都跑不过,说明环境问题大得很,先修环境。
第三步,从返回值差异切入。如果基本函数跑了,但目标函数结果不对,就用HookZz从后往前看算法入口。先找到最外层加密函数,看它的输入是否和真机相同;如果不同,说明前面的参数预处理有分支条件走到了错误路径;如果相同,再看加密函数内部调用了哪些加解密原语。
这套流程走下来,大部分问题都能定位到具体的一层。
5.3 一个真实的“固定值”坑:时间源不一致
最后讲一个比较有代表性的问题。某次测试时,nativeSign在unidbg里能跑、不报错、参数也正确,但不管传什么入参,返回结果都是一模一样的固定字符串。当时第一反应是so内部缓存了结果——于是去hook了所有内存写操作,没发现问题。
后来仔细扒日志发现,so内部同时获取了两个时间:一个走System.currentTimeMillis()的JNI回调,一个走native层gettimeofday。我在Java层把时间固定了,但native层的时间还是随JVM实际时间在变。这两个时间在同一个sign流程里被拼到了两个字段里,而算法在最上层比较了这两个字段的相对顺序,发现后续时间小于前面的时间(因为我固定到1700000000000L,而gettimeofday还停在2023年),就直接走了异常分支,返回预置的死值。
修正办法就是统一时间源,让Java层和native层拿到的时间完全一致。改完瞬间返回值就正常了。这个案例让我意识到:补环境不是“能跑就行”,而是要让所有环境感知一致性对齐。尤其时间戳、设备ID、随机种子这些同时被多方读取的值,如果有一处对不上,可能不会直接报错,而是返回一个让你迷惑很久的错误结果。
6. 一些经验沉淀与扩展建议
unidbg这套体系我用下来最大的体会是:它不是一个开箱即用的工具,而是一个让“耐心”和“系统化思维”发挥作用的平台。代码本身并不多,难的是后面的环境分析和日志排查能力。你补环境补得多了,会发现不同App的so其实就是那几类套路的排列组合:文件检测、时间检测、签名校验、系统属性回读。第一次啃下来一个完整样本,后面再做同类项目会快很多。
如果你准备自己上手练,我强烈建议先找一个老版本的、加了简单混淆的so做样本,先把基本调用跑通,再一步步给so加“障碍”来模拟现在商业App的套路。比如自己写一个so,在里面加上文件读取和时间校验,然后用unidbg去跑——你会很快理解“为什么我调用函数总报错”、“为什么返回值不对”这一类问题背后的原因。
最后说一个比较实用的建议:当你辛苦把某个so的环境补全完之后,一定要把整套初始化代码封装成一个独立类,比如SignEnv。以后如果有新版本so、只是算法表变了、环境检测逻辑没变,你唯一要做的事情就是替换so文件,其余代码一行不动。我自己就是靠这一套“环境固化”的思路,后续迭代签名算法基本只需要半小时就能跑通新版本。unidbg不难,难的是你愿不愿意把第一次的环境打磨扎实。扎实用上一个下午,后面全都是复利。