news 2026/10/13 3:59:25

生鲜电商App so签名算法逆向:基于unidbg的完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
生鲜电商App so签名算法逆向:基于unidbg的完整实现

做生鲜电商这类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 11unidbg基于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定位
函数返回nullJNI方法签名错误,或so内参数解析失败核对Java层native方法签名;检查jstring参数构造
抛UnsupportedOperationException某个syscall未实现打印调用栈,查看是哪个syscall,去源码里找对应Hook
返回字符串跟真机不一致环境属性、时间、随机数差异对比真机抓包的输入输出,逐步排查
卡死无返回so创建了线程并等待线程结束检查pthread_create、pthread_join相关Hook是否生效
报UnsatisfiedLinkErrorso依赖了其他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排查路径:

  1. 先静态分析:用IDA看so导出函数、导入表、关键交叉引用,搞明白目标函数的基本逻辑。
  2. 再动态跑:直接在unidbg里调用,收集第一个报错。
  3. 按需补环境:每次根据报错补一个点,不要一次性把网上能找到的“全套环境配置”全塞进去。补得越少,越容易定位问题。
  4. 对比验证:跑通之后,跟真机数据做对比,不一致再回到步骤2。
  5. 固化封装:把补好的环境整理成可复用的类,下次遇到同源的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日志,比自己瞎猜高效得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 3:58:52

电商数仓实战:从ODS到ADS的四层精简架构与Trino优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 3:58:34

Simulink仿真验证二阶系统:从传递函数到PID整定与Bode图校验

Simulink 做经典控制理论的仿真验证&#xff0c;是控制工程里绕不开的一件事。学完系统的传递函数、时域响应、PID、Bode 图这些内容&#xff0c;如果只停留在纸面推导&#xff0c;很难真正建立"理论公式和实际系统表现之间的对应感"。这篇文章以一个二阶系统的综合仿…

作者头像 李华
网站建设 2026/10/13 3:58:07

Ubuntu裸机部署OpenClaw全指南:从依赖安装到工具调用链路验证

1. 我为什么坚持在Ubuntu裸机上装OpenClaw&#xff0c;而不是直接丢进容器里跑1.1 OpenClaw到底是个什么东西如果你最近在关注开源AI工具链&#xff0c;大概率会刷到OpenClaw这个名字。简单来说&#xff0c;它是一个开源的AI助手运行框架&#xff0c;可以把大语言模型的能力接到…

作者头像 李华
网站建设 2026/10/13 3:57:28

旅游场景分布式IM系统设计与Go实践

1. 项目概述&#xff1a;为什么一个旅游平台需要自研分布式IM系统&#xff1f;“基于Go的马蜂窝旅游网分布式IM系统技术实践”——这个标题里藏着三个关键信号&#xff1a;Go语言、旅游场景、分布式IM。它不是在讲一个聊天工具怎么写&#xff0c;而是在说&#xff1a;当数千万用…

作者头像 李华
网站建设 2026/10/13 3:57:07

滑块验证码全解析:原理、实现与攻防对抗

我做了这么多年前端&#xff0c;最常被朋友问的一句话就是&#xff1a;“你们为什么非要做滑块验证&#xff1f;拖来拖去的烦死了。”每次我都得解释一遍&#xff0c;但说实话&#xff0c;大部分人听完还是一脸困惑。直到后来我完整走了一遍从需求、设计、实现到对抗的全流程&a…

作者头像 李华