尧图精选

Unidbg Native协议分析实战:绕过反调试还原签名算法

🕒 发布时间:2026/9/20 10:22:13 📁 来源:尧图网络
先讲个我经常遇到的情况接手一个App的协议分析任务抓包一看请求头里挂着一串几十位的签名参数改一下body里任意一个字节签名就变。你用IDA跟进去满屏花指令反调试挂了一堆Frida刚spawn就被检测到退出真机上一通折腾进度还是卡在入口函数。这个场景做过客户端安全研究的人应该都不陌生。后来我在一个内部项目里正式把Unidbg用起来才发现之前的很多痛苦其实可以绕开。Unidbg是一个基于Unicorn引擎的Java开源库能在PC端模拟Android的JNI环境和native层执行直接在JVM里加载目标so并动态调用。它最大的价值在于把“真机FridaIDA动态调试”这种容易被保护的组合换成了“本地进程模拟执行代码级调试”的方式。整个过程不依赖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底层基于UnicornUnicorn是QEMU抽取出来的轻量级CPU模拟器支持ARM、ARM64、X86等主流指令集。Unidbg在其之上模拟了完整的内存布局、进程堆栈、系统调用、线程调度以及JNI所需的JavaVM和JNIEnv环境。换句话说它把一个so当成一个普通动态库加载到自己的虚拟地址空间里然后调用so里的某个导出函数就像Android里native方法被Java层调用一样。关键是JNI支持。Android的native方法通常用JNIEXPORT导出内部大量调用NewByteArray、GetStringUTFChars、CallObjectMethod这些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 groupIdcom.github.zhkl0228/groupId artifactIdunidbg-android/artifactId version0.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下代码里用DalvikVM和AndroidEmulator加载。整个项目实际上就是一个普通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回调获取包名来做行为校验后面我们会注册对应的回调。调用结果如果是一个jstringUnidbg会自动转成Java String打印。如果是其他类型比如jbyteArray需要手动读取模拟器内存byte[] data emulator.getMemory().readByteArray(address, length);这个读取逻辑在算法还原中会反复用到因为很多加密结果是以字节数组形式返回的。3. 从抓包到定位加密入口的完整链路3.1 先定位请求里的加密字段我一般不会一上来就逆向代码而是先把协议层的字段摸清楚。用Charles或Fiddler这类代理工具抓包时重点关注请求头里那些看起来像签名、非固定值、长度固定的参数。以常见的App为例像x-s、x-t、x-s-common这类字段通常就是服务端做风控和签名校验的关键。定位方法是做差分。把同一个请求body里的明文改一个字符比如把一句话从“你好”改成“你好啊”看哪个字段变化最大哪个字段完全不变。全不变的字段可能就是设备标识或固定token变化的字段基本可以确定是参与签名计算的。接下来再想尽一切办法构造多个输入样本把明文、时间戳、设备参数、响应体都记录下来为后面交叉验证做准备。这一步不需要太高深技术但对后续工作极其重要。我给自己的要求是样本至少留20条每条都包含完整的请求头、请求体、抓包时间、对应用户设备ID。没有这些真实样本后面即使Unidbg跑出结果也没办法确认算法的输入输出是否完整。3.2 用Unidbg在PC端还原native调用链定位到可疑字段后下一步是找到它的生成位置。先用静态分析做粗筛打开IDA搜这几个字段的字符串引用比如搜索x-s或十六进制形式通常可以定位到Java层调用native方法的入口从而知道对应的native函数名字。如果目标把字符串做了加密或拼接也可以先搜so的导出表找出可疑的签名函数名字比如带sign、encode、crypt这类关键字的函数。拿到函数名之后就可以在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_OnLoad、init_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_printUnidbg默认没实现这个函数。解决办法是显式加载liblog或在JNI层手动Hook掉这些日志函数。还有一个高频卡死场景是目标so启动了内部线程。Unidbg虽然支持一定程度的线程模拟但面对无节制的pthread_create、signal、socket等系统调用模拟器很可能直接卡住。我的处理方案是先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.currentTimeMillis或gettimeofday固定返回值让随机序列可复现。这也是为什么我建议在做算法还原时尽量把所有时间相关函数全部固定住。5.3 常见问题速查表现象可能原因处理方式加载so时UnsatisfiedLinkError缺少依赖so或so架构与模拟器不匹配用readelf -h确认ARM/ARM64把依赖so一并放入资源目录加载调用函数时SIGSEGV崩在内存地址0传入JNI对象为空或函数需要全局对象未被保留检查参数是否为null必要时用vm.addGlobalObject保存对象执行卡死无输出目标so创建了线程或等待锁Hookpthread_create、pthread_mutex_lock打印线程上下文暂时屏蔽非关键线程JNI回调方法报AbstractMethodErrorso回调到Java层的方法没有注册在Unidbg中用vm.setJniResolver注册同名Java方法返回模拟数据输出结果与真机不一致输入参数遗漏或随机性输入未固定开启verbose日志查看so读取了哪些JNI回调或系统时间逐一固定Hook点不生效函数被编译期优化/内联或地址计算错误开启trace确认执行流换用更低层函数或入口地址无法识别加密算法类型标准算法被魔改或加了混淆dump内存中固定常量表对比标准算法常数构造差分输入做模式判断这些坑很多不是一次踩完的。我每次分析一个新目标都会把Unidbg的verbose日志完整留档遇到异常先翻日志再定位到具体指令效率比盲目试要高得多。最后再分享一个小技巧。当Unidbg跑通目标函数、并且输出和真机完全一致之后不要急着把这段代码写完就结束。我习惯把它封装成一个本地HTTP服务入参是明文、时间戳、设备指纹出参就是签名结果这样可以把Unidbg的能力直接复用给其他研究环节比如后续的批量回归测试。封装服务时注意加一层调用超时和熔断因为有些so在非正常情况下会陷入死循环或异常分支不能让单个请求拖垮整体流程。另外强烈建议把样本集做成自动化回归用例每次调整目标so环境或模拟器版本后都能快速验证之前的结论是否仍然成立。这个习惯帮我省掉了很多重复排查的时间。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →