Unidbg实战:逆向分析短视频App SO加密算法与模拟执行

Unidbg实战:逆向分析短视频App SO加密算法与模拟执行
1. 项目概述当逆向分析遇上“沙盒”在移动安全与逆向工程领域我们常常面临一个棘手的问题目标应用的核心加密算法被封装在原生共享库SO文件即Android的.so或iOS的.dylib/.a中。传统的动态调试需要真机或模拟器环境搭建繁琐且极易触发应用的反调试检测导致进程崩溃或逻辑自毁。静态分析又如同盲人摸象面对高度混淆、控制流平坦化的机器码理清逻辑耗时耗力。这时Unidbg就像一个专为逆向工程师打造的“沙盒模拟器”。它允许我们在普通的Java或Python环境中直接加载并模拟执行这些SO文件中的代码无需启动完整的Android系统或越狱的iOS设备。本次实战的目标正是利用Unidbg在不触碰真机、不修改APK的前提下逆向并调用某款流行短视频App中的一个核心SO加密函数从而理解其数据加密流程并实现算法的本地复现。这个项目的价值在于它为我们提供了一种高效、安全、可复现的分析手段。无论是为了安全研究、风控策略验证还是学习先进的代码保护技术Unidbg模拟执行都是一项必备的高级技能。接下来我将从一个逆向老手的视角带你从零开始拆解整个实战过程。2. 逆向目标分析与环境搭建2.1 目标SO定位与初步分析我们的目标是某短视频App的签名或请求参数加密算法。通常这类算法位于libcms.so、libsscrypto.so或类似名称的库中。首先我们需要获取目标APK。注意所有分析应仅用于授权的安全测试或学习研究务必遵守相关法律法规与服务条款。使用apktool或jadx-gui解压APK后在lib/目录特别是lib/arm64-v8a或lib/armeabi-v7a下寻找可疑的SO文件。通过搜索字符串如“sign”、“encrypt”、“md5”、“aes”等或分析JNI_OnLoad函数可以快速定位目标函数。假设我们最终定位到libencrypt.so中的Java_com_xxx_encrypt_EncryptUtils_getSign这个JNI函数。初步的静态分析使用IDA Pro或Ghidra显示该函数接收一个Java字符串可能是请求参数拼接的字符串经过一系列复杂运算后返回一个签名字符串。内部可能调用了其他私有函数并使用了大量的位运算和常量表初步判断是自定义的哈希算法或基于标准算法的魔改。2.2 Unidbg环境搭建与基础配置Unidbg是一个Java项目因此我们需要准备Java开发环境。我推荐使用IntelliJ IDEA管理依赖和运行更为方便。创建项目新建一个Maven或Gradle项目。引入依赖在pom.xml中添加Unidbg的依赖。目前官方维护的版本可在Maven中央仓库找到。dependency groupIdcom.github.zhkl0228/groupId artifactIdunidbg/artifactId version0.9.4/version !-- 请使用最新稳定版 -- /dependency准备SO文件与虚拟机将目标libencrypt.so及其可能依赖的其他SO文件如libc.so,libdl.so放置到项目的资源目录如src/main/resources下。Unidbg需要这些系统库来解析目标SO的导入函数。核心的模拟器选择有两种AndroidEmulator模拟ARM Android用户态和DarwinARMEmulator用于iOS。这里我们选择AndroidEmulator。// 示例代码框架 public class DouyinEncrypt { private final AndroidEmulator emulator; private final VM vm; private final Module module; public DouyinEncrypt() { // 创建模拟器支持32位或64位。根据目标SO的架构选择。 emulator new AndroidARMEmulator(com.example.douyin); Memory memory emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); // 设置API级别 vm emulator.createDalvikVM(null); // 加载SO文件 DalvikModule dm vm.loadLibrary(new File(src/main/resources/libencrypt.so), false); dm.callJNI_OnLoad(emulator); // 调用JNI_OnLoad进行初始化 module dm.getModule(); } }这里有几个关键点架构选择务必与目标SO的架构匹配。AndroidARMEmulator对应armeabi-v7aAndroidARM64Emulator对应arm64-v8a。选错会导致无法正确加载指令。API级别AndroidResolver(23)中的23代表Android 6.0。有些SO的功能可能依赖特定API版本如果遇到系统调用失败可以尝试调整。JNI_OnLoad主动调用callJNI_OnLoad至关重要很多SO的全局初始化、函数注册、反调试检测都放在这里。3. Unidbg模拟执行的核心流程与技巧3.1 函数符号定位与调用约定成功加载SO后下一步是找到我们要调用的目标函数。在JNI中函数名遵循固定格式。我们可以通过Unidbg直接调用该符号。public String getSign(String input) { // 1. 为Java字符串参数分配内存 MemoryBlock block emulator.getMemory().malloc(input.length() 1, true); block.getPointer().write(input.getBytes(StandardCharsets.UTF_8)); block.getPointer().setByte(input.length(), (byte) 0); // C字符串结尾的\0 // 2. 获取函数符号 Symbol signFunc module.findSymbolByName(Java_com_xxx_encrypt_EncryptUtils_getSign); if (signFunc null) { // 有时符号被strip需要通过偏移地址获取 signFunc module.findSymbolByAddress(0x1234); // 通过IDA分析得到的函数偏移地址 } // 3. 准备调用 emulator.getMemory().setStackPoint(emulator.getContext().getLongArg(0)); // 设置栈指针简化示例实际需根据ABI // 设置参数JNIEnv*, jobject, jstring emulator.getContext().setLongArg(0, vm.getJNIEnv().getPointer().toUIntPeer()); // JNIEnv* emulator.getContext().setLongArg(1, 0); // jobject (thiz)静态方法则为0 emulator.getContext().setLongArg(2, block.getPointer().toUIntPeer()); // jstring 参数即我们传入的字符串指针 // 4. 调用函数 signFunc.call(emulator); // 5. 获取返回值假设返回jstring long resultPtr emulator.getContext().getLongArg(0); // 根据ARM ABIR0寄存器存放返回值 String result vm.getObject(resultPtr).getValue().toString(); // 6. 释放内存 block.free(); return result; }这个过程的核心在于理解JNI调用约定和ARM ABI应用程序二进制接口。JNI函数的第一个参数永远是JNIEnv*第二个是jclass或jobject。在ARM32上前几个参数通过R0, R1, R2, R3寄存器传递返回值放在R0。Unidbg的emulator.getContext()提供了设置和获取寄存器值的接口。实操心得很多SO会检查JNIEnv*和jobject的有效性。直接传0或随机值可能导致崩溃。一个稳妥的做法是先创建一个虚拟的Java类用Unidbg的vm对象来构造一个合法的jobject。对于JNIEnv*使用vm.getJNIEnv().getPointer().toUIntPeer()总是安全的。3.2 处理系统调用与链接库依赖SO文件在运行时会调用系统函数如strlen,malloc,pthread_create或Android特有的__system_property_get,logcat输出等。Unidbg通过LibraryResolver来提供这些系统函数的实现。memory.setLibraryResolver(new AndroidResolver(23));AndroidResolver已经实现了大量标准C库和Android库的函数。但是目标SO可能使用了非标准或自定义的函数或者某些系统函数的行为需要特殊处理例如time函数需要返回一个符合业务逻辑的时间戳。这时我们需要**补写Hook或实现Implement**这些函数。// 示例Hook一个函数打印其参数和返回值 module.addSymbolHook(__aeabi_memcpy, new SymbolHook() { Override public void hook(SymbolContext context) { // 在函数执行前 long dest context.getLongArg(0); long src context.getLongArg(1); long n context.getLongArg(2); System.out.println(String.format(memcpy called: dest0x%x, src0x%x, n%d, dest, src, n)); // 继续执行原函数逻辑或者我们可以自己实现拷贝逻辑并跳过原函数 context.setSkipInvoke(true); // 跳过原函数 emulator.getMemory().pointer(dest).write(emulator.getMemory().pointer(src).getByteArray(0, (int)n)); } }); // 示例实现一个完全不存在的函数 memory.addHookListener(new HookListener() { Override public void hook(Backend backend, long address, int size, Object user) { if (address module.base 0x5678) { // 某个自定义函数地址 // 读取参数实现逻辑 long arg0 emulator.getContext().getLongArg(0); // ... 计算 emulator.getContext().setLongArg(0, result); // 设置返回值 emulator.getBackend().reg_write(ArmConst.UC_ARM_REG_PC, emulator.getContext().getLR()); // 模拟函数返回 } } });处理系统调用和依赖是Unidbg调试中最耗时但也最核心的部分。你需要像法医一样观察SO在崩溃前最后调用了哪个缺失或行为异常的函数然后为其“量身定做”一个实现。3.3 对抗反调试与完整性校验商业级的SO文件绝不会让你轻易模拟。常见的对抗手段包括检测调试器调用ptrace(PTRACE_TRACEME, ...)、检查/proc/self/status中的TracerPid、或android_log相关函数是否被Hook。在Unidbg中我们可以Hook这些检测点直接返回“未被调试”的状态。// Hook ptrace使其总是返回0成功 module.addSymbolHook(ptrace, (ctx) - { ctx.setLongArg(0, 0); // 设置返回值 ctx.setSkipInvoke(true); });校验文件完整性计算SO文件自身或内存中特定段的CRC32/MD5与预设值比较。我们需要在Unidbg中让这些校验函数返回“正确的”校验值。一种方法是先让程序在真机上运行一次打印出正确的校验值然后在Hook中直接返回该值。时间/环境检测检查gettimeofday、clock_gettime返回的时间戳是否合理或检查/proc/cpuinfo等设备信息。我们需要模拟一个合理的环境。// 固定时间戳使算法输出稳定 module.addSymbolHook(gettimeofday, (ctx) - { Pointer tv emulator.getPointer(ctx.getLongArg(0)); tv.setLong(0, 1640995200L); // 2022-01-01 00:00:00 的秒数 tv.setLong(8, 0); // 微秒 ctx.setLongArg(0, 0); // 返回值0表示成功 ctx.setSkipInvoke(true); });多线程与同步原语算法可能使用pthread创建线程或使用mutex、cond进行同步。Unidbg对多线程的模拟支持有限。如果遇到一个可行的策略是Hookpthread_create等函数将其实现改为直接在当前线程顺序执行子线程的任务逻辑避免真正的线程切换。对抗这些保护是一个“猫鼠游戏”的过程。核心思路是让SO文件“感觉”自己运行在一个正常的、未被调试的Android环境中。通过精心设计的Hook我们可以逐步“安抚”这些检测代码引导其走向正常的算法执行路径。4. 算法还原与代码复现4.1 动态追踪与关键逻辑定位当Unidbg能够稳定执行目标函数并返回结果后我们就拥有了一个绝佳的动态分析环境。接下来可以利用Unidbg的**指令级跟踪Tracer**功能记录下函数执行过程中的每一条指令、每一个内存读写和寄存器变化。emulator.getBackend().addHook(new CodeHook() { Override public void hook(Backend backend, long address, int size, Object user) { // 只跟踪目标模块内的代码 if (address module.base address module.base module.size) { Disassembler disassembler UnidbgPointer.getDisassembler(emulator); String asm disassembler.disassemble(backend, address, size, false); System.out.println(String.format(0x%x: %s, address, asm)); // 还可以打印寄存器状态 System.out.println(String.format( R00x%x, R10x%x, backend.reg_read(ArmConst.UC_ARM_REG_R0), backend.reg_read(ArmConst.UC_ARM_REG_R1))); } } });通过分析追踪日志我们可以定位关键循环和分支找到处理输入字符串的主循环。识别加密操作关注eor(异或)、add、ror(循环右移)等位操作指令以及ldr加载常量表的内存地址。找出标准算法特征例如MD5/SHA1有固定的初始化常量和循环移位次数AES的SubBytes,ShiftRows,MixColumns操作也有特定模式。将追踪到的操作序列与标准算法对比可以快速判断是否是魔改算法以及魔改了哪里。4.2 数据流分析与算法抽象在定位了关键代码段后下一步是进行数据流分析。我们需要理解原始输入字符串是如何一步步被变换成最终签名的。记录中间值在关键函数入口、循环开始/结束、内存写操作处设置断点通过Hook打印出当时关键寄存器或内存地址的值。例如记录下每一轮循环处理后的中间哈希值。绘制数据流图手动或借助工具将输入数据经过的每一个操作异或、加、乘、查表、移位记录下来理清其变换路径。与标准算法对齐将记录下的操作序列、常量表与MD5、SHA256、HMAC、AES等标准算法进行比对。很多时候所谓的“自定义加密”只是在标准算法的输入预处理、输出后处理或者S盒Substitution-box、常量表上做了修改。例如你可能发现算法主体是标准的SHA256但在对输入消息进行填充Padding前先对消息头几个字节做了额外的异或或者算法是MD5但初始化向量IV被替换成了另一组魔数。4.3 使用高级语言复现算法一旦完全理解了算法步骤就可以用高级语言如Python、Java、C进行复现了。这是验证分析成果的最后一步也是最关键的一步。# 假设我们分析出是一个魔改的MD5 import hashlib import struct def custom_md5(data: bytes) - str: # 1. 预处理在标准MD5预处理前先对数据头进行魔改 if len(data) 4: # 例如将前4字节与一个固定值异或 magic 0xDEADBEEF prefix struct.unpack(I, data[:4])[0] prefix ^ magic data struct.pack(I, prefix) data[4:] # 2. 使用标准MD5计算这里只是示例真正的魔改可能发生在压缩函数内部 m hashlib.md5() m.update(data) digest m.digest() # 3. 后处理对MD5结果进行二次变换 # 例如将16字节的摘要每4字节一组相加 ints struct.unpack(IIII, digest) result sum(ints) 0xFFFFFFFF return f{result:08x} # 返回一个8位十六进制字符串复现后必须用多组测试数据包括边界情况如空字符串、长字符串、中文字符等与Unidbg模拟执行的结果进行严格比对确保完全一致。任何细微差别都意味着分析还有遗漏。实操心得在复现过程中要特别注意字节序Endianness和整数溢出的处理。ARM通常是小端序Little-Endian而我们在Python/Java中处理字节数组时要格外小心。Unidbg中看到的0x12345678在内存中可能是78 56 34 12。使用struct模块或ByteBuffer时务必指定正确的字节序。5. 实战中常见问题与深度排查指南即使按照流程操作你也一定会遇到各种让模拟器崩溃或结果异常的问题。下面是一个常见问题速查表收录了我踩过的坑和解决方案。问题现象可能原因排查思路与解决方案加载SO时崩溃1. 架构不匹配32位 vs 64位。2. 依赖的系统库缺失或版本不对。3.JNI_OnLoad中有强校验或初始化失败。1. 用file命令确认SO架构匹配创建AndroidARMEmulator或AndroidARM64Emulator。2. 检查LibraryResolver是否包含了所有需要的库如libc.so,libm.so,libdl.so,liblog.so。尝试调整API级别。3. 在callJNI_OnLoad前后打印日志或单步跟踪其执行看崩溃在哪条指令。调用目标函数时崩溃1. 参数传递错误JNIEnv*, jobject, 参数类型。2. 函数内部调用了未实现的系统函数或自定义函数。3. 触发了反调试检测。1. 使用vm.getJNIEnv()获取有效的JNIEnv*。对于非静态方法需要构造一个有效的jobject可通过vm创建该类的实例。2. 查看崩溃时的寄存器PC值和栈回溯找到最后调用的函数名然后补写它。3. 系统性地Hook常见的反调试函数ptrace,fopenof/proc/self/status,syscall等。函数执行成功但返回结果错误1. 环境依赖未模拟好如时间、设备ID。2. 算法依赖某些全局变量或静态数据未正确初始化。3. 多线程或异步逻辑导致执行顺序与预期不符。1. Hookgettimeofday,/proc/cpuinfo读取等返回固定值。2. 在JNI_OnLoad或函数入口处检查并手动设置关键全局内存区域的值。可以通过静态分析找到这些数据的地址和初始值。3. 尝试将pthread_createHook掉改为同步执行。检查是否有信号量或锁未正确模拟。Unidbg执行速度极慢开启了全指令跟踪Tracer。只在必要时开启跟踪或通过地址范围限制跟踪区域。生产性调用时应关闭所有跟踪和调试Hook。如何确定某个未知函数的作用函数名被剥离strip静态分析困难。1.动态分析在Unidbg中Hook该函数打印其所有参数和返回值观察其输入输出规律。2.上下文分析看谁调用了它调用前后数据有何变化。3.模式匹配其内部的指令序列可能匹配某个标准库函数如memcmp,strlen, 某个加密原语。深度排查技巧“二分法”定位崩溃点如果崩溃在一个复杂的函数里可以先尝试跳过函数内部某些可疑的调用通过Hook让其立即返回一个安全值逐步缩小范围定位到具体引发崩溃的指令或函数。内存断点Unidbg支持内存读写断点。如果你知道某个关键数据如密钥、IV的存放地址可以对其设置写保护或读断点观察何时、由哪段代码访问它这能快速理清数据流。对比执行如果条件允许在真机上用frida等工具挂载目标App在相同输入下记录目标函数的参数、返回值以及关键内存区域的内容。然后在Unidbg中重现这一过程并逐条对比日志差异点就是需要修正的模拟行为。Unidbg模拟执行是一个需要极大耐心和细致观察的过程。它不像动态调试那样直观但一旦打通你对目标算法的理解将远超简单的动态跟踪。因为你不仅知道了“它做了什么”还通过补环境、抗反调深刻地理解了“它为什么能这么做”以及“它如何防止别人知道它做了什么”。这整套方法论才是逆向工程中最宝贵的财富。