做生鲜电商这类App的接口分析时,总会遇到一个绕不开的坎:核心签名算法被塞进so库里,Java层怎么翻都只能看到一个native方法声明,真正干活的逻辑全在底层。标题里这个“某生鲜电商-unidbg”项目,本质上就是一套完整的so层签名还原方案。我最初接手的时候,要面对的也是最常见的需求——搞清楚请求头里的签名到底怎么生成、能不能脱离真机在服务端复算。
这篇文章会用项目复盘的方式,把从so提取到unidbg调通、再到服务端部署的完整链路拆开讲。内容包括:为什么选unidbg而不是Hook真机、怎么把so库在JVM里跑起来、补环境的十几个经典坑、以及调通之后如何封装成可用的签名服务。适合做接口逆向、反爬对抗、客户端安全研究的人参考,哪怕你是第一次接触unidbg,跟着这条思路也能把so里的算法“请”到电脑上。
1. 为什么偏偏是unidbg:从“黑盒签名”说起
1.1 问题源头:客户端签名为什么难啃
先说目标App的签名机制。很多电商类App在请求接口时,Header里都会带一个签名参数,比如x-sign、x-mini-sign之类,后台会根据这个签名判断请求是否来自合法客户端。这个签名的生成逻辑通常不在Java层,而是在libxxx.so里。Java层只是通过System.loadLibrary("xxx")加载so,再调用一个native方法,传入一堆参数,拿回一个字符串。
这意味着什么?你抓包能看到请求参数、能看到Header里的签名值,但签名是怎么算出来的,Java层完全黑盒。如果只在Java层做文章,比如Hook一下MessageDigest、追踪一下字符串拼接,大概率会扑空——因为真正的算法是so里的C/C++代码用各种位运算、查表、AES、RSA拼出来的,Java层的Hook根本插不上手。
我当时分析目标App时,第一步就尝试用Frida在真机上Hook so里的导出函数。Frida确实能看到函数调用,也能dump寄存器、内存,但问题是:每次分析都得连着真机,参数一多、函数一嵌套,日志乱成一团,而且目标App有反调试,Frida一挂上就闪退。折腾了几天,效率非常低。
1.2 unidbg解决的核心痛点
unidbg的定位很明确:在JVM进程里模拟出一个完整的Android运行环境,让so库在电脑上直接跑起来,不需要真机,也不需要Root。它内部实现了基于Unicorn的CPU模拟器、内存模拟、JNI环境模拟,以及Linux系统调用模拟,所以so里的代码可以像在Android上一样执行。
这句话信息量很大,拆开说:
- 不需要真机:意味着没有反调试、没有机型适配、没有USB连接稳定的问题。
- 不需要Root:意味着能分析的App范围更广,不用纠结系统版本。
- JVM环境:意味着调通之后可以直接打成jar包,部署在服务器上,作为一个签名计算服务来用。
- 动态调试:unidbg支持断点、单步、内存读写,比在真机上用Frida更可控。
用一句话概括:unidbg把“在Android上逆向so”变成了“在电脑上调试Java程序”,难度直接降了一个量级。
1.3 项目整体技术选型分析
选了unidbg之后,整个项目的技术栈就定下来了:
| 层次 | 工具/技术 | 作用 |
|---|---|---|
| 运行环境 | JDK 11 | unidbg基于Java,高版本JDK兼容性更好 |
| 核心引擎 | unidbg | 模拟执行so库 |
| ARM指令 | Unicorn | 底层CPU模拟 |
| 构建工具 | Maven或Gradle | 管理unidbg依赖 |
| 辅助分析 | IDA Pro | 静态分析so里的导出函数、交叉引用 |
| 辅助验证 | Python脚本 | 批量对比签名结果,快速验证算法还原是否正确 |
为什么用unidbg而不是其他模拟器?我当时也看过类似QEMU方案,但QEMU太重,纯命令行模拟Android整个系统,启动慢、配置复杂。unidbg是专门为逆向分析设计的库,API正对“加载so、调函数、看返回值”这场景,开箱即用,社区也活跃,遇到问题基本能搜到答案。
如果你是第一次接触unidbg,建议先别管后面服务化的事情,就在本地把Demo跑起来,把一个最简单的so函数调通,再逐步往复杂推进。
2. 前置准备:把so库和调用环境“请”到桌面端
2.1 从App包中提取so等文件
拿到目标App的安装包之后,第一步要做的不是解压,而是先确认架构。
现在市面上App的so库通常包含armeabi-v7a和arm64-v8a两种架构。unidbg模拟的是ARM指令,优先选择armeabi-v7a下的so,因为32位so在unidbg里的兼容性普遍比64位好,很多JNI相关的处理更成熟。
提取so本身没什么技术含量,用解压工具把apk当成zip直接解压就行。但有几个文件需要特别留意:
lib/armeabi-v7a/libxxx.so:核心so,目标对象。assets/下的文件:有些App会把加密密钥、算法参数放在assets里,后面补环境时可能要用。- AndroidManifest.xml:看包名、签名信息,偶尔能辅助定位JNI函数对应的Java类。
我当时提取完之后,习惯先用IDA打开so,看一下导出表里的函数名。目标App的so导出表里往往有Java_com_xxx_sign这样的函数,这就是Java层native方法对应的C函数。有这个信息,就能确定unidbg里需要调用的函数名。
注意:如果so里的符号被strip掉了,导出表是空的,那就只能用
hook或者动态调试来找目标函数地址,难度会翻倍。好在很多电商App为了兼容性和稳定性,不会刻意strip掉JNI导出符号,先查导出表往往就有收获。
2.2 unidbg工程搭建与骨架认识
unidbg本身就是一个Maven项目,可以在GitHub上找到。搭建工程时,我建议直接fork它的一个Demo来做骨架,而不是从零写pom。核心依赖就这么几行:
<dependency> <groupId>com.github.zhkl0228</groupId> <artifactId>unidbg-android</artifactId> <version>0.9.7</version> </dependency>当然,如果拉不下来,也可以直接把unidbg源码编进项目里,灵活修改调试。
在写调用代码之前,先理解unidbg的顶层结构:
AndroidEmulator:模拟器对象,负责创建ARM CPU、内存空间、JNI环境。VM:对应Android里的Dalvik/ART虚拟机,在这个对象上注册Java类和方法,让so里的JNI调用有地方落地。Emulator:底层核心,可以理解为CPU和内存的集合。HookZz/Dobby:unidbg内置的Hook框架,可以在模拟执行时拦截某个函数、改参数、改返回值,这在调试时非常有用。
一个最基础的unidbg调用流程是这样的:
AndroidEmulator emulator = AndroidEmulatorBuilder.for32Bit() .setProcessName("com.xxx.app") .build(); Memory memory = emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); VM vm = emulator.createDalvikVM(); DalvikVMList vmList = new DalvikVMList(); vmList.add(vm);这段代码先创建一个32位模拟器,指定进程名,设置库解析器,再创建Dalvik虚拟机。进程名不是随便填的,后面so里的代码如果读取自身包名,就会用到这个值,所以要跟目标App的真实包名保持一致。
2.3 JNI调用链的初步梳理
要在unidbg里调so函数,必须先搞清楚Java层是怎么调native方法的。这个信息可以从Java层反编译结果里拿。
我当时是这么做的:用jadx打开App的dex,搜loadLibrary,找到加载的so名;再搜native关键字,找到声明native方法的类;最后看native方法的参数和返回值类型。比如:
public class SignManager { public static native String sign(String data); }这个sign方法接收一个String,返回一个String。那么在so里对应函数名就是Java_com_xxx_SignManager_sign,unidbg调用时,就要模拟这个JNI签名:参数是JNIEnv指针、jobject/jclass,然后是jstring。
到了这一步,你基本就清楚“so库在等什么输入”了。接下来要解决的是“怎么给这个输入”。如果是简单的字符串,直接构造jstring就行;如果是复杂对象(比如byte数组、list、map),就要在unidbg里构造Java对象并传给so。
提示:目标App的签名函数往往不止一个参数。常见的是
sign(String data, String ts, String nonce)这种组合,甚至某些App会把整个请求体塞进去。参数顺序和类型一定要从Java层反编译结果里确认清楚,错了就是白跑。
3. 核心实操:在JVM里把so函数“跑起来”
3.1 确定目标函数与参数构造
搞定环境之后,就进入核心环节:实际调用so函数。
以目标App为例,Java层是这样的native方法:
public class EncryptUtil { public static native String getSign(String bizParams, String ts); }对应的so导出函数是Java_com_xxx_EncryptUtil_getSign(JNIEnv*, jobject, jstring, jstring)。
在unidbg里,要把这个函数调起来,需要按下面的步骤来:
第一,拿到so的导出函数符号地址:
Module module = emulator.loadLibrary("libxxx.so"); long funcPtr = module.findSymbolByName("Java_com_xxx_EncryptUtil_getSign").getAddress();如果找不到符号,考虑用内存搜索或者hook来定位,不过像前面说的,正常情况导出表是完好的。
第二,构造JNI参数。这里是两个String参数。unidbg提供了JNIEnvironment来操作jstring:
JNIEnvironment env = vm.getJNIEnv(); StringObject bizParams = new StringObject(vm, "{\"page\":\"home\"}"); StringObject ts = new StringObject(vm, "1699999999");第三,调用函数并获取返回值:
Number result = module.callFunction(emulator, funcPtr, bizParams, ts); String sign = vm.getJNIEnv().getString((StringObject) result);是不是感觉跟写普通Java差不多?但别高兴太早,第一次调用大概率是跑不通的——不是crash就是返回null。这就要进入整个项目里最折磨人,也是最有价值的部分:补环境。
3.2 补环境的那些“坑”
so库在Android里跑的时候,不仅仅是“执行一段计算”,它会跟操作系统、Java虚拟机交互。比如:
- 调用
gettimeofday获取时间戳; - 调用
pthread_create建线程; - 通过
dlsym找Java方法并回调; - 读
/proc/self/maps查自己模块基址; - 访问系统属性
ro.build.version.sdk_int; - 通过JNI反射拿Java层的类、字段、实例。
这些操作在真机上都不是事,但到了unidbg里,每一项都可能变成拦路虎。unidbg虽然模拟了大部分系统调用,但不保证100%覆盖。遇到未实现的调用,会抛UnsupportedOperationException或者直接返回一个诡异值。
我当时遇到的第一个坑,就是so里调用了pthread_create创建了一个后台线程,在线程里执行回调。unidbg默认是单线程模拟,遇到这种情况要么用emulator.getSyscallHandler().hook()处理线程创建,要么想办法让目标so不要走那条分支。
第二个坑是so通过JNI回调Java方法,比如把加密结果传回Java层,再由Java层拼装签名。这种情况下,必须在unidbg里注册对应的Java方法,让so的JNI调用能落地。
第三个坑是so内部读系统属性。unidbg默认提供了一些常见属性,但不全。目标App会读ro.product.model、ro.build.version.release等做环境检测,如果返回空字符串或者返回不了,so可能直接走异常分支。
补环境没有银弹,唯一的路径就是:跑一次,看报错,查调用栈,定位到具体是哪个系统调用或JNI调用没被处理,然后针对性地补上。这个过程快则几小时,慢则几天,全看目标App的对抗程度。
3.3 编写unidbg调用主流程
把补环境的大框架理清楚之后,就能写出一版相对完整的主流程了。我用代码展示一个典型的调用模板:
public class SignCaller extends AbstractJni { private final AndroidEmulator emulator; private final VM vm; private final Module module; public SignCaller() { emulator = AndroidEmulatorBuilder.for32Bit() .setProcessName("com.xxx.app") .build(); Memory memory = emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); vm = emulator.createDalvikVM(); vm.setJni(this); DalvikVMList vmList = new DalvikVMList(); vmList.add(vm); emulator.setDalvikVMList(vmList); module = emulator.loadLibrary("libxxx.so"); } @Override public DvmObject<?> callObjectMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) { // 这里处理so回调Java对象方法的情况,比如获取某个Java对象的字段值或计算结果 switch (signature) { case "com/xxx/Helper->getAppId()Ljava/lang/String;": return new StringObject(vm, "sdk_16888"); } return super.callObjectMethod(vm, dvmObject, signature, varArg); } public String sign(String bizParams, String ts) { StringObject bizObj = new StringObject(vm, bizParams); StringObject tsObj = new StringObject(vm, ts); Number result = module.callFunction(emulator, module.findSymbolByName("Java_com_xxx_EncryptUtil_getSign").getAddress(), bizObj, tsObj); return (String) vm.getJNIEnv().getString(result); } }这个类继承了AbstractJni,核心在两点:
- 构造函数里完成了“造模拟器、建虚拟机、加载so”三步。
callObjectMethod处理so回调Java方法的分支逻辑。
实际业务里,你可能还要重写更多方法:getStaticObjectField、callStaticObjectMethod、getStaticIntField等,取决于so跟Java层交互的深度。目标App的so经常会读Java层传入的Context对象、SharedPreferences之类,这些全都要在AbstractJni里手工模拟。
3.4 验证结果一致性的关键动作
unidbg调通一次不等于万事大吉。签名算法有一种情况特别坑:签名结果跟输入参数有关,也跟当前时间戳、随机数有关,导致你无法用“静态的固定值”去验证。
我的做法是双管齐下:
第一,用真机跑一次,抓取真实请求数据。具体来说,抓一个真实的请求Header,把里面的参数、时间戳、请求体记录下来,连同Header里的签名值一起存下来。
第二,用unidbg复算同样的输入,对比输出。
如果一致,说明这条路走通了;如果不一致,就要分析差异。常见的差异来源有:
- 参数类型或顺序不对;
- so内部读取了额外的环境信息(比如系统属性、设备ID);
- so内部使用了随机数,导致每次签名都有一个动态因子;
- so内部有自校验,检测到运行环境变了,走了另一个算法分支。
我印象很深的一次:目标App的签名so里,会先检查/proc/self/maps里有没有调试器痕迹,如果发现unidbg的模块名跟正常App不一样,就直接返回一个固定错误串。虽然unidbg是模拟执行,理论上检查会被绕过,但目标App直接读取maps字符串做字符串匹配,没匹配上就出差错。
解决办法是hook掉它的fopen和fgets,让它读maps时返回一个伪造的、看起来像正常App的maps内容。这种对抗思路在unidbg里非常常见,也是最有意思的地方。
4. 高频问题与排查实录
4.1 常见报错速查表
我整理了一下unidbg调so时最容易遇到的几类问题,给个速查表方便对照:
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 调用so后直接crash,无输出 | so内调用了未实现系统调用 | 用emulator.traceCode()看崩溃点,结合IDA定位 |
| 函数返回null | JNI方法签名错误,或so内参数解析失败 | 核对Java层native方法签名;检查jstring参数构造 |
抛UnsupportedOperationException | 某个syscall未实现 | 打印调用栈,查看是哪个syscall,去源码里找对应Hook |
| 返回字符串跟真机不一致 | 环境属性、时间、随机数差异 | 对比真机抓包的输入输出,逐步排查 |
| 卡死无返回 | so创建了线程并等待线程结束 | 检查pthread_create、pthread_join相关Hook是否生效 |
报UnsatisfiedLinkError | so依赖了其他so,但没有加载 | 用emulator.getMemory().loadLibrary()加载依赖so,或在构造时指定库路径 |
这些问题的共性在于:unidbg不会主动告诉你哪里不对,需要你把错误堆栈、崩溃地址、调用链结合起来定位。
4.2 一次“so自杀”的排查经历
这里想细讲一个典型排查过程,对刚上手的人应该特别有参考价值。
当时我调用目标App的签名函数,一旦传入了特定的请求体,so就直接返回空字符串,而且不是每次必现,看起来像随机触发。由于签名函数内部逻辑复杂,我也不确定是环境问题还是算法本身有随机性。
我先做了两步:
第一步,hook了so内部的time、gettimeofday、random这些函数,把它们的返回值全部固定成相同的值,排除时间随机性。
第二步,用unidbg的trace功能,把so执行时的每条指令地址、寄存器值都打印出来,跟IDA的反汇编结果对照,定位到返回空值的代码分支。
排查后发现,so内部并不是直接返回空,而是有一个异常分支:它在计算过程中读取了一个全局偏移量,来自一个根据设备信息动态解码的密钥,如果设备信息某个字段为空,这个偏移量就会算错,导致解密结果乱七八糟,最终走catch分支返回空串。
于是我在unidbg里补上了设备信息的JNI回调,让so能拿到一个看起来“合法”的设备ID,问题就解决了。
这给了一个很重要的教训:so返回null或空串,不一定是环境缺失,也可能是某个内部数据不合法导致异常分支。遇到这种情况,别急着堆环境,先用trace定位到具体分支,再反推缺什么数据。
4.3 排查中的方法论沉淀
经历几个项目后,我总结出一套相对固定的unidbg排查路径:
- 先静态分析:用IDA看so导出函数、导入表、关键交叉引用,搞明白目标函数的基本逻辑。
- 再动态跑:直接在unidbg里调用,收集第一个报错。
- 按需补环境:每次根据报错补一个点,不要一次性把网上能找到的“全套环境配置”全塞进去。补得越少,越容易定位问题。
- 对比验证:跑通之后,跟真机数据做对比,不一致再回到步骤2。
- 固化封装:把补好的环境整理成可复用的类,下次遇到同源的App,能省很多时间。
提示:unidbg没有官方调试器,但你可以用
Debugger类,在某个函数地址设置断点,进入单步调试。这在排查复杂分支时比反复加日志高效得多。
5. 从单点调通到自动化落地
5.1 算法导出与远程调用设计
unidbg调通之后,你的签名逻辑就跑在Java进程里了。但是业务侧要接入,总不能每次都打开IDE手动调一下。这时候就需要把unidbg封装成服务。
我的做法是这样:
第一步,把核心调用类做成一个独立Maven模块,只暴露一个接口:
public interface SignService { String getSign(String bizParams, String ts); }接口后面挂的,就是上面那个SignCaller的实例。每次调用这个接口,就创建一个新的unidbg模拟器,加载so,计算签名,返回结果。
第二步,通过HTTP或RPC把这个服务暴露出去。本地开发可以用Spring Boot嵌一个轻量接口,生产环境如果有独立的Java服务链路,直接作为内部服务接入。
需要注意:unidbg模拟器的创建开销并不小,频繁创建会严重影响性能。建议用池化思想,把已经初始化好的模拟器对象缓存起来,每次请求时复用。
我当时封装的时候,还做了一个动作:把so库和unidbg需要的资源文件全部打包进jar包的resources里,这样部署的时候只需要一个jar包,不用额外拷贝so文件。
5.2 工程化部署时的稳定性问题
本地调通之后,部署到服务器上,你还会遇到几个只在服务端才会出现的问题。
第一个是CPU架构问题。unidbg底层用的是Unicorn引擎,在x86服务器上跑的是ARM模拟指令,性能会比原生ARM机器慢一些。如果对性能要求极高,可以考虑用ARM架构的云服务器,实测性能能好不少。
第二个是并发问题。模拟器对象不是线程安全的,多个线程同时调用同一个AndroidEmulator实例,大概率会crash。我用了一个简单的Semaphore控制并发访问,让同一时间只有一个线程在使用模拟器。这种设计能满足大部分内部系统的QPS要求。
第三个是内存问题。每加载一次so,Unicorn会占用几十到几百MB虚拟内存,如果不做限制,服务端内存扛不住。建议给每次调用设置超时时间,超时就强制中断,防止so陷入死循环。
第四个是依赖so的加载顺序。有些App的so不是独立的,它依赖libc++_shared.so、libz.so等系统库。在unidbg里,这些库也要手动加载,而且顺序错了会导致符号解析失败。我的做法是先加载系统库,再加载目标so。
5.3 最后几个建议
这个项目做完,我的整体感受是:unidbg的确是把复杂问题变简单的好工具,但它不是一个“开箱即用”的神器。它的核心价值是给了你一个可编程的ARM模拟环境,至于里面怎么补环境、怎么对抗、怎么排查,拼的还是分析功底。
如果你刚接触这类工作,我的建议是先找一个没有加固、so导出表完整、签名逻辑简单的目标练手,把整个链路从“加载so”到“跑通返回”完整走一遍,再尝试复杂目标。这个过程中你踩过的所有坑,都是后面做更复杂项目时最值钱的积累。
最后再分享一个小技巧:unidbg运行时的日志级别调到DEBUG,里面有很多so执行细节,有些看似莫名其妙的报错,在DEBUG日志里往往能直接看到是哪个JNI方法没注册、哪个系统调用没实现。我后来遇到问题,第一件事就是翻DEBUG日志,比自己瞎猜高效得多。