
1. 项目概述与核心价值最近在逆向分析一个主流的资讯类APP时遇到了一个典型的“硬骨头”它的核心API请求足足带了7个不同的加密签名头。这些头字段像是一道道门禁不搞定它们你连数据的大门都摸不着。更棘手的是这些加密逻辑并非写在Java层而是深藏在Native层的SO库里用C/C实现常规的Java层Hook手段完全失效。这场景想必搞过逆向的朋友都懂直接静态分析ARM汇编或者动态调试门槛高、效率低还容易触发反调试。这时候就该Unidbg登场了。简单说Unidbg是一个基于Java的、用于模拟执行Native代码SO库的逆向分析框架。它不需要真机或模拟器直接在你的Java程序里“造”出一个虚拟的CPU和环境让SO文件“以为”自己正在Android或iOS系统上运行从而直接调用其中的函数并拿到计算结果。对于搞定这种“黑盒”加密算法它简直就是“降维打击”。我花了差不多一周时间把这7个加密头全部“剥”了出来并整理成了可复现的Java代码。整个过程踩了不少坑也积累了一些关键心得。这篇文章我就以一个实战复盘的形式带你走一遍完整流程。无论你是想学习Unidbg入门还是正在被某个APP的Native加密困扰相信这篇近万字的干货都能给你提供一条清晰的路径。我们不止讲“怎么做”更重点剖析“为什么这么做”以及“哪里容易翻车”。2. 环境准备与Unidbg初探2.1 工具链与依赖搭建工欲善其事必先利其器。首先你需要一个Java开发环境。我推荐使用JDK 11或17稳定性比较好。IDE方面IntelliJ IDEA社区版就完全够用。创建一个标准的Maven项目然后在pom.xml中添加Unidbg的核心依赖。这里有个关键点Unidbg项目更新迭代较快且有一些个人维护的优化版本。我经过测试选择了目前兼容性和功能都比较稳定的一个分支版本。dependencies dependency groupIdcom.github.zhkl0228/groupId artifactIdunidbg/artifactId version0.9.4/version !-- 注意版本号需根据实际情况调整 -- /dependency !-- 可能需要添加一些辅助依赖如日志框架 -- dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version1.7.36/version /dependency /dependencies注意Unidbg的中央仓库地址可能需要额外配置。如果无法直接下载你可能需要手动将Jar包导入或者配置Github Packages等仓库源。这是第一个小坑很多新手在环境搭建时就卡住了。除了Unidbg本身你还需要目标APP的APK文件。使用任何一款反编译工具如Jadx-GUI、Apktool将其打开。我们的目标很明确找到那些加密的SO库文件通常在lib/目录下特别是armeabi-v7a或arm64-v8a架构的以及Java层中加载和调用这些SO库的代码。2.2 理解目标定位七个加密头通过抓包工具如Charles、Fiddler拦截目标APP的请求我发现每个API请求的Header里都包含了类似以下的字段X-Sign: 89a7dfe8c27a... X-Timestamp: 1646123456789 X-Nonce: gY3p9kL X-Client-ID: android_xxx X-Encrypt-Key: U2FsdGVkX1... X-Request-ID: 550e8400-e29b-41d4-a716-446655440000 X-Payload-Checksum: crc32_of_body这七个字段就是我们要攻克的堡垒。其中X-Sign和X-Encrypt-Key看起来就是典型的加密结果X-Nonce像是随机数X-Payload-Checksum是对请求体的校验。下一步就是在反编译的Java代码中搜索这些Header字段名顺藤摸瓜找到设置它们的地方。用Jadx全局搜索“X-Sign”很快就能定位到网络请求封装类。你会发现这些值的计算最终都流向了一个NativeUtil或SecurityHelper这样的类里面全是native方法声明例如public class SignGenerator { public static native String generateSign(String param1, String param2, long timestamp); public static native String encryptKey(String source); // ... 其他方法 }对应的在静态初始化块里一定有System.loadLibrary(crypto)这样的语句。这个crypto就是我们要分析的SO库文件名可能是libcrypto.so。把它从APK的lib/armeabi-v7a/目录里提取出来这就是我们Unidbg模拟执行的主角。3. Unidbg模拟执行的核心步骤3.1 创建虚拟机和加载SO库Unidbg的核心是创建一个AndroidEmulator对象它模拟了ARM CPU和Android操作系统环境。然后我们需要将SO库加载到这个虚拟机中。// 1. 创建模拟器指定架构为ARMv732位更通用兼容性更好 AndroidEmulator emulator new AndroidEmulatorBuilder() .setProcessName(com.target.app) .setRootDir(new File(target)) // 指定一个工作目录 .build(); // 2. 获取内存接口和虚拟机 final Memory memory emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); // 设置Android API级别23对应6.0 // 3. 创建虚拟机 final VM vm emulator.createDalvikVM(); // 4. 加载SO库 Module module emulator.loadLibrary(new File(unidbg-android/src/test/resources/libcrypto.so), true); // true表示强制加载 // 5. 调用JNI_OnLoad如果SO库有的话 emulator.getBackend().thread_loop();这里有几个至关重要的细节架构选择大部分较老的APP使用armeabi-v7a选择ARM模拟器即可。如果是较新的64位APP需要选择ARM64。用错了架构加载SO时会直接失败。API级别AndroidResolver(23)中的数字代表Android版本。需要根据APP的minSdkVersion来大致确定。设高了可能找不到某些系统符号设低了可能行为不一致。一个稳妥的做法是先用一个中间版本如23出错了再调整。工作目录需要提前创建一个target目录Unidbg会在里面生成一些缓存和虚拟文件。最好使用绝对路径避免相对路径带来的问题。加载与循环loadLibrary的第二个参数true表示即使有些初始化错误也强制加载。加载后一定要调用thread_loop()来执行SO库初始化时的线程循环否则一些在JNI_OnLoad里启动的守护线程可能无法正常工作导致后续调用崩溃。3.2 定位与调用目标JNI函数SO库加载成功后我们需要调用具体的JNI函数。首先得知道函数在SO库里的符号名。JNI函数的命名规则是Java_[包名类名全路径]_[方法名]其中.要替换成_。例如对于类com.example.app.util.SignGenerator中的generateSign方法其JNI函数名可能是Java_com_example_app_util_SignGenerator_generateSign我们可以用readelf、objdump或者IDA Pro等工具查看SO的导出符号表来确认。在Unidbg代码中调用方式如下// 假设我们已经拿到了JNI函数指针对应的符号地址 // 方式一通过模块和符号名获取函数对象 Number funcAddr module.findSymbolByName(Java_com_example_app_util_SignGenerator_generateSign).getAddress(); // 方式二如果知道偏移量也可以直接计算 // Number funcAddr module.base 0x1234; // 准备参数 String param1 test_data; String param2 android; long timestamp System.currentTimeMillis() / 1000; // 在Dalvik虚拟机中调用 vm.setJni(this); // 设置JNI环境可以处理Java对象转换 vm.setVerbose(true); // 打印详细日志调试时非常有用 // 调用JNI函数。注意JNI函数的前两个参数永远是JNIEnv*和jclass/jobject // 在Unidbg中我们通常使用emulator.eFunc来调用但更推荐使用封装好的DalvikVM的JNI调用方式 // 这里演示更接近底层的方式实际中我们可能会封装一个更易用的方法 DvmObject? context vm.resolveClass(com/example/app/util/SignGenerator).newObject(null); // 实际上对于静态native方法第二个参数是jclass我们可以用null暂时替代直接调用底层函数指针比较复杂因为涉及到JNIEnv结构体和参数传递。更常见且推荐的做法是利用Unidbg的DalvikModule和DvmClass去“欺骗”SO库让它以为是在真正的Android环境里被Java代码调用。// 更实用的方法在VM中执行一段Java代码来触发对native方法的调用 DalvikModule dm vm.loadLibrary(crypto, true); // 获取对应的DvmClass DvmClass signClass vm.resolveClass(com/example/app/util/SignGenerator); // 调用其静态native方法 String result signClass.callStaticJniMethodObject(emulator, generateSign(Ljava/lang/String;Ljava/lang/String;J)Ljava/lang/String;, vm.addLocalObject(new StringObject(vm, param1)), vm.addLocalObject(new StringObject(vm, param2)), timestamp).getValue().toString(); System.out.println(生成的Sign: result);这段代码的关键在于方法签名(Ljava/lang/String;Ljava/lang/String;J)Ljava/lang/String;它必须和Native方法声明完全一致。一个字符都不能错否则会调用失败或者结果错误。获取方法签名可以直接看反编译的Java代码或者用javap -s命令。3.3 处理复杂的参数与返回值实际情况往往比上面的例子复杂。加密函数可能接收字节数组(byte[])、自定义对象或者修改传入的Java对象。返回值也可能是byte[]、int或boolean。处理byte[]参数与返回值// 假设native方法签名 public static native byte[] encryptData(byte[] input); byte[] inputData hello.getBytes(StandardCharsets.UTF_8); // 在Unidbg中需要将byte[]包装成ByteArray对象 ByteArray inputArray new ByteArray(vm, inputData); vm.addLocalObject(inputArray); // 调用方法 DvmObject? resultArray signClass.callStaticJniMethodObject(emulator, encryptData([B)[B, vm.addLocalObject(inputArray)); // 从结果中提取byte[] byte[] encryptedData ((ByteArray)resultArray.getValue()).getValue();处理自定义对象如果参数是一个自定义的Request对象你需要先在Unidbg中构造一个对应的DvmObject。这需要你了解该对象的字段结构。一种方法是在Java层写一个简单的类用Unidbg加载这个类然后实例化。另一种更直接的方法是用vm.resolveClass找到类然后newObject再通过JNI函数去设置其字段值。这个过程比较繁琐需要结合日志一点点调试。实操心得在调用复杂函数前务必开启详细日志vm.setVerbose(true);和emulator.getBackend().showRegs();。Unidbg会打印出每一步的寄存器值、调用的函数地址、堆栈情况。这是你排查问题最强大的武器。看到SIGSEGV段错误不要慌通常是参数传递错误或者内存访问越界根据日志回溯到出错前的那条指令分析原因。4. 逆向分析与算法还原实战4.1 静态分析与动态Hook结合仅仅能调用函数拿到结果还不够我们的目标是还原算法以便在任何地方都能独立生成这些加密头。Unidbg虽然能直接给出结果但它本身不告诉你算法逻辑。我们需要结合静态分析。入口点分析通过Unidbg成功调用函数后记下函数的入口地址。用IDA Pro打开SO库跳转到这个地址开始静态分析伪代码。关键数据流跟踪在IDA中查看函数接收的参数通常是JNIEnv*,jclass,jstring param1...跟踪这些参数是如何被处理最终生成返回值的。关注其中出现的常量字符串如AES/ECB/PKCS5Padding、系统函数调用如MD5_Init,AES_set_encrypt_key和关键循环、异或操作。Unidbg动态Hook静态分析遇到复杂控制流或混淆时动态Hook就派上用场了。Unidbg允许你在任意地址设置断点或Hook。// Hook一个函数调用例如Hook libc的strlen函数看看加密过程中处理了哪些字符串 emulator.getBackend().hook_add_new(new CodeHook() { Override public void hook(Backend backend, long address, int size, Object user) { // 当执行到指定地址时打印寄存器状态 System.out.println(String.format(Hook at 0x%x, R00x%x, address, backend.reg_read(UnicornConst.UC_ARM_REG_R0))); } }, module.base 0x5678, module.base 0x5678, null); // 在地址0x5678处设置Hook // 或者Hook一个函数符号 IHookZz hookZz HookZz.getInstance(emulator); hookZz.wrap(module.findSymbolByName(MD5_Update), new WrapCallbackHookZzArm32RegisterContext() { Override public void preCall(HookZzArm32RegisterContext ctx, HookEntryInfo info) { // 调用前打印MD5_Update的输入数据 Pointer dataPtr ctx.getPointerArg(1); long len ctx.getLongArg(2); byte[] input dataPtr.getByteArray(0, (int)len); System.out.println(MD5_Update Input: Hex.encodeHexString(input)); } Override public void postCall(HookZzArm32RegisterContext ctx, HookEntryInfo info) { // 调用后可以处理 } });通过这种“动静结合”的方式你可以清晰地看到原始字符串是如何被拼接的MD5/SHA256在哪个环节介入AES的密钥是如何生成的随机数nonce的生成规则是什么。我遇到的7个加密头里有3个是不同参数的HMAC-SHA2562个是AES加密Base64输出1个是简单的时间戳变形还有1个是请求体的CRC32校验。4.2 算法还原与Java代码实现分析清楚后就可以用纯Java代码实现算法了。这里以其中一个X-Sign的生成算法为例它实际上是HMAC-SHA256(请求路径 “” 排序后的参数字符串, 设备ID密钥)然后将结果进行Hex编码。import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.*; public class SignGenerator { private static final String HMAC_SHA256 HmacSHA256; /** * 生成X-Sign请求头 * param path 请求路径如 /api/v1/news/list * param params 请求参数Map * param deviceIdKey 从APP本地数据中提取的设备相关密钥 * return 计算出的Sign字符串 */ public static String generateXSign(String path, MapString, String params, String deviceIdKey) { try { // 1. 参数排序并拼接成 key1value1key2value2 格式 ListString paramList new ArrayList(); for (Map.EntryString, String entry : params.entrySet()) { paramList.add(entry.getKey() entry.getValue()); } Collections.sort(paramList); // 按字典序排序 String queryString String.join(, paramList); // 2. 拼接待签名字符串 String stringToSign path queryString; // 3. 计算HMAC-SHA256 Mac mac Mac.getInstance(HMAC_SHA256); SecretKeySpec secretKeySpec new SecretKeySpec(deviceIdKey.getBytes(StandardCharsets.UTF_8), HMAC_SHA256); mac.init(secretKeySpec); byte[] hashBytes mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)); // 4. 转换为十六进制字符串小写 StringBuilder hexSign new StringBuilder(); for (byte b : hashBytes) { hexSign.append(String.format(%02x, b)); } return hexSign.toString(); } catch (NoSuchAlgorithmException | InvalidKeyException e) { e.printStackTrace(); return ; } } }其他几个加密头的实现也类似可能是用时间戳加盐做MD5或者用固定的公钥进行RSA加密。关键在于通过Unidbg的动态执行和Hook我们确定了算法的每一个步骤、每一个密钥和盐值。这些密钥往往隐藏在APP的资产文件、数据库或SharedPreferences中需要你进一步去逆向Java代码来获取。5. 避坑指南与疑难问题排查5.1 常见崩溃与错误处理SIGSEGV(段错误)这是最常见的问题。原因通常是传入了错误的指针如null、访问了未映射的内存、或者JNI函数内部调用了其他未实现的系统函数。排查开启vm.setVerbose(true)看崩溃前最后执行的几条指令。检查你传递给JNI函数的参数是否正确特别是对象引用。确认SO库依赖的其他系统库如libc.so,liblog.so是否被正确解析可以通过memory.setLibraryResolver添加更多解析器。UnsatisfiedLinkError(链接错误)原因找不到要调用的JNI函数符号。排查首先用readelf -s libcrypto.so | grep Java_确认函数名完全正确包括大小写。其次有些SO库会动态注册JNI函数在JNI_OnLoad中用RegisterNatives而不是静态导出。这时你需要HookRegisterNatives函数或者直接调用JNI_OnLoad后再通过其他方式触发函数注册。结果与真机不一致原因算法可能依赖真机环境信息如/dev/urandom、android_id、Build类信息、传感器数据等。解决Unidbg提供了“补丁”机制。你可以实现IO接口重写open、read等方法当SO库尝试读取特定文件如/proc/self/status时返回你预设好的数据。对于android_id等系统属性可以通过vm.setJni()传入自定义的JNI实现来拦截SystemProperties.get调用。// 示例拦截文件读取返回固定的“设备ID” emulator.getSyscallHandler().addIOResolver(new IOResolver() { Override public FileResult resolve(Emulator? emulator, String pathname, int oflags) { if (pathname.equals(/sys/class/net/wlan0/address)) { // 当SO库尝试读取MAC地址时返回一个固定的假地址 return FileResult.success(new SimpleFileIO(oflags, pathname, new ByteArrayInputStream(02:00:00:00:00:00.getBytes()))); } return null; } });5.2 性能优化与稳定性Unidbg模拟执行每条ARM指令速度比真机慢很多。一个复杂的加密函数可能执行几十万条指令跑一次需要几秒甚至十几秒。缓存结果对于固定输入输出不变的函数如设备初始化调用一次后将结果缓存下次直接使用。精简环境只加载必要的SO库关闭不必要的日志输出调试完成后将setVerbose设为false。识别纯计算函数如果确认某个函数只是纯粹的数学计算不依赖任何环境可以尝试用Unidbg执行一次后记录下其输入输出然后用Java完全重写算法这是终极优化。5.3 对抗反调试与代码混淆一些安全性较高的APP会在SO库里加入反调试、代码混淆、控制流平坦化等保护措施。反调试检测SO库可能会检查/proc/self/status中的TracerPid或调用ptrace、fork等。在Unidbg中可以通过前面提到的文件IO拦截和系统调用Hook返回“未调试”的状态。指令级Hook对于简单的完整性校验如检查函数开头几个字节是否被修改可以用Unidbg的Hook能力在函数执行时动态修复内存中的指令。耐心与技巧遇到高度混淆的代码静态分析几乎失效。这时更要依赖Unidbg的动态执行和Hook像“单步调试”一样记录下所有关键的内存读写、分支跳转和函数调用人工梳理出逻辑。这个过程很耗时但往往是唯一的办法。6. 完整代码结构与集成测试当你把所有加密头的算法都还原并实现后最终的代码结构应该是清晰、可配置的。src/main/java/com/yourcompany/decrypt/ ├── AppSigner.java // 主类整合所有加密头生成逻辑 ├── crypto/ // 加密算法实现包 │ ├── HmacSigner.java │ ├── AesEncryptor.java │ └── Crc32Calculator.java ├── model/ // 数据模型 │ └── RequestContext.java // 封装请求路径、参数、设备信息等 └── unidbg/ // Unidbg相关封装用于辅助分析生产环境可移除 ├── CryptoEmulator.java // 封装Unidbg虚拟环境 └── JniFunctionHook.java // 自定义Hook示例集成测试编写单元测试用Unidbg模拟执行的结果和你纯Java实现的结果进行对比。确保在各种边界情况空参数、超长字符串、特殊字符下两者输出完全一致。这一步是保证算法还原正确性的关键。public class SignerTest { Test public void testXSignGeneration() { // 准备测试数据 RequestContext context new RequestContext(...); String expectedSign CryptoEmulator.callNativeGenerateSign(context); // Unidbg方式 String actualSign AppSigner.generateXSign(context); // 纯Java实现 assertEquals(X-Sign生成不一致, expectedSign, actualSign); } // ... 测试其他6个加密头 }7. 总结与延伸思考搞定这7个加密头其实是一个标准的Native层逆向工程流程抓包定位 - 静态分析找入口 - Unidbg动态验证 - Hook跟踪数据流 - 算法还原 - 代码实现与测试。Unidbg在其中扮演了“桥梁”和“探测器”的角色极大地降低了直接逆向ARM汇编的难度。回过头看整个过程中最花时间的往往不是Unidbg本身而是对APP整体安全体系的理解。比如那个deviceIdKey它可能是在APP安装时生成并加密存储的也可能由服务器下发的令牌派生而来。这就需要你进一步分析Java层的代码找到密钥的源头。有时候加密函数本身并不复杂但前置的密钥生成流程却绕了七八个弯。另外不要满足于“跑通”。要思考算法背后的设计意图为什么用HMAC而不用普通哈希为什么有的参数要参与签名有的不用时间戳为什么是10位而不是13位理解这些不仅能帮你更好地还原算法还能提升你对安全设计的认知。最后技术是把双刃剑。我们学习Unidbg和逆向技术是为了提升自己的安全攻防能力、进行合法的安全评估或兼容性开发。请务必在法律法规和授权范围内使用这些知识尊重开发者的劳动成果。希望这篇超详细的复盘能帮你打开Native逆向的大门下次遇到“加密黑盒”时能从容地拿出Unidbg这把利器。