Unidbg实战:逆向分析Android Native层加密算法与签名生成
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> <groupId>com.github.zhkl0228</groupId> <artifactId>unidbg</artifactId> <version>0.9.4</version> <!-- 注意:版本号需根据实际情况调整 --> </dependency> <!-- 可能需要添加一些辅助依赖,如日志框架 --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>1.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. 创建模拟器,指定架构为ARMv7(32位),更通用,兼容性更好 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, R0=0x%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 WrapCallback<HookZzArm32RegisterContext>() { @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-SHA256,2个是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, Map<String, String> params, String deviceIdKey) { try { // 1. 参数排序并拼接成 key1=value1&key2=value2 格式 List<String> paramList = new ArrayList<>(); for (Map.Entry<String, 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这把利器。
