Unidbg逆向分析:从SO文件到加密算法还原实战

Unidbg逆向分析:从SO文件到加密算法还原实战
1. 项目概述从“黑盒”到“白盒”的探索之旅最近在分析一个天气类App的数据接口时遇到了一个典型的问题它的核心数据请求被一层自定义的加密算法包裹得严严实实。直接抓包看到的是一堆毫无意义的乱码常规的Hook手段也因为App做了反调试检测而频频失效。这让我决定走一条更底层的路——直接逆向分析它的原生库通常是.so文件并借助Unidbg这个“沙盒”来模拟执行其中的算法。这个过程本质上是一场将“黑盒”逻辑转化为可理解、可复现的“白盒”代码的工程。对于从事移动安全研究、风控对抗或只是想了解某个App内部工作机制的开发者来说这是一项极具价值的技能。它不仅要求你有逆向分析的耐心还需要对Android运行机制、ARM指令集以及Java Native InterfaceJNI有深入的理解。接下来我将以这个天气App为例完整拆解从APK拆解、SO文件逆向分析到使用Unidbg成功模拟执行加密函数的全过程并分享其中踩过的坑和总结出的实战技巧。2. 逆向工程的核心思路与前期准备逆向工程不是漫无目的地乱撞尤其是在面对一个功能完整的商业App时清晰的思路和合适的工具链是成功的一半。我们的目标很明确找到对网络请求数据进行加密/签名的核心函数并理解其输入、输出和内部逻辑。2.1 目标分析与工具选型首先我们需要定位加密发生的位置。对于大多数Android App加密逻辑无外乎两种实现方式纯Java/Kotlin编写或者通过JNI调用C/C编写的原生库.so文件。经验告诉我们涉及核心安全、性能要求高的算法如自定义的HMAC、AES变形、各种魔改的哈希很大概率会放在SO库里。工具链准备静态分析工具Jadx-GUI / Bytecode Viewer:用于反编译APK中的DEX文件查看Java层代码。这是我们的起点用于寻找加载SO库和声明Native方法的代码。IDA Pro / Ghidra:工业级的反汇编和逆向分析工具用于深入分析SO文件。IDA的交互式图形视图和强大的插件生态如F5生成伪代码是逆向工程师的利器。Ghidra作为NSA开源的工具免费且功能强大是不错的备选。Android Studio / apktool:用于解包APK获取资源文件、清单文件以及最重要的lib目录下的SO文件。动态分析/模拟执行工具Unidbg:本项目的主角。它是一个基于Unicorn引擎的模拟器可以绕过反调试直接在PC上模拟执行SO文件中的代码并支持Hook和打印日志极大地降低了动态分析门槛。Frida:强大的动态插桩工具可以在Java层和Native层进行Hook。在前期探索和验证阶段非常有用但面对强反调试的App可能失效。一台Root过的Android真机或模拟器:用于辅助验证、抓包和进行一些基础的动态调试。为什么选择Unidbg在逆向中我们常常遇到无法轻易绕过的反调试、环境检测或代码混淆。直接在真机上调试SO文件可能触发崩溃或导致功能异常。Unidbg提供了一个“沙盒”环境让我们能够可控地、反复地执行目标代码片段观察其内存和寄存器状态的变化而无需担心对真实设备或App造成影响。这对于算法还原来说效率是革命性的。2.2 定位关键代码与入口点拿到APK后第一步是用Jadx打开它。我们的搜索关键词很明确System.loadLibrary或System.load: 这是加载SO库的代码。native关键字声明本地方法。通常在负责网络请求的类如OkHttpClient的拦截器、某个XXXManager或XXXService中我们会找到类似这样的代码public class SignUtil { static { System.loadLibrary(weather-sec); // 加载名为 libweather-sec.so 的库 } public native String generateSign(String param, long timestamp); }这就找到了突破口generateSign这个Native方法很可能就是生成加密签名的函数。它的输入是参数字符串和时间戳输出是一个签名字符串。接下来用apktool解包APK在lib/armeabi-v7a或lib/arm64-v8a目录下找到libweather-sec.so文件。这个文件就是我们接下来要在IDA中深入分析的目标。注意很多App会使用字符串混淆、控制流扁平化等保护技术。不要被复杂的流程吓到我们的目标不是理解每一行代码而是找到从输入到输出的关键路径。关注对输入字符串的处理、循环、以及最终生成输出字符串的代码块。3. SO文件逆向分析与算法逻辑梳理将libweather-sec.so拖入IDA Pro等待自动分析完成。在Exports窗口导出函数表中我们寻找与Java层对应的方法。JNI函数的命名规则是Java_包名_类名_方法名。例如如果我们的SignUtil类全名是com.example.weather.util.SignUtil那么对应的函数名可能就是Java_com_example_weather_util_SignUtil_generateSign。3.1 识别JNI函数与参数转换找到目标函数后按F5生成伪代码。你会看到类似下面的结构JNIEXPORT jstring JNICALL Java_com_example_weather_util_SignUtil_generateSign(JNIEnv *env, jobject thiz, jstring param_jstr, jlong timestamp) { const char *param_cstr (*env)-GetStringUTFChars(env, param_jstr, 0); // ... 核心处理逻辑 ... jstring result (*env)-NewStringUTF(env, output_cstr); (*env)-ReleaseStringUTFChars(env, param_jstr, param_cstr); return result; }这里清晰地展示了JNI的桥梁作用将Java的jstring和jlong转换为C层可操作的char*和long。核心算法就在中间的// ... 核心处理逻辑 ...部分。3.2 跟踪核心算法流程接下来的工作就是仔细阅读这段伪代码。我们需要关注字符串操作strlen,strcat,sprintf等用于拼接待加密的原始字符串。天气App常见的拼接格式可能是param{...}timestamp...key...。加密/哈希函数调用识别标准库函数如MD5_Init,SHA1_Update,HMAC或自定义的加密函数。有时开发者会魔改标准算法比如修改初始常量、增加额外的轮次或与非对称加密结合。循环与条件分支算法中可能包含复杂的循环处理比如对参数字符串按特定规则进行置换或迭代哈希。关键常量与密钥在代码的静态数据区通常是rodata段寻找硬编码的密钥、盐值Salt或初始向量IV。这些是算法还原不可或缺的部分。一个实用的技巧在IDA中对疑似处理字符串或进行加密的函数按X键查看交叉引用了解它被谁调用这有助于理清函数间的层级关系。同时善用注释功能给重要的变量和函数重命名如将v15改为md5_context让分析过程更清晰。实操心得逆向分析时不要试图一次性理解所有代码。先画出大致的函数调用图确定主干流程。遇到非常复杂的混淆可以结合动态验证。例如先假设一个简单的算法如MD5用Unidbg快速验证输出是否匹配如果不匹配再深入细节。4. 使用Unidbg构建模拟执行环境分析完伪代码我们对算法有了初步猜想。现在是时候用Unidbg来验证和动态跟踪了。Unidbg允许我们编写Java代码来模拟调用SO中的函数。4.1 初始化Unidbg与加载SO库首先创建一个Maven或Gradle项目引入Unidbg的依赖。然后编写一个测试类public class WeatherSignEmulator { public static void main(String[] args) { // 1. 创建模拟器选择ARM架构 AndroidEmulator emulator new AndroidARMEmulator(); Memory memory emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); // API Level 23 // 2. 加载Android核心库libc, libdl等 VM vm emulator.createDalvikVM(); vm.setVerbose(true); // 打印详细日志调试时非常有用 // 3. 加载目标SO库 Module module vm.loadLibrary(new File(unidbg-android/src/test/resources/libweather-sec.so), true); // 4. 获取目标Native函数 // 需要根据IDA分析得到的函数符号来调用 // 例如如果函数在JNI_OnLoad中动态注册可能需要通过JNI调用的方式 DvmObject? signUtil vm.resolveClass(com/example/weather/util/SignUtil).newObject(null); // 更常见的是直接调用导出函数 Number result module.callFunction(emulator, 0x1234, paramString, 1678888888888L); // 地址和参数是示例 System.out.println(Sign Result: result); emulator.close(); } }这里有几个关键点AndroidARMEmulator选择ARM指令集架构需与SO文件匹配v7a或v8a。setVerbose(true)在初期调试时务必打开它会打印JNI调用、系统调用等详细信息帮助你理解SO库的初始化过程。loadLibrary会触发SO的初始化函数如JNI_OnLoad许多反调试和全局变量初始化都在这里完成。4.2 构造JNI调用与参数传递大多数情况下我们需要模拟完整的JNI调用流程而不是直接调用内部函数。因为算法可能依赖JNIEnv或jobject。Unidbg的DalvikVM提供了强大的JNI模拟能力。// 创建一个Native方法的调用桥接 public class GenerateSign extends AbstractJni { Override public DvmObject? callObjectMethodV(BaseVM vm, DvmObject? dvmObject, String method, VaList vaList) { // 拦截对SignUtil对象的方法调用 if (method.equals(generateSign)) { String param vaList.getObject(0).getValue().toString(); // 第一个参数是jstring long timestamp vaList.getLong(1); // 第二个参数是jlong // 这里可以调用我们之前获取的Native函数指针或者直接在这里实现算法逻辑进行验证 System.out.println(Called generateSign with param: param , timestamp: timestamp); // 假设我们计算的结果是 abcd1234 return new StringObject(vm, abcd1234); } return super.callObjectMethodV(vm, dvmObject, method, vaList); } } // 在主函数中注册这个JNI vm.setJni(new GenerateSign()); // 然后通过反射调用Java方法触发Native调用 DvmClass signUtilClass vm.resolveClass(com/example/weather/util/SignUtil); DvmObject? signUtilInstance signUtilClass.newObject(null); String result signUtilInstance.callJniMethodObject(emulator, generateSign(Ljava/lang/String;J)Ljava/lang/String;, citybeijing, 1678888888888L).getValue().toString();这种方式更贴近真实App的运行环境能处理更复杂的JNI交互。4.3 Hook关键函数与内存监控Unidbg最强大的功能之一是Hook。我们可以在关键函数入口、出口或任意指令地址设置断点打印参数、返回值和寄存器状态。// 使用AndroidEmulator的调试器功能 emulator.attach().addBreakPoint(module.base 0x5678); // 在函数入口地址下断点 // 或者使用更灵活的CodeHook emulator.getBackend().hook_add_new(new CodeHook() { Override public void hook(Backend backend, long address, int size, Object user) { // 当执行到指定地址时触发 Capstone capstone new Capstone(Capstone.CS_ARCH_ARM, Capstone.CS_MODE_ARM); Capstone.CsInsn[] insns capstone.disasm(backend.mem_read(address, size), address, 1); System.out.println(Executing at 0x Long.toHexString(address) : insns[0].mnemonic insns[0].opStr); // 例如打印R0寄存器的值可能是字符串指针 Arm32RegisterContext context Arm32RegisterContext.getInstance(emulator, address); int r0 context.getR0Int(); if (r0 ! 0) { String str emulator.getMemory().getString(r0); System.out.println(R0 points to string: str); } } }, module.base 0x5678, module.base 0x5678, null);通过Hook我们可以像单步调试一样清晰地看到待加密字符串是如何一步步被处理最终变成那个加密签名的。这对于验证静态分析的猜想和定位算法细节至关重要。5. 算法还原、验证与代码移植通过Unidbg的动态执行和Hook我们已经能够准确获取任意输入对应的输出。接下来就是将模糊的伪代码逻辑还原成清晰、可移植的编程语言代码如Python、Java。5.1 对比验证与逻辑确认设计多组测试用例不同长度、不同内容的参数字符串和时间戳分别在Unidbg环境和我们的还原代码中运行对比输出结果是否完全一致。这是验证还原是否正确的黄金标准。测试用例表示例输入参数 (param)输入时间戳 (timestamp)Unidbg输出还原代码输出是否一致citybeijing1678888888888a1b2c3d4e5a1b2c3d4e5✅cityshanghaidays31678888888999f6g7h8i9j0f6g7h8i9j0✅(空字符串)1678888888000k1l2m3n4o5k1l2m3n4o5✅如果出现不一致就需要回到Unidbg在算法流程的关键节点如哈希初始化后、最终输出前Hook并打印中间状态如MD5的上下文状态、中间哈希值与还原代码的中间状态进行比对定位差异点。5.2 代码移植与细节处理将C伪代码逻辑移植到高级语言时需特别注意以下几点字节序与整数溢出C语言中的位操作和算术运算需要特别注意整数类型uint32_t,int64_t和溢出行为。在Python或Java中要使用对应的无符号类型或通过 0xffffffff进行模拟。内存操作C语言中直接对内存指针的操作如*(uint32_t*)ptr在高级语言中需要转换为对字节数组bytearray的切片和struct.unpack操作。魔改算法如果算法是标准算法的魔改版如修改了MD5的初始常量表需要将魔改后的常量值准确无误地复制到还原代码中。密钥与常量确保所有从SO文件中提取的硬编码密钥、盐值、固定字符串都正确无误地使用。一个还原后的Python伪代码示例import hashlib import hmac def weather_generate_sign(param_str, timestamp): # 1. 拼接原始字符串 (根据逆向结果) raw_str fv1.0{param_str}t{timestamp} # 2. 提取硬编码的密钥 (来自SO的.rodata段) secret_key bytes.fromhex(2a7d8f1c4e9b0a3d) # 3. 可能是HMAC-SHA256但填充方式有自定义 # 先进行一次特殊的填充 padded_input custom_padding(raw_str.encode(utf-8)) # 4. 计算HMAC (逆向发现是SHA256但输出截取前16字节并反转) hmac_digest hmac.new(secret_key, padded_input, hashlib.sha256).digest() # 5. 最终处理取前16字节每4字节反转顺序小端转大端 final_sign process_final_bytes(hmac_digest[:16]) # 6. 转换为十六进制字符串 return final_sign.hex() def custom_padding(data): # 逆向发现的独特填充规则例如按0x55填充至64字节对齐 # ... pass def process_final_bytes(data): # 逆向发现的最终变换例如每4字节一组进行字节序反转 # ... pass5.3 处理反调试与环境检测成熟的App会在SO库中植入反调试代码。常见手段有检查调试状态通过ptrace(PTRACE_TRACEME, ...)、读取/proc/self/status中的TracerPid或检查android:debuggable属性。检测模拟器/ROOT检查特定文件、属性或硬件信息。代码完整性校验检查自身SO文件或内存中的代码段是否被修改。在Unidbg中我们可以通过Hook这些检测函数并修改其返回值来绕过。例如找到检测TracerPid的函数Hook它并强制返回0。emulator.getBackend().hook_add_new(new CodeHook() { Override public void hook(Backend backend, long address, int size, Object user) { Arm32RegisterContext context Arm32RegisterContext.getInstance(emulator, address); // 假设该函数返回调试状态0为未调试 // 我们强制将返回值R0设置为0 context.setR0(0); // 然后跳过原函数体直接返回 backend.emu_stop(); } }, module.base anti_debug_func_offset, module.base anti_debug_func_offset, null);踩坑记录反调试代码可能有多处且可能相互关联。单纯绕过一处可能导致后续逻辑异常。更好的方法是系统性地分析反调试逻辑在Unidbg初始化时就通过修改系统调用syscall的返回值来提供一个“干净”的环境视图。这需要对Linux系统调用和ARM汇编有更深的理解。6. 常见问题排查与实战技巧汇编在实际操作中你几乎一定会遇到下面这些问题。这里是我总结的排查清单和技巧。6.1 Unidbg执行崩溃或卡住现象可能原因排查步骤与解决方案加载SO时崩溃1. 架构不匹配 (用32位模拟器加载64位SO)2. 依赖库缺失3.JNI_OnLoad初始化失败1. 确认SO文件架构 (file命令)使用对应的AndroidARMEmulator或AndroidARM64Emulator。2. 使用memory.setLibraryResolver确保所有依赖的Android系统库如libc.so,liblog.so都被正确加载和解析。3. 开启verbose日志看崩溃在哪个函数。HookJNI_OnLoad内部逐步排查。调用函数时崩溃1. 函数地址错误2. 参数传递错误类型、数量3. 函数内部访问非法内存1. 在IDA中确认函数符号和偏移地址。注意JNI_OnLoad动态注册的函数需要通过JNI方式调用。2. 仔细对照函数原型jstring,jint等在Unidbg中正确构造参数。使用DvmObject表示Java对象。3. Hook函数开头检查传入的指针是否有效。可能是前置逻辑未正确初始化全局变量或数据结构。执行到特定指令卡住1. 遇到未实现的系统调用2. 陷入死循环或条件分支异常1. 查看日志中卡住前最后的系统调用。在Unidbg中实现对应的IOResolver或SyscallHandler来模拟这个系统调用如gettimeofday,open。2. 单步调试Hook每条指令观察程序流。可能是某个环境检测返回了意外值导致分支跳转到错误地址。6.2 算法还原结果不一致原因一字节序问题。ARM架构通常是小端序Little-Endian。在C代码中一个uint32_t变量在内存中字节是反的。如果你在Hook时直接读取内存当作整数或者移植代码时没有处理字节序就会出错。解决方案在Python/Java中使用struct模块或ByteBuffer时明确指定字节序如I表示小端32位无符号整数。原因二未初始化的内存或全局变量。SO库中的全局变量或静态变量在JNI_OnLoad或第一次调用时被初始化。如果你的还原代码没有模拟这个初始化过程算法状态就是错的。解决方案在Unidbg中在调用目标函数前先触发或模拟初始化流程。或者在还原代码中完全复制SO中全局数据段的初始值。原因三魔改算法的细节遗漏。可能遗漏了一个看似不起眼的位运算、一次额外的字节置换、或者一个非标准的循环次数。解决方案使用Unidbg的Hook功能在标准算法库函数如MD5_Final的入口和出口打印其内部上下文MD5_CTX结构体与你使用的标准库如Python的hashlib的中间状态进行逐字节比对。这是最有效的定位方法。6.3 效率与优化建议针对性Hook不要一开始就全量Hook或单步。先通过静态分析缩小关键函数范围然后只在最关心的几个函数入口、出口和循环处下钩子。日志分级Unidbg的verbose日志信息量巨大。在后期稳定运行阶段可以关闭或者自己实现更精细的日志控制。缓存结果对于纯函数式的算法输入相同输出必然相同可以在还原代码中实现缓存机制避免重复计算这在批量测试或生产环境中能极大提升性能。单元测试为还原的算法代码编写完善的单元测试覆盖边界情况空输入、超长输入、特殊字符等确保其健壮性。逆向与Unidbg模拟是一个需要耐心、细心和系统化方法的过程。它就像在解一个复杂的立体拼图静态分析给你图纸动态调试给你灯光而Unidbg则给了你一双可以随意摆弄零件的手。当你成功让那个加密算法在独立的环境中复现时那种对系统底层运作豁然开朗的感觉正是这项技术工作最大的乐趣所在。记住每一次失败和排查都在加深你对移动端安全、系统机制和程序执行逻辑的理解。