Unidbg Native协议分析实战:绕过反调试还原签名算法
2026/9/20 10:22:03 网站建设 项目流程

先讲个我经常遇到的情况:接手一个App的协议分析任务,抓包一看,请求头里挂着一串几十位的签名参数,改一下body里任意一个字节,签名就变。你用IDA跟进去,满屏花指令,反调试挂了一堆,Frida刚spawn就被检测到退出,真机上一通折腾,进度还是卡在入口函数。这个场景,做过客户端安全研究的人应该都不陌生。后来我在一个内部项目里正式把Unidbg用起来,才发现之前的很多痛苦其实可以绕开。

Unidbg是一个基于Unicorn引擎的Java开源库,能在PC端模拟Android的JNI环境和native层执行,直接在JVM里加载目标so并动态调用。它最大的价值在于把“真机+Frida+IDA动态调试”这种容易被保护的组合,换成了“本地进程+模拟执行+代码级调试”的方式。整个过程不依赖root,不需要真机,也不触发目标App自身的反调试逻辑。对做协议分析、安全评估、算法还原的人来说,它是一个非常顺手的动态分析底座。这篇东西就把我用Unidbg跑通一整套native协议分析的思路、关键步骤和踩坑经验整理出来,目标是让刚接触Unidbg的人少走弯路。

1. 为什么选Unidbg做动态分析

1.1 静态逆向的瓶颈

很多刚入门的同学习惯拿到so先丢进IDA,找导出函数、看F5伪代码。这套流程对简单函数没问题,可一旦目标是商业级的加密协议,静态分析很快就会吃力。首先是混淆问题,常见的OLLVM控制流平坦化、指令替换,会让反编译结果完全没法看,很多真实逻辑被拆得七零八落,你只能看到一堆状态机和永真分支。其次是环境干扰,目标so里通常有大量反调试、反模拟器、反Frida检测,比如读取/proc/self/maps检查调试器,通过pthread_create起线程做完整性校验,或者用svc指令直接陷入内核拿信息。静态分析看不出来,动态调试又会被检测到,这就是最尴尬的阶段。

Frida和Xposed这类方案确实方便,但它们在真机或模拟器上运行,注定绕不过App自身的检测。现在的App普遍有root检测、Frida端口检测、内存扫描,甚至直接读取/status/proc/net/tcp来感知注入进程。每次加固更新,Hook点可能就失效。相比之下,Unidbg是在PC上用一个Java进程模拟整个Android环境,目标so根本不知道自己在被分析,它看到的是一个合法的Linux用户态环境。反调试代码跑不出预期效果,因为你压根没有给它真正的调试条件。

1.2 Unidbg的核心运行机制

Unidbg底层基于Unicorn,Unicorn是QEMU抽取出来的轻量级CPU模拟器,支持ARM、ARM64、X86等主流指令集。Unidbg在其之上模拟了完整的内存布局、进程堆栈、系统调用、线程调度以及JNI所需的JavaVM和JNIEnv环境。换句话说,它把一个so当成一个普通动态库加载到自己的虚拟地址空间里,然后调用so里的某个导出函数,就像Android里native方法被Java层调用一样。

关键是JNI支持。Android的native方法通常用JNIEXPORT导出,内部大量调用NewByteArrayGetStringUTFCharsCallObjectMethod这些JNI函数。真实环境里这些函数由ART虚拟机提供,Unidbg则用Java实现了这些接口,还会回调到我们注册的Java方法。这样一来,so运行时产生的所有JNI调用、内存分配、文件读取、socket连接,你都能在Unidbg的日志里看到。很多so会通过JNI回调Java层获取参数,Unidbg允许你把这些回调直接注册成Java方法,返回你想让它返回的数据。这个特性在算法还原中非常有用,我可以先填假值看走向,再根据输出判断这个值在算法里的角色。

Unidbg的另一个核心优势是可控性好。它可以按指令步进调试,可以设置内存断点、指令断点,还可以用addHookListener监听特定地址的执行。因为在模拟器里运行,所有断点都在我们自己进程内,不会触发目标so的调试器检测。所以遇到复杂的自定义算法,我会直接在Unidbg里单步跟踪,观察寄存器变化,这种体验比在真机上稳定太多。

2. 环境搭建与基本运行流程

2.1 基础依赖与工程结构

Unidbg本身是一个Maven项目,引入方式很简单,在你的pom.xml里加依赖:

<dependency> <groupId>com.github.zhkl0228</groupId> <artifactId>unidbg-android</artifactId> <version>0.9.7</version> </dependency>

需要注意JDK版本,我一般用JDK 8或11,太高的话部分依赖可能有问题。如果从GitHub拉源码自己编译,记得先装Maven和Git,构建时国内网络可能需要配镜像,这块自己处理一下就好。

目录结构我习惯这么建:

src/main/java com/example/protocol/ Main.java SignInvoker.java src/main/resources/ so/ libnative-lib.so libc++_shared.so

目标so直接扔到resources/so下,代码里用DalvikVMAndroidEmulator加载。整个项目实际上就是一个普通Java工程,不需要连接任何设备,跑起来就是一个JVM进程,出问题很容易用IDE断点调试。

2.2 一个最小化的调用骨架

假设目标so是libnative-lib.so,里面有个导出函数stringFromJNI,我们先写一个最基础的调用:

public class Main { public static void main(String[] args) { // 创建模拟器,根据so位数选择arm或arm64 AndroidEmulator emulator = AndroidEmulator.builder() .setProcessName("com.example.target") .build(); // 创建DalvikVM,代表Android运行时 DalvikVM vm = emulator.createDalvikVM(); vm.setVerbose(true); // 加载so文件 DalvikModule module = vm.loadLibrary("native-lib", true); // 调用JNI_OnLoad,执行so的初始化逻辑 module.callJNI_OnLoad(emulator); // 拿到导出函数 Symbol symbol = module.findSymbolByName("stringFromJNI"); // 构造JNI参数:一个JNIEnv对象和一个JObject(一般是null) Number result = symbol.call(emulator, new JObject(), "from_java"); System.out.println("result = " + result); } }

很多第一次用的人会忽略callJNI_OnLoad这一步。so被加载后如果通过JNI_OnLoad注册了native方法或初始化了全局状态,你不调用它,后面主逻辑很可能异常。另外,setProcessName要和目标App包名一致,因为很多so会通过getPackageName这类JNI回调获取包名来做行为校验,后面我们会注册对应的回调。

调用结果如果是一个jstring,Unidbg会自动转成Java String打印。如果是其他类型,比如jbyteArray,需要手动读取模拟器内存:

byte[] data = emulator.getMemory().readByteArray(address, length);

这个读取逻辑在算法还原中会反复用到,因为很多加密结果是以字节数组形式返回的。

3. 从抓包到定位加密入口的完整链路

3.1 先定位请求里的加密字段

我一般不会一上来就逆向代码,而是先把协议层的字段摸清楚。用Charles或Fiddler这类代理工具抓包时,重点关注请求头里那些看起来像签名、非固定值、长度固定的参数。以常见的App为例,像x-sx-tx-s-common这类字段,通常就是服务端做风控和签名校验的关键。

定位方法是做差分。把同一个请求body里的明文改一个字符,比如把一句话从“你好”改成“你好啊”,看哪个字段变化最大,哪个字段完全不变。全不变的字段可能就是设备标识或固定token,变化的字段基本可以确定是参与签名计算的。接下来再想尽一切办法构造多个输入样本,把明文、时间戳、设备参数、响应体都记录下来,为后面交叉验证做准备。

这一步不需要太高深技术,但对后续工作极其重要。我给自己的要求是:样本至少留20条,每条都包含完整的请求头、请求体、抓包时间、对应用户设备ID。没有这些真实样本,后面即使Unidbg跑出结果,也没办法确认算法的输入输出是否完整。

3.2 用Unidbg在PC端还原native调用链

定位到可疑字段后,下一步是找到它的生成位置。先用静态分析做粗筛,打开IDA搜这几个字段的字符串引用,比如搜索x-s或十六进制形式,通常可以定位到Java层调用native方法的入口,从而知道对应的native函数名字。如果目标把字符串做了加密或拼接,也可以先搜so的导出表,找出可疑的签名函数名字,比如带signencodecrypt这类关键字的函数。

拿到函数名之后,就可以在Unidbg里构造参数调用了。这里的核心是搞清楚函数签名。我常用的做法是先反编译Java层,找到调用native方法的接口。例如Java层可能是:

public static native String sign(String payload, String timestamp);

那么Unidbg对应调用就是传入两个jstring。如果native方法接收的是byte[]int等类型,也要一一对应构造。Unidbg里构造jbyteArray可以用new ByteArray(vm, data),构造jstring就是直接传Java的String。调用之后返回的可能是jstring,也可能是jobject,视情况再做类型转换。

这里一个小技巧是:如果你的函数在导出表里找不到,但你知道它在某个地址,可以用module.callFunction(emulator, address, args...)直接按地址调用。地址可以从IDA里拿到,注意基址偏移要转成Unidbg里的绝对地址。例如在IDA里函数地址是0x12345,加载基址是0x40000000,真实地址就是0x40000000 + 0x12345,但Unidbg已经帮你处理了模块基址,直接用相对偏移即可,前提是loadLibrary时选择了保存符号信息。

3.3 参数追踪与交叉验证

Unidbg跑通一次调用之后,先不要急着还原算法。最首要是验证:这次调用的输入输出和真机上抓到的数据是否一致。一致性的判断标准是:给定相同的输入组合,Unidbg算出的签名结果必须和真机生成的完全相同。

实际操作中,我发现很多不一致的原因是输入遗漏。比如签名函数除了明文和时间戳,还悄悄读了设备ID、会话token、App版本号,甚至so内部自己的一个随机种子。Unidbg里这些值如果没注入,结果自然对不上。这时候就需要启用JNI回调日志,把so运行期间调用过的Java方法全部打出来,看它都在读哪些东西。

我这里分享一个典型的排查过程。某次分析的签名一直比对不上,我用Unidbg打印了所有JNI回调,发现so在计算前先调用了一个getDeviceInfo()方法,从Java层读取设备指纹。我一开始完全忽略了这个输入,导致Unidbg返回的结果始终不同。后来我在Unidbg里注册了一个同名的Java方法,返回设备指纹字符串,再次比对,输出就完全一致了。

交叉验证还有一个好处:你不需要一开始就知道算法细节,只要输入输出能够对齐,后续用差分法还原就容易得多。我建议把样本集做成自动化测试,每次修改调用的参数类型或注入数据,都跑一遍全量回归,确保不会改坏之前已经验证通过的逻辑。

4. 算法还原过程中的几个关键环节

4.1 符号调用与边界对齐

跑通Unidbg只是一小步,真正花时间的是把so里内部的算法逻辑搞清楚。首先要处理的是一堆“隐式初始化”问题。很多so的算法不是独立函数,而是依赖JNI_OnLoadinit_array、全局构造函数里的初始化状态。你直接调用业务函数,可能因为某个全局变量没初始化而得到错误结果,甚至崩溃。

解决办法是在Unidbg里按顺序执行初始化流程:加载so后,先用module.callJNI_OnLoad(emulator)触发JNI注册,再查找.init_array里的构造函数。Unidbg提供的callJNI_OnLoad已经包含了大部分标准流程,但如果so自己实现了线程创建或其他系统调用,可能还需要你额外Hook。常见做法是Hookpthread_create,暂时屏蔽掉那些不重要的后台线程,避免模拟器卡住或崩溃。

内存边界问题也是新手最容易踩的坑。Unidbg模拟的Android环境虽然完整,但毕竟不是真ART,内存对齐和JNI对象布局有一些细微差异。比如向so传入一个Java String时,Unidbg会生成对应的JNI字符串指针,但这个指针只在当前JNI调用内有效,如果so把它保存下来,稍后再用,可能会出现指针失效。遇到这种情况,我会提前在Unidbg中调用vm.addGlobalObject(obj)把对象全局化,确保它不会被GC释放。

4.2 从动态调用结果反推算法结构

我认为Unidbg对算法还原最大的帮助,不是说它能自动告诉你用了什么加密,而是它能让你极其方便地做差分实验。你可以任意修改输入内容、修改长度、修改对齐方式,然后观察输出变化。这种实验在真机上做会非常麻烦,在Unidbg里就是改几个java参数。

拿密码学算法识别来说,我会先观察输出长度。如果输入16字节、输出也是16字节,很可能是AES类分组加密;如果输出总是32字节,可能是MD5、SHA-256、HmacSHA256或某种摘要算法。接下来看分组特征:把输入长度从15改成16、17,观察输出长度是否发生跳变。如果从15到16没有跳变但17变长,说明有Padding机制,可能是AES/CBC或ECB模式。再把输入里的固定前缀改成另一个值,观察哪些位置的输出发生变化,从而推断是否使用了IV或CBC模式。

想要进一步确认具体算法,可以在Unidbg里用addHookListener对内存读取做监控。比如算法在运行时读取了固定的常数表,你会在内存日志里看到固定的地址范围被反复读取,把这些数据dump出来对比MD5、SHA、AES等标准常量表,基本能判断出算法类型。如果是自研算法,没有现成常量表,那就需要配合指令trace来分析了。Unidbg允许对某个代码范围开启trace,把每条指令的寄存器和内存访问打印出来,配合骚操作时,能快速定位核心循环和关键变量。

我见过不少人一上来就想着把整个函数反编译成等价代码,这其实没必要。更高效的做法是只还原出一份“业务等价”的实现,即你给我同样的输入,我能算出同样的输出。比如某个签名算法内部可能包含了AES加密、自定义置换、CRC校验、异或混淆,你不需要把每一处混淆都逆向到源码级别,只要通过动态观察和数学分析,写出一个输入输出完全一致的等价函数,就已经达到了协议分析的目标。

当然,这个过程要遵守底线。如果目标算法涉及标准密码学算法,建议直接复用开源库实现,不要自己边猜边写,容易在边界情况出错。如果我判断某段逻辑是自研算法,我会在Unidbg里构造大量的边界测试用例,比如空输入、超长输入、特殊字符、全零、随机串,保证等价实现和原始so在全部用例上输出一致,而不是只看一两个happy path。

5. 常见问题与排查技巧实录

5.1 崩溃、卡死和初始化失败

Unidbg最常见的崩溃是SIGSEGV,多半是目标so依赖了没有加载的库,或者某些JNI调用没有被模拟器支持。我跑某次分析时,libfoo.so加载成功了,但一调用主函数就崩。打印详细日志发现它在调用liblog.so里的__android_log_print,Unidbg默认没实现这个函数。解决办法是显式加载liblog或在JNI层手动Hook掉这些日志函数。

还有一个高频卡死场景是目标so启动了内部线程。Unidbg虽然支持一定程度的线程模拟,但面对无节制的pthread_createsignalsocket等系统调用,模拟器很可能直接卡住。我的处理方案是:先Hookpthread_create,打印线程入口地址后直接返回失败,让so退回到不启动线程的路径;如果业务必须依赖线程完成某个初始化,那就把该线程的逻辑单独拎出来分析,再手动模拟它产生的状态。

调试时建议打开Unidbg的verbose开关:

vm.setVerbose(true); emulator.getMemory().setVerbose(true);

这样so执行过程中的JNI调用、内存读写、模块加载都会打印出来。遇到未知系统调用时,Unidbg通常会抛出异常并在日志里显示是哪条指令触发的。这时候不要硬撑,试着找出对应地址在IDA里的函数上下文,判断它想干什么,再决定是Hook还是自行实现。

5.2 Hook不生效与输出结果不一致

在Unidbg里Hook某个函数时,不生效的原因主要有两类:一类是地址不对,另一类是代码被内联或优化了。地址问题好解决,用module.base + offset确保地址正确;内联问题则需要换Hook点。比如你Hook了一个内部函数,但它只是个薄封装,真正逻辑被内联到调用方了。这种情况下,我会用trace排查:开启指令trace后,观察执行流有没有经过目标地址,如果没有,说明它根本没被调用或被优化掉了,得换一个更底层的函数。

输出结果不一致的问题,最需要系统排查。我的排查顺序是:先检查所有输入数据是否完全一致,包括时间戳、设备ID、随机数、内存地址内容;再检查so内部依赖的全局状态是否被正确初始化;最后检查是否存在多线程时序问题,比如某个随机数是由后台线程生成并写进全局状态的,Unidbg没有跑那个线程,自然得不到一致结果。

我还遇到过一种反直觉的情况:输入输出在单次调用里完全一致,但连续调用10次后开始出现偶发不一致。追踪后发现so内部用了Random类,并且没有显式设置种子,而是从系统时间取的种子。Unidbg模拟的时间精度和真机不完全一样,导致随机序列对不上。解决方案是HookSystem.currentTimeMillisgettimeofday,固定返回值,让随机序列可复现。这也是为什么我建议在做算法还原时,尽量把所有时间相关函数全部固定住。

5.3 常见问题速查表

现象可能原因处理方式
加载so时UnsatisfiedLinkError缺少依赖so,或so架构与模拟器不匹配readelf -h确认ARM/ARM64,把依赖so一并放入资源目录加载
调用函数时SIGSEGV崩在内存地址0传入JNI对象为空,或函数需要全局对象未被保留检查参数是否为null,必要时用vm.addGlobalObject保存对象
执行卡死无输出目标so创建了线程或等待锁Hookpthread_createpthread_mutex_lock,打印线程上下文,暂时屏蔽非关键线程
JNI回调方法报AbstractMethodErrorso回调到Java层的方法没有注册在Unidbg中用vm.setJniResolver注册同名Java方法,返回模拟数据
输出结果与真机不一致输入参数遗漏或随机性输入未固定开启verbose日志,查看so读取了哪些JNI回调或系统时间,逐一固定
Hook点不生效函数被编译期优化/内联,或地址计算错误开启trace确认执行流,换用更低层函数或入口地址
无法识别加密算法类型标准算法被魔改或加了混淆dump内存中固定常量表,对比标准算法常数;构造差分输入做模式判断

这些坑很多不是一次踩完的。我每次分析一个新目标,都会把Unidbg的verbose日志完整留档,遇到异常先翻日志,再定位到具体指令,效率比盲目试要高得多。

最后再分享一个小技巧。当Unidbg跑通目标函数、并且输出和真机完全一致之后,不要急着把这段代码写完就结束。我习惯把它封装成一个本地HTTP服务,入参是明文、时间戳、设备指纹,出参就是签名结果,这样可以把Unidbg的能力直接复用给其他研究环节,比如后续的批量回归测试。封装服务时注意加一层调用超时和熔断,因为有些so在非正常情况下会陷入死循环或异常分支,不能让单个请求拖垮整体流程。另外强烈建议把样本集做成自动化回归用例,每次调整目标so环境或模拟器版本后,都能快速验证之前的结论是否仍然成立。这个习惯帮我省掉了很多重复排查的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询