移动端加密算法逆向实战:Frida+Unidbg破解x-sign签名 1. 项目概述一次完整的移动端加密算法逆向之旅最近在分析一些移动端应用的数据交互时遇到了一个经典的挑战逆向其核心的签名算法。这次的目标是某二手交易平台我们暂且称之为“黄鱼”的x-sign参数。这个参数是客户端发起网络请求时用于验证请求合法性和防止重放攻击的关键签名通常由一系列请求参数、时间戳和设备信息等经过特定的加密算法生成。不搞定它就无法模拟客户端进行自动化数据抓取或分析。整个逆向过程就像一场侦探游戏你需要从App这个“黑盒”的输入输出关系中推理出内部的计算逻辑。我采用的路径是从动态调试入手快速定位关键代码再到用Unidbg进行完整的算法复现和验证。这套组合拳对于处理大多数移动端尤其是Android的Native层加密逻辑非常有效。无论你是移动安全研究员、爬虫工程师还是对逆向工程感兴趣的学习者这个过程都能让你对App的防护机制和逆向方法有更深入的理解。2. 逆向工程的核心思路与工具选型逆向一个未知的算法尤其是像x-sign这种很可能被放在Native层C/C以增加逆向难度的算法不能盲目地一头扎进代码里。一个清晰的策略能事半功倍。我的核心思路是“由外而内动静结合”。2.1 为什么选择“Frida动态调试 Unidbg复现”这条路径首先直接静态分析一个加固过的、混淆严重的APK尤其是其so库Native动态链接库犹如大海捞针效率极低。Frida作为一个强大的动态插桩框架允许我们在App运行时像做手术一样精准地在关键函数调用前后注入我们的代码实时查看参数、返回值、甚至修改逻辑。这能帮助我们快速定位到生成x-sign的函数是哪一个以及它的输入输出是什么。这是“动”的部分目的是快速找到目标。然而Frida依赖真实的App运行环境。对于自动化、批量化的需求或者想在服务器端复现算法总不能一直挂着手机跑Frida。这时Unidbg的价值就体现出来了。Unidbg是一个基于Java的模拟器可以模拟Android或iOS的Native环境让我们能够直接加载和调用so文件中的函数而无需依赖完整的App或真机。这是“静”的部分目的是将找到的算法逻辑剥离出来形成一个独立的、可移植的模块。2.2 工具链准备与环境搭建工欲善其事必先利其器。以下是本次逆向过程中用到的核心工具及其作用一部已Root的Android测试机或模拟器这是运行目标App和Frida的基础。我推荐使用Android Studio自带的x86_64架构模拟器并刷入Magisk进行Root这样环境干净快照功能方便回退。Frida Frida-Server核心动态调试工具。在电脑上安装Frida的Python包 (pip install frida-tools)并将对应版本的frida-server推送到手机/模拟器并运行。Jadx-GUI强大的APK反编译工具用于快速浏览Java层代码结构寻找可能的签名函数入口例如在OkHttp拦截器或Retrofit的Converter中。IDA Pro 或 Ghidra静态分析so文件的利器。在通过Frida定位到关键Native函数后需要用它们来深入分析该函数的汇编或反编译后的C代码逻辑。Unidbg算法复现的最终舞台。你需要建立一个Java项目Maven或Gradle引入Unidbg的依赖然后编写代码来模拟调用。抓包工具如Charles或Fiddler用于捕获含有x-sign的网络请求为后续的算法验证提供“标准答案”。注意所有操作请在合法合规的前提下进行仅用于学习与研究自己拥有合法权限的App。逆向他人软件可能违反用户协议或相关法律。3. 动态定位使用Frida Hook关键函数一切从一次网络请求开始。用抓包工具捕获一个典型的请求你会发现x-sign这个参数每次请求都不同但同一时刻相同参数的请求其值固定说明它是一个基于某些输入计算出的签名。3.1 从Java层寻找突破口首先用Jadx打开APK。搜索关键词“x-sign”、“sign”、“getSign”等。常见的签名逻辑可能放在网络库的拦截器里。例如在OkHttp中可能会找到一个Interceptor在请求发出前统一添加签名参数。找到这个类就找到了Java层调用Native方法的入口。通常代码会类似这样public class SignInterceptor implements Interceptor { Override public Response intercept(Chain chain) throws IOException { Request request chain.request(); String url request.url().toString(); String params ... // 拼接参数 String xSign NativeLib.getSign(params, timestamp, someOtherKey); // 调用Native方法 // 将xSign添加到请求头或URL参数中 ... } }我们的目标就是这个NativeLib.getSign方法。记下它的类名和方法签名。3.2 Frida Hook Java层方法编写一个Frida脚本来Hook这个Java方法打印它的输入参数和返回值。// hook_java_sign.js Java.perform(function () { var SignClass Java.use(com.xxx.xxx.NativeLib); // 替换为实际类名 SignClass.getSign.implementation function (paramStr, timestamp, key) { console.log([*] Hook到 getSign 方法被调用); console.log( 参数 paramStr: paramStr); console.log( 参数 timestamp: timestamp); console.log( 参数 key: key); var result this.getSign(paramStr, timestamp, key); // 调用原方法 console.log( 返回值 x-sign: result); console.log(-----------------------------------); return result; }; });在命令行运行frida -U -f com.xxx.targetapp -l hook_java_sign.js --no-pause。然后操作App触发一个网络请求。如果Hook成功你将在终端看到打印出的参数和生成的x-sign值。将这个值与抓包工具捕获的值对比如果一致恭喜你找到了正确的Java入口。3.3 深入Native层Hook JNI函数getSign方法大概率通过JNI调用到了so库里的函数。我们需要继续向下追踪。可以使用Frida的Interceptor.attach来Hook这个Native函数。但首先我们需要知道它在哪个so文件里以及它的函数符号是什么。一种方法是HookSystem.loadLibrary看看App加载了哪些so。更直接的是在Hook了Java方法后通过打印堆栈看调用链路。或者在IDA中打开APK解压后的lib目录下可能的so文件如libsign.so,libsecurity.so,libmain.so等搜索getSign相关的导出函数。假设我们通过分析确定目标函数在libsign.so中函数名可能是Java_com_xxx_xxx_NativeLib_getSign标准的JNI函数命名或一个被动态注册的匿名函数。对于动态注册我们需要找到JNI_OnLoad函数或RegisterNatives的调用点。这里给出一个Hook标准JNI函数的Frida脚本示例// hook_native_sign.js Java.perform(function () { // 首先获取so库的基地址 var libsign Module.findBaseAddress(libsign.so); if (libsign) { console.log([*] libsign.so 基地址: libsign); // 假设我们知道了函数偏移地址通过IDA静态分析获得 var funcOffset 0x1234; // 替换为实际偏移 var nativeGetSignPtr libsign.add(funcOffset); Interceptor.attach(nativeGetSignPtr, { onEnter: function (args) { console.log([*] 进入Native getSign函数); // JNIEnv* 是第一个参数jobject是第二个jstring等是后续参数 // 注意需要根据函数签名来解析args[2], args[3]... var paramStr Java.vm.getEnv().getStringUtfChars(args[2], null).readCString(); console.log( Native层输入 paramStr: paramStr); // 可以同样打印其他参数 }, onLeave: function (retval) { console.log([*] 离开Native getSign函数); // retval 是一个 jstring 对象指针 var result Java.vm.getEnv().getStringUtfChars(retval, null).readCString(); console.log( Native层输出 x-sign: result); } }); } else { console.log([-] 未找到 libsign.so); } });通过动态调试我们最终可以确认Native函数的准确地址、输入参数顺序和类型、以及返回值。这为后续的静态分析和Unidbg复现提供了最关键的路标。4. 静态分析使用IDA Pro解析算法逻辑在Frida的帮助下我们拿到了目标Native函数的地址例如libsign.so0x1234。现在用IDA Pro打开这个so文件直接跳转到这个地址开始静态分析。4.1 定位函数与反编译在IDA中按下G键输入地址libsign.so的基址可以从Frida的Module.findBaseAddress获得加上偏移量或者直接搜索函数名。找到函数后按下F5键将其反编译成伪C代码。这是最耗费心力的部分因为代码很可能被混淆控制流平坦化、指令替换等。4.2 关键算法识别即使被混淆加密算法的某些特征依然明显。你需要关注常量数组算法中定义的静态数组可能是S盒AES、DES、初始化向量IV、或者魔数MD5、SHA系列。循环结构加密算法通常包含多层循环。注意循环的边界条件可能与输入长度或固定轮数有关。特定函数调用可能会调用标准库的加密函数如MD5_Init,SHA1_Update,AES_encrypt等。IDA可能能识别出这些符号。位运算大量的与()、或(|)、异或(^)、移位(, )操作是散列和分组加密算法的典型特征。输入输出处理观察传入的参数字符串是如何被处理的转字节数组、补位等以及最终输出是如何生成的字节数组转十六进制字符串或Base64。以我这次逆向的x-sign为例通过静态分析结合动态Hook时看到的输入输出格式我推断出它是一个自定义的、基于HMAC的变种。它没有直接调用OpenSSL的HMAC函数而是手动实现了类似的过程先对密钥进行某种处理然后与输入数据经过多轮杂凑运算。4.3 记录关键逻辑与偏移在分析过程中需要详细记录算法的主流程。用到的所有关键常量及其在内存中的位置地址。如果调用了so内的其他函数这些函数的地址和功能。任何与系统环境相关的调用如/dev/urandom取随机数、gettimeofday取时间这在Unidbg模拟时需要特别注意。这个过程就像在解读一份用晦涩语言写成的食谱你需要反复对照动态调试时看到的“食材”输入和“成品”输出来验证你对每一步“烹饪步骤”代码逻辑的理解是否正确。5. 完整复现使用Unidbg构建模拟执行环境动态分析让我们理解了算法静态分析让我们看到了代码。现在我们要在电脑上“克隆”一个能运行这个算法的小型环境这就是Unidbg的工作。5.1 Unidbg项目初始化创建一个新的Java项目在pom.xml中添加Unidbg依赖。然后编写一个测试类。public class XSignEmulator { public static void main(String[] args) throws Exception { // 1. 创建Android模拟器 (32位还是64位取决于so) AndroidEmulator emulator new AndroidARMEmulator(); Memory memory emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); // API Level 23 // 2. 加载关键so库 Module module emulator.loadLibrary(new File(./target/libsign.so)); // 3. 创建VM VM vm emulator.createDalvikVM(); // 如果需要模拟JNI环境可以加载必要的Jar包 // vm.setJni(this); // vm.setVerbose(true); // 打印JNI调用日志调试时非常有用 // 4. 调用目标函数 // 首先需要知道函数符号。如果是动态注册可能需要先调用JNI_OnLoad或触发注册。 // 这里假设我们已经知道了函数的地址或符号。 Number result module.callFunction(emulator, 0x1234, // 函数地址 StringObject.class, // 返回值类型 vm.addLocalObject(new StringObject(vm, paramString)), // 参数1: jstring vm.addLocalObject(new StringObject(vm, timestamp)), // 参数2: jstring vm.addLocalObject(new StringObject(vm, key)) // 参数3: jstring ); // 或者通过符号调用如果是导出函数 // Symbol signSymbol module.findSymbolByName(Java_com_xxx_NativeLib_getSign); // Number result signSymbol.call(emulator, ...); // 5. 处理结果 if (result ! null) { StringObject so (StringObject) result; System.out.println(计算得到的x-sign: so.getValue()); } // 6. 关闭模拟器 emulator.close(); } }5.2 补全环境与系统调用直接运行上述代码大概率会失败。因为so文件中的算法可能依赖一些Android系统的功能获取设备信息如android.os.Build系列字段MODEL, BRAND, SERIAL等。需要在Unidbg中实现对应的JNI函数返回模拟的值。随机数生成调用/dev/urandom或getrandom系统调用。Unidbg需要拦截这些调用并返回可控的随机字节这对于保证签名结果可复现至关重要。时间戳调用gettimeofday或clock_gettime。我们需要在Unidbg中Hook这些函数返回我们指定的时间以确保每次计算的签名一致便于测试。内存操作函数如memcpy,strlen,malloc,free等Unidbg通常已经提供了良好的模拟。实现方式是通过实现AbstractJni和IO接口或者使用UnixSyscallHandler来拦截系统调用。public class MyJni extends AbstractJni { private final AndroidEmulator emulator; public MyJni(AndroidEmulator emulator) { super(emulator); this.emulator emulator; } Override public String getStaticStringField(BaseVM vm, DvmClass dvmClass, String signature) { // 拦截获取设备信息的调用 if (signature.contains(android/os/Build-MODEL)) { return Unidbg-Phone; // 返回模拟的设备型号 } return super.getStaticStringField(vm, dvmClass, signature); } } // 在main函数中设置 vm.setJni(new MyJni(emulator)); emulator.getSyscallHandler().addIOResolver(new IOResolver() { Override public FileResult resolve(Emulator emulator, String pathname, int oflags) { if (/dev/urandom.equals(pathname)) { // 返回一个固定的“随机数”源用于确定性输出 return FileResult.success(new SimpleFileIO(oflags, new FixedRandomFile())); } return null; } });5.3 算法验证与参数调优环境补全后运行你的Unidbg程序输入与之前Frida Hook时捕获的一模一样的参数参数字符串、时间戳、密钥。将输出的x-sign与抓包得到的真实值进行比对。如果结果不一致就需要进入调试环节开启Unidbg详细日志emulator.getSyscallHandler().setVerbose(true);vm.setVerbose(true);这能打印出所有的系统调用和JNI调用帮助你发现哪里模拟得不对。对比内存状态在关键算法步骤如每一轮加密后同时用Frida在真机中Dump内存并在Unidbg中打印对应内存区域的内容进行逐字节比对。检查输入编码确保字符串到字节数组的转换UTF-8, GBK?与App中完全一致。检查常量确认从so中加载的常量数组如S盒的数值与IDA中看到的是否一致。这个过程可能需要反复迭代不断修正环境模拟的细节和算法理解的偏差直到Unidbg的输出与真实App的输出完全吻合。6. 常见问题与排查技巧实录在整个逆向和复现过程中我踩过不少坑这里总结几个典型问题和解决思路希望能帮你节省时间。6.1 Frida Hook失败或App崩溃问题注入脚本后App闪退或Hook的函数没有被调用。排查检测Frida环境运行frida-ps -U确认是否能列出进程。运行adb shell然后ps | grep frida确认frida-server在运行。绕过反调试很多App会检测Frida。尝试使用frida的隐藏参数或者使用objection等工具又或者修改frida-server的文件名。也可以尝试在App启动后再附加-n参数指定进程名而非-f启动。检查Hook点确认类名和方法签名完全正确包括包名的大小写。使用Java.choose或Java.enumerateMethods来动态查找方法。脚本错误确保JavaScript语法正确特别是Java.perform的回调函数内。6.2 Unidbg模拟结果与真实值不符这是最复杂的情况原因多种多样。输入不一致这是最常见的原因。务必确保Unidbg中传入函数的每一个参数其值、类型、内存布局都与真实调用时完全一致。仔细核对Frida Hook日志里的每一个字节。系统调用/环境差异时间Hookgettimeofday确保返回的时间戳一致。随机数算法可能用随机数做盐salt。Hook/dev/urandom读操作记录真实App读出的字节并在Unidbg中复现。设备信息算法可能掺入了ANDROID_ID,Build.SERIAL等。在JNI层拦截这些调用返回真实App使用的值可以从Frida Hook中获取。算法理解错误静态分析可能出错。用Frida在关键函数内部下更细粒度的Hook打印出中间变量的值如每一轮加密后的状态数组与Unidbg单步调试如果支持或打印的中间结果进行比对。内存对齐与字节序某些算法对内存对齐敏感或者涉及网络字节序与主机字节序的转换。检查代码中是否有#pragma pack或直接的位操作在Unidbg中可能需要特殊处理。6.3 性能优化与生产部署当算法复现成功后你可能希望将它用于生产环境如爬虫。性能Unidbg模拟执行Native代码比直接调用C库慢很多。对于高性能场景可以考虑将算法用纯Java/C重写这是最优解但需要完全吃透算法。Unidbg进程常驻初始化Unidbg环境开销很大。可以写一个常驻的守护进程通过RPC如gRPC、HTTP接收参数并返回签名避免每次调用都创建销毁模拟器。缓存结果如果签名在一定时间内对相同输入有效可以建立缓存。稳定性确保Unidbg模拟的环境足够健壮处理好各种边界条件的输入避免内存泄漏注意及时关闭emulator。6.4 应对so加固与混淆如果so被加固加壳、混淆会增加逆向难度。动态脱壳在Frida Hook到JNI_OnLoad或某些初始化函数后Dump出内存中解密后的so代码。指令抽取型混淆可能需要跟踪执行流手动还原代码逻辑。这会极大增加工作量。虚拟机保护遇到这种高级保护常规逆向手段几乎失效需要更专业的工具和深厚的底层知识。这次逆向的x-sign算法其核心逻辑最终被证实是一个自定义的哈希拼接过程虽然掺杂了一些简单的变换但并未使用强VM保护。整个从动态定位到静态分析再到Unidbg复现的流程是处理这类问题的标准方法论。掌握它你就拥有了打开大多数移动端加密黑盒的钥匙。关键在于耐心、细致的观察和不断的假设验证。最后记得在一切开始前准备好咖啡和耐心这注定是一场与细节较量的持久战。