逆向分析移动应用Native层加密:从抓包到Unidbg算法复现全流程
1. 项目概述与核心目标最近在分析一个移动应用时遇到了一个典型的“黑盒”挑战其核心的加密、签名和风控逻辑都被封装在了一个名为libxxx.so的动态链接库里。直接通过抓包工具如 Charles、Fiddler 或 HTTPCanary捕获到的网络请求其关键参数如X-Gorgon、X-Khronos等都是经过这个 so 库计算后的密文或签名无法直接理解其生成规则更别提模拟或复现了。这种场景在分析一些对安全要求较高的应用时非常普遍。我们的目标就是逆向分析这个 so 库理清其内部函数的调用链最终在脱离真实手机环境的情况下能够稳定、高效地复现其核心算法。这不仅是技术上的探索更是理解现代移动应用风控体系的一把钥匙。整个逆向过程可以概括为三个核心阶段信息收集、静态与动态分析、算法复现与验证。信息收集阶段我们通过抓包定位到关键接口和加密参数静态与动态分析阶段我们使用 IDA Pro、Frida 等工具深入 so 库内部理解其函数逻辑和数据流最后的复现阶段我们借助 Unidbg 这样的模拟执行框架将分析成果转化为可运行的代码。这个过程环环相扣每一步的发现都为下一步提供线索。对于从事安全研究、爬虫开发或对移动应用底层机制感兴趣的朋友来说掌握这套方法论至关重要。它不仅适用于文中的案例其思路和工具链可以迁移到绝大多数类似的 Native 层逆向场景中。2. 逆向工程核心思路与工具选型逆向工程不是漫无目的地乱撞而是一场有明确目标的“外科手术”。我们的核心思路是“由外而内动静结合”。由外而内指的是从应用的外部行为网络请求、日志输出入手逐步追踪到内部的代码逻辑Java层、JNI层、Native层。我们首先需要知道“黑盒”输出了什么才能去推断它内部可能做了什么。动静结合则是方法论的核心。“静”指静态分析即在不运行程序的情况下直接分析其二进制文件如 APK、DEX、SO了解其代码结构、函数关系和可能的逻辑。“动”指动态分析即在程序运行时通过调试、Hook、内存dump等手段观察其实际执行流程、函数调用顺序和内存数据变化。两者相辅相成静态分析为我们提供“地图”告诉我们哪里可能有关卡函数动态分析则是“实地探险”验证地图的正确性并获取通关的“密钥”参数、算法。基于这个思路我们的工具选型如下抓包与协议分析工具这是起点。Charles或HTTPCanary针对安卓是首选。它们能拦截HTTPS流量需安装证书让我们清晰地看到请求头、请求体、响应数据并快速定位到那些看起来像加密或签名的参数。这一步的目标是找到逆向分析的“入口点”。反编译与静态分析工具Jadx-GUI用于快速反编译 APK查看 Java/Kotlin 代码。重点是找到加载 so 库的System.loadLibrary调用以及声明 Native 方法的类。这能帮助我们建立 Java 层到 Native 层的桥梁。IDA Pro逆向分析的“瑞士军刀”尤其是其强大的反汇编和反编译功能F5。用于深入分析libxxx.so查看汇编指令并将其转换为更易读的伪 C 代码。我们可以通过它查看函数列表、交叉引用、字符串常量初步理解 so 的内部结构。动态调试与 Hook 工具Frida动态分析的“神器”。它是一个动态代码插桩框架允许我们向目标进程注入 JavaScript 或 Python 脚本从而 Hook 指定的函数无论是 Java 还是 Native实时查看、修改函数的参数、返回值甚至拦截执行流程。在逆向 so 时Frida 用于快速验证函数功能、追踪调用栈、Dump 内存中的关键数据如算法中间值。IDA Pro 的 Debugger当需要更底层的、指令级别的单步调试时IDA 的远程调试功能不可或缺。它可以附加到安卓进程上像调试本地程序一样调试 so 库中的代码设置断点查看寄存器、内存状态。模拟执行与算法复现工具Unidbg这是本项目的关键。它是一个基于 Unicorn 引擎的模拟器专门用于在 PC 上模拟执行 Android/iOS 的 so 文件。最大的优点是无需真机环境可以脱离复杂的应用上下文直接调用 so 中的指定函数并传入我们构造的参数。这对于算法复现、批量测试和集成到自动化系统中至关重要。注意工具是死的思路是活的。不要试图用一个工具解决所有问题。在实际操作中往往是 Charles 抓包发现线索 - Jadx 找到 JNI 接口 - IDA 静态分析 so 函数 - Frida Hook 验证函数功能并获取关键数据 - 最后用 Unidbg 搭建模拟环境进行复现和稳定调用。这个流程会根据目标的复杂程度反复迭代。3. 从抓包到定位关键 so 与 JNI 接口一切始于一次看似普通的网络请求捕获。我们打开目标应用进行某个关键操作如登录、发布、刷新列表同时在抓包工具中观察流量。3.1 抓包分析与参数定位使用 Charles 配置好代理和手机证书后我们很快捕获到了目标请求。重点关注请求的URL、Headers和Body。经验告诉我们风控或加密参数通常存在于 Headers 中并且名字可能具有一定的迷惑性。例如我们可能发现类似以下的 HeaderX-SS-STUB: 7a3b8c9d... X-Khronos: 1715167890 X-Gorgon: 0408a0b0c0d0e0f...X-SS-STUB看起来像是对请求体Body的某种摘要或签名。X-Khronos看起来是一个时间戳。X-Gorgon则可能是一个更复杂的、结合了多种信息如URL、时间戳、设备信息、请求体的签名。我们的第一个假设是这些参数是在客户端APP生成然后附加到请求头上的。那么生成它们的代码在哪里大概率在 Native 层so库中因为 Native 代码更难被逆向安全性更高。3.2 反编译 APK 寻找线索将目标 APK 文件拖入 Jadx-GUI。首先查看AndroidManifest.xml了解应用的基本组件。然后全局搜索loadLibrary或 so 库的文件名如libxxx.so。通常加载 so 的代码会放在一个static代码块中。public class SecurityUtil { static { System.loadLibrary(xxx); // 加载 libxxx.so } // 声明对应的 Native 方法 public static native String getGorgon(String url, String body, long timestamp); public static native byte[] calculateStub(byte[] data); }找到这个类我们就找到了 Java 层调用 Native 函数的入口。记下这些 Native 方法的签名方法名、参数类型、返回类型。接下来我们需要知道 so 库中对应的 C/C 函数名是什么。JNI 函数的命名有固定规则Java_包名_类名_方法名。例如对于com.example.app.SecurityUtil.getGorgon方法其在 so 中的函数名可能是Java_com_example_app_SecurityUtil_getGorgon。3.3 使用 IDA Pro 初步分析 so 库从 APK 的lib/目录通常是lib/armeabi-v7a或lib/arm64-v8a中提取出libxxx.so用 IDA Pro 打开。IDA 会自动进行分析。分析完成后在Functions窗口快捷键CtrlF中搜索我们推测的 JNI 函数名例如Java_com_example_app_SecurityUtil_getGorgon。如果找到了双击进入该函数按F5进行反编译可以看到大致的伪 C 代码逻辑。这里我们可能看到一些标准的 JNI 函数调用如GetStringUTFChars,CallObjectMethod以及核心的加密/哈希函数可能被混淆但通过常量、循环结构可以猜测。实操心得很多时候函数名会被混淆Obfuscate。此时搜索字符串常量是一个突破口。在 IDA 的Strings窗口快捷键ShiftF12中搜索抓包时看到的参数名如X-Gorgon或一些常见的算法常量如AES、MD5、SHA256的初始向量。找到引用这些字符串的函数很可能就是我们的目标函数。另外关注 so 的JNI_OnLoad函数这里有时会动态注册RegisterNativesNative 方法是另一种定位方式。4. 深入 so 库静态分析与动态 Hook 结合仅仅找到入口函数还不够我们需要理清这个函数内部的调用链即它调用了哪些其他函数这些函数又做了什么。4.1 静态分析调用链与算法识别在 IDA 中进入目标 JNI 函数后利用其强大的图形视图默认模式和反编译视图F5可以清晰地看到函数的控制流。查看交叉引用Xrefs在函数名上按X键可以查看哪些地方调用了这个函数以及这个函数调用了哪些其他函数。这帮助我们理解函数的上下文。分析函数流程图图形视图用方块和箭头表示基本块和跳转关系对于理解分支逻辑if-else, switch非常直观。我们可以顺着箭头追踪程序的执行路径。识别加密/哈希函数在反编译的伪 C 代码中寻找以下特征循环与位操作大量的for循环内部包含与、|或、^异或、左移、右移操作可能是自定义的混淆或标准算法如TEAXXTEA的实现。常量数组查找大的、看起来随机的常量数组通常在DATA段。这些可能是 AES 的 S-Box、MD5/SHA 的初始常量等。标准库函数虽然 so 可能静态链接或自己实现算法但有时也会调用系统的OpenSSL或BoringSSL库函数。在 IDA 的Imports窗口可以看到导入的函数如AES_encrypt,SHA1_Init等。字符串操作如果涉及拼接 URL、参数等会看到strcat,sprintf或std::string的相关操作。通过静态分析我们可以绘制出一个初步的函数调用关系图。例如Java_com_..._getGorgon-sub_1234(参数拼接) -sub_5678(计算MD5) -sub_9ABC(AES加密) -sub_DEF0(Base64编码)。4.2 使用 Frida 进行动态验证与数据捕获静态分析是基于反编译的推测可能存在误差。动态分析则是“眼见为实”。我们编写 Frida JavaScript 脚本对怀疑的关键函数进行 Hook。首先确保手机已 root 或使用模拟器并运行了frida-server。然后在电脑上编写脚本// hook_so.js Java.perform(function () { // 1. Hook Java层的Native方法声明类如果方便 var SecurityUtil Java.use(com.example.app.SecurityUtil); SecurityUtil.getGorgon.implementation function (url, body, timestamp) { console.log([Java] getGorgon called:); console.log( url: ${url}); console.log( body: ${body}); console.log( timestamp: ${timestamp}); var result this.getGorgon(url, body, timestamp); // 调用原方法 console.log( result: ${result}); return result; }; // 2. Hook Native层的函数基于静态分析得到的函数地址或导出符号 // 方法一Hook导出函数如果有符号 var libxxx Module.findBaseAddress(libxxx.so); var nativeFunc libxxx.add(0x1234); // 假设 sub_1234 的偏移是 0x1234 Interceptor.attach(nativeFunc, { onEnter: function (args) { console.log([Native] sub_1234 entered.); // 打印参数可能需要根据函数原型来解析args[0], args[1]... // 例如如果第一个参数是 char*可以这样读 // var arg0 Memory.readCString(args[0]); // console.log( arg0: ${arg0}); }, onLeave: function (retval) { console.log([Native] sub_1234 will return.); // 打印返回值 } }); // 方法二Hook JNI 接口函数更稳定 var getGorgonAddr Module.findExportByName(libxxx.so, Java_com_example_app_SecurityUtil_getGorgon); if (getGorgonAddr) { Interceptor.attach(getGorgonAddr, { onEnter: function (args) { // args[1] 是 JNIEnv*, args[2] 是 jobject this, args[3]开始是Java参数 var jniEnv args[1]; var url_jstring args[3]; var body_jstring args[4]; var timestamp_jlong args[5]; // 将jstring转换为可读字符串 var url_cstr Java.vm.getEnv().getStringUtfChars(url_jstring, null).readCString(); var body_cstr Java.vm.getEnv().getStringUtfChars(body_jstring, null).readCString(); console.log([JNI] getGorgon called with url:${url_cstr}, body:${body_cstr}, ts:${timestamp_jlong}); }, onLeave: function (retval) { // retval 是 jstring var result_cstr Java.vm.getEnv().getStringUtfChars(retval, null).readCString(); console.log([JNI] getGorgon will return: ${result_cstr}); } }); } });使用命令frida -U -f com.example.app -l hook_so.js --no-pause注入脚本并触发网络请求。观察控制台输出我们可以验证 Java 层传入的参数是否正确。确认 Native 函数是否被调用调用顺序如何。捕获函数调用之间的中间数据这是理解算法流程的关键。例如我们可以在sub_5678(MD5计算)的onLeave时打印其输出的16字节数据与后续sub_9ABC(AES加密) 的输入进行比对。注意事项动态 Hook 可能面临反调试、反 Hook 检测。目标 so 可能会检查ptrace、检测frida特征字符串、或校验函数代码完整性。遇到这种情况需要尝试 Frida 的隐身模式、定制化 frida-server、或者使用更底层的调试手段如 IDA Debugger。此外Hook 地址如libxxx.add(0x1234)中的偏移量0x1234是相对于 so 加载基址的偏移需要在 IDA 中查看函数地址时注意。通常 IDA 显示的地址是加载基址偏移而 so 的加载基址在每次运行时都可能变化但函数在 so 文件内的偏移File Offset是固定的。Frida 的Module.findBaseAddress返回的是运行时基址加上固定偏移就能定位到函数。5. Unidbg 环境搭建与核心调用链模拟动态 Hook 给了我们“快照”但我们需要一个能稳定、独立运行 so 中算法的环境。这就是 Unidbg 的用武之地。5.1 Unidbg 项目初始化与依赖Unidbg 是一个 Java 项目。我们通常创建一个 Maven 或 Gradle 项目来管理依赖。!-- Maven pom.xml 示例依赖 -- dependencies dependency groupIdcom.github.zhkl0228/groupId artifactIdunidbg/artifactId version0.9.5/version !-- 请使用最新版本 -- /dependency !-- 可能还需要其他依赖如unidbg-android等 -- /dependencies核心思路是编写一个 Java 类模拟 Android 的环境VMClassLoader加载目标 so 文件然后调用我们分析出来的 JNI 函数。5.2 构建模拟环境与加载 soimport com.github.unidbg.AndroidEmulator; import com.github.unidbg.Module; import com.github.unidbg.linux.android.AndroidEmulatorBuilder; import com.github.unidbg.linux.android.AndroidResolver; import com.github.unidbg.linux.android.dvm.*; import com.github.unidbg.memory.Memory; import java.io.File; import java.io.IOException; public class GorgonGenerator { private final AndroidEmulator emulator; private final VM vm; private final Module module; public GorgonGenerator() { // 1. 创建模拟器指定架构如ARM emulator AndroidEmulatorBuilder.for32Bit().build(); // 2. 获取内存接口 Memory memory emulator.getMemory(); // 3. 设置库解析器用于解决系统so依赖如libc, liblog memory.setLibraryResolver(new AndroidResolver(23)); // API Level 23 // 4. 创建Android虚拟机 vm emulator.createDalvikVM(); // 可以在这里添加一些必要的JNI类如果so调用了特定的Android API // vm.addJniClass(new JniClass()); // 5. 加载目标so文件 File soFile new File(path/to/your/libxxx.so); module emulator.loadLibrary(soFile); // 6. 可选打印so的导出函数验证加载成功 System.out.println(SO loaded, exports: module.getSymbols()); } }5.3 调用 JNI 函数并构造参数这是最核心的一步。我们需要根据之前静态和动态分析的结果知道目标函数的签名和参数意义。public String getGorgon(String url, String body, long timestamp) { // 1. 获取要调用的函数。使用在IDA中看到的完整JNI函数名。 // 注意Unidbg内部可能已经处理了Java_前缀和名称修饰有时需要尝试不同的名称格式。 String jniFuncName Java_com_example_app_SecurityUtil_getGorgon; // 或者如果so使用了动态注册函数名可能是简短的需要通过其他方式获取地址。 // 2. 将Java字符串参数转换为DVM可处理的对象 DvmObject? context vm.resolveClass(com/example/app/MyContext).newObject(null); // 如果有this对象 StringObject urlObj new StringObject(vm, url); StringObject bodyObj new StringObject(vm, body); // 3. 调用函数 // 参数顺序JNIEnv*, jobject this, jstring url, jstring body, jlong timestamp // 在Unidbg中通常第一个参数是DVM虚拟机本身第二个是JNIEnv的指针自动处理 // 从第三个开始是Java方法的参数。 // 具体调用方式取决于so的注册方式和Unidbg的封装。 // 方式A如果函数是标准JNI导出可以直接通过module调用 Number resultPtr module.callFunction(emulator, 0xXXXX, // 函数地址偏移 urlObj, bodyObj, timestamp); // 方式B更常见的是通过DvmClass的callStaticJniMethod如果是静态方法 DvmClass securityUtilClass vm.resolveClass(com/example/app/SecurityUtil); // 这里需要知道方法在JNI中的签名 (Ljava/lang/String;Ljava/lang/String;J)Ljava/lang/String; String signature (Ljava/lang/String;Ljava/lang/String;J)Ljava/lang/String;; StringObject resultObj securityUtilClass.callStaticJniMethodObject(emulator, jniFuncName signature, urlObj, bodyObj, timestamp); // 4. 处理返回值 if (resultObj ! null) { return resultObj.getValue(); } // 如果返回的是指针可能需要从内存中读取字符串 // String result new String(emulator.getMemory().read(resultPtr.intValue(), 100)); return null; } public static void main(String[] args) { GorgonGenerator generator new GorgonGenerator(); String gorgon generator.getGorgon(https://api.example.com/feed, {\page\:1}, System.currentTimeMillis() / 1000); System.out.println(Generated X-Gorgon: gorgon); }5.4 处理 so 中的系统调用与内存操作so 文件在运行时会调用系统函数如malloc,free,memcpy,strlen,time或 Android 特有的函数如__android_log_print。Unidbg 通过LibraryResolver和IOResolver来模拟这些调用。对于常见的 libc 函数Unidbg 已经内置了实现。但对于一些自定义或非标准的系统调用我们需要自己实现AbstractJni或LinuxSyscallHandler。例如如果 so 调用了gettimeofday来获取时间我们可以 Hook 这个调用返回我们指定的时间这对于固定签名结果进行测试非常有用。public class CustomJni extends AbstractJni { Override public long callStaticLongMethodV(BaseVM vm, DvmClass dvmClass, String signature, VaList vaList) { if (signature.contains(currentTimeMillis)) { // 返回一个固定的时间戳用于测试 return 1715167890000L; } return super.callStaticLongMethodV(vm, dvmClass, signature, vaList); } } // 在创建vm后设置 vm.setJni(this);更复杂的情况是 so 内部可能进行了反调试、环境检测如检查/proc/self/status中的 TracerPid。我们需要在 Unidbg 中模拟这些检测使其返回“安全”的结果。这需要对 so 的行为有深入的了解通常通过动态调试Frida/IDA来发现检测点。6. 调用链复现中的常见问题与深度排查即使按照上述步骤操作在 Unidbg 中复现调用链也极少能一帆风顺。下面记录几个最常见的问题及其排查思路。6.1 函数调用崩溃或返回错误问题现象调用callFunction或callStaticJniMethodObject时Unidbg 抛出异常或进程崩溃。排查思路函数地址/签名错误这是最常见的原因。确认 IDA 中看到的函数名和 Unidbg 中调用的是否完全一致包括大小写、包名中的下划线。对于动态注册的函数需要通过JNI_OnLoad中的RegisterNatives来查找其对应的原生函数指针和签名。可以在 Unidbg 中 HookRegisterNatives来捕获这些信息。参数传递错误仔细核对 JNI 函数的参数列表JNIEnv*, jobject, ...。确保在 Unidbg 中传递的参数类型、顺序、数量完全正确。特别是jobjectthis引用对于静态 Native 方法是NULL对于实例方法是对应的对象引用。内存访问违规函数内部可能访问了未初始化或无效的内存地址。使用 Unidbg 的emulator.attach().addBreakPoint(address)设置断点或者开启emulator.traceCode()进行指令级跟踪观察崩溃前执行的最后几条指令分析其访问的内存地址是否合法。缺失的系统依赖so 可能依赖其他 so 文件如libcrypto.so,libutils.so。确保所有依赖的 so 文件都放在LibraryResolver能搜索到的路径下或者已通过emulator.loadLibrary加载。6.2 算法结果与抓包结果不一致问题现象Unidbg 计算出的签名与抓包得到的签名不同但程序没有崩溃。排查思路输入参数差异这是首要怀疑点。仔细比对抓包时 Frida Hook 捕获到的传入getGorgon函数的url,body,timestamp与你在 Unidbg 中传入的是否完全一致注意 URL 是否包含端口号、查询参数顺序、Body 字符串的编码特别是空格、换行、中文。环境参数干扰算法可能不仅仅依赖于显式传入的参数还秘密读取了设备信息如IMEI、Android ID、Build信息、应用上下文如Context对象、甚至网络状态。这些信息在 Unidbg 的“空”环境中是缺失的。需要通过 Frida Hook找出 so 还调用了哪些JNI函数来获取环境信息如getDeviceId,getSystemProperty然后在 Unidbg 的AbstractJni实现中模拟返回固定的值。随机数或时间因子算法中可能引入了随机数如rand()或高精度时间戳如clock_gettime。你需要 Hook 这些函数在 Frida 运行时记录下它们返回的值然后在 Unidbg 中模拟返回相同的值以确保结果可复现。算法路径选择so 内部可能有 if-else 分支根据某些条件如 SDK 版本、设备型号选择不同的算法分支。你需要确保 Unidbg 模拟的环境触发了与真实手机相同的分支。可以通过在 IDA 中分析分支条件并在关键跳转处用 Frida Hook 验证真实环境走的是哪条路。6.3 性能优化与稳定性提升当算法复现成功后可能面临性能问题计算慢或偶发崩溃。性能优化减少日志和跟踪在调试时开启的emulator.traceCode()、emulator.traceRead()、emulator.traceWrite()会极大拖慢速度。生产使用时务必关闭。缓存模拟器实例创建AndroidEmulator和加载 so 是重操作。应该将初始化好的GorgonGenerator实例作为单例或池化对象复用而不是每次调用都新建。识别热点函数使用 Unidbg 的 profiling 功能或简单计时找出耗时最长的函数。如果该函数是纯计算且无副作用可以考虑用 Java 重写其算法逻辑替代模拟执行。稳定性提升处理异步信号有些 so 会使用pthread或信号。确保 Unidbg 配置了正确的信号处理。内存管理模拟器长时间运行可能存在内存泄漏。定期监控必要时重启模拟器实例。异常处理用try-catch包裹 Unidbg 调用对特定异常如CPU访问错误进行降级处理或重试。6.4 对抗 so 的自我保护机制高强度的 so 会集成各种反分析技术符号混淆与字符串加密IDA 中看到的函数名是sub_xxxx字符串是乱码。这需要结合动态调试在内存解密后下断点 Dump 出明文字符串。代码混淆与控制流平坦化IDA 的流程图变得极其复杂像一片“浆糊”。这大大增加了静态分析的难度。需要依赖动态调试Frida/IDA Debugger来理清真实的执行流或者使用 deobfuscation 插件如 IDA 的 Hex-Rays Decompiler 插件配合脚本进行一定程度的还原。完整性校验so 会计算自身代码段或某些关键数据的哈希值与预存值比较不一致则退出。对付这种校验通常有两种思路1) 在 Unidbg 中 Hook 校验函数使其永远返回成功2) 修改 so 文件将校验跳转指令BNE(Branch if Not Equal) 改为B(无条件跳转) 或NOP(空操作)但这需要了解 ARM/ARM64 汇编。调试器检测检测ptrace、检查TracerPid、检测调试寄存器等。在 Unidbg 中我们需要模拟一个“干净”的环境。可以通过实现自定义的SyscallHandler让相关的系统调用如ptrace,open,read返回“未调试”状态的值。整个逆向过程尤其是对抗环节是一场耐心的较量。没有一成不变的解决方案需要根据目标 so 的具体实现灵活组合静态分析、动态调试、补环境、Patch 等多种手段。每一次成功的逆向不仅获得了一个可用的算法更是对移动端安全机制一次深刻的理解。