Unidbg实战:逆向汽车App加密算法,从黑盒到透明
1. 项目概述当逆向分析遇上汽车App最近在分析一个汽车服务类App的网络请求时遇到了一个典型的“黑盒”难题。App的所有关键请求比如查询车辆状态、控制车门锁、获取历史轨迹等都经过了复杂的加密处理。抓包工具里看到的只是一堆无法直接解读的密文这对于安全研究、协议分析或者想自己写个自动化脚本的车主来说无疑是一道高墙。传统的静态分析工具面对加固后的App和核心逻辑被编译进.so动态库的情况常常力不从心。这时候一个名为unidbg的工具就进入了我的视野。简单来说unidbg是一个基于Java的、用于模拟执行Android和iOS原生库.so/.a文件的逆向分析框架。它最大的魅力在于你不需要一台真实的手机甚至不需要运行整个App就能在你的电脑上“运行”并调试那个关键的加密so库直接看到加密函数的输入和输出甚至动态地修改参数和逻辑。这次实战的目标就是利用unidbg完整地逆向出这个汽车App的请求加密算法从定位加密点、提取关键so文件到搭建unidbg环境、模拟执行并最终还原出可复用的加密代码。整个过程就像一次精密的数字外科手术下面我就把手术刀下的每一个细节分享给你。2. 逆向前的侦察与准备定位加密战场在开始动用unidbg这类“重型装备”之前细致的侦察工作至关重要。盲目地加载so文件只会让你迷失在成千上万个导出函数中。我们的目标是精准定位一击即中。2.1 网络抓包与特征分析首先使用任何你顺手的抓包工具如Charles、Fiddler或mitmproxy对目标App进行抓包。重点关注那些执行核心功能如登录、车辆控制、数据查询的请求。你会发现这些请求的请求体Body和关键的查询参数Query Params往往是加密的表现为一长串无规律的Base64字符串或十六进制数据。而请求头Headers里常常会包含一些特征字段比如X-Sign,X-Timestamp,X-Nonce或者一个看似随机的sign值这些通常是加密或签名算法的产物。我的经验是先对比多次相同操作的请求。比如连续两次解锁车门。你会发现尽管请求体可能相同都是执行解锁指令但那个sign或X-Sign的值每次都不一样。这说明签名算法很可能引入了时间戳Timestamp、随机数Nonce或者一个递增的序列号。这是第一个关键线索加密/签名函数可能接收了多个参数如URL路径、排序后的参数、时间戳、随机数等。2.2 静态分析寻找线索接下来我们需要在App的安装包APK中寻找更多线索。使用jadx-gui或APKTool反编译APK。搜索的关键词包括抓包中发现的特征头字段名如X-Sign。网络请求库的调用如OkHttp的Interceptor拦截器或Retrofit的Converter这里经常是统一添加签名的位置。与加密相关的Java类名如包含Sign,Encrypt,Crypto,Security等字样的类。加载原生库的代码System.loadLibrary(xxx)或System.load(xxx.so)。这是找到关键so文件名的直接途径。在我分析的案例中通过搜索loadLibrary我很快发现了一行代码System.loadLibrary(vehicle-security)。这几乎明牌了核心加密逻辑就在这个libvehicle-security.so库里。同时在某个NetworkInterceptor类中我找到了调用NativeLib.sign(params)的Java方法。至此目标非常明确我们需要在libvehicle-security.so中找到这个sign函数或其JNI映射的函数并用unidbg模拟执行它。注意现代App普遍使用加固技术核心的加密so可能被进一步隐藏或动态解密加载。如果反编译后的代码过于混乱或找不到明显加载点可能需要先进行脱壳处理。这是一个更深入的领域本次分析假设我们已经获取到了未加固或已脱壳的核心so文件。2.3 关键So文件的提取与初步探查从反编译的APK的lib目录通常位于lib/armeabi-v7a或lib/arm64-v8a中找到libvehicle-security.so。用file命令确认其架构这决定了后续unidbg要模拟的CPU类型。更重要的步骤是使用IDA Pro、Ghidra或开源的radare2对其进行静态分析。我们不需要完全读懂所有汇编重点是快速找到目标函数。因为知道了Java层的类名和方法名例如com.example.vehicle.NativeLib.sign我们可以根据JNI的命名规则来查找。JNI函数有两种命名方式静态注册函数名格式为Java_{包名}_{类名}_{方法名}例如Java_com_example_vehicle_NativeLib_sign。动态注册在JNI_OnLoad函数中通过RegisterNatives将Java方法和原生函数指针进行绑定。这时我们需要在JNI_OnLoad中寻找注册结构体。使用IDA打开so在导出函数列表Exports中搜索Java或sign很快就能定位到目标函数。记下这个函数的偏移地址或直接记住它的符号名。同时浏览一下这个函数的交叉引用和字符串列表有时能发现一些硬编码的密钥如AES-KEY或算法标识如HMACSHA256这些信息对后续分析有巨大帮助。3. Unidbg环境搭建与基础框架构建侦察完毕战场清晰。现在开始搭建我们的模拟环境。Unidbg本质上是一个Java项目因此我们需要一个Java开发环境。3.1 环境准备与项目初始化我推荐使用IntelliJ IDEA作为开发工具配合Maven进行依赖管理。创建一个新的Maven项目在pom.xml中添加unidbg的依赖。目前unidbg的主要版本在GitHub上更新你可以直接引用其GitHub仓库的版本或者使用一些镜像仓库中的版本。dependencies !-- Unidbg 核心依赖 -- dependency groupIdcom.github.zhkl0228/groupId artifactIdunidbg/artifactId version0.9.8/version !-- 请检查并使用最新版本 -- /dependency !-- 用于日志输出 -- dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version1.7.36/version /dependency /dependencies创建一个主类比如CarAppEmulator。Unidbg的核心是创建一个AndroidEmulator实例它模拟了CPU、内存和基本的系统环境。import 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 CarAppEmulator { private final AndroidEmulator emulator; private final VM vm; private final Module module; public CarAppEmulator() { // 1. 创建模拟器指定架构为ARMv732位更通用。如果是64位so则使用ARM64 emulator AndroidEmulatorBuilder.for32Bit().build(); // 2. 获取内存操作接口 Memory memory emulator.getMemory(); // 设置库解析器用于自动加载Android系统so如libc, libdl memory.setLibraryResolver(new AndroidResolver(23)); // API Level 23 // 3. 创建Dalvik虚拟机用于调用JNI函数 vm emulator.createDalvikVM(); // 可以设置JNI相关的处理器对于复杂交互很有用 vm.setJni(this); // 设置应用的APK路径虽然不是必须运行APK但有时需要其中的资源 vm.setVerbose(true); // 开启详细日志调试时非常有用 // 4. 加载我们目标so文件 DalvikModule dm vm.loadLibrary(new File(path/to/your/libvehicle-security.so), false); // false表示不立即调用JNI_OnLoad module dm.getModule(); // 5. 可选调用JNI_OnLoad进行初始化 dm.callJNI_OnLoad(emulator); } }3.2 定位并调用目标Native函数环境搭建好后最关键的一步就是调用我们之前找到的native函数。这里需要根据函数是静态注册还是动态注册采用不同的调用方式。情况一调用静态注册的JNI函数如果函数名是Java_com_example_vehicle_NativeLib_sign我们可以直接通过符号名来调用。public String callSign(String data, String timestamp, String nonce) { // 获取函数符号 Number result module.callFunction(emulator, Java_com_example_vehicle_NativeLib_sign, new Object[]{ vm.addLocalObject(new StringObject(vm, data)), // 第一个参数JNIEnv* env vm.addLocalObject(new StringObject(vm, timestamp)), // 第二个参数jobject thiz (如果是静态方法通常为jclass) vm.addLocalObject(new StringObject(vm, nonce)) // 实际参数... }); // 函数返回值可能是一个jstring对象在Unidbg中是一个Number代表地址 // 我们需要将这个地址转换回Java的String DvmObject? retObj vm.getObject(result.intValue()); if (retObj ! null) { return (String) retObj.getValue(); } return null; }这里有个巨大的坑JNI函数的参数列表。第一个参数永远是JNIEnv*指针第二个参数是jobject thiz对于实例方法或jclass clazz对于静态方法。在unidbg中vm.addLocalObject用于创建DVM对象并传递其引用。对于基本类型如int、long可以直接传递int、long值。你必须根据反编译看到的Java方法签名来精确匹配参数类型和顺序。情况二调用动态注册的函数如果函数是动态注册的我们可能没有直接的符号名。这时我们需要找到它在JNI_OnLoad中注册时使用的函数指针。一种方法是在IDA中查看JNI_OnLoad里的RegisterNatives调用找到对应的原生函数地址。然后在unidbg中我们可以直接通过函数在内存中的偏移地址来调用。// 假设通过IDA分析得知目标函数的偏移地址是 0x1234相对于so加载基址的偏移 long functionOffset 0x1234L; // 计算绝对地址模块基址 偏移量 long functionAddress module.base functionOffset; // 使用emulator的backend直接调用 emulator.getBackend().run(functionAddress, new Object[]{...}); // 这种方式更底层需要自己处理JNI环境更常用的方法是如果Java层调用清晰我们可以直接调用那个Java方法让unidbg自动路由到native函数。这需要创建一个与原始App中同名的Java类。// 在vm创建后定义我们的JNI类 vm.resolveClass(com/example/vehicle/NativeLib).newObject(null); // 然后通过DvmObject调用其方法这需要unidbg能成功绑定native方法模拟了完整的JNI调用流程实操心得在初期最稳妥的方式是从静态注册的函数入手。它的调用路径清晰不容易出错。动态注册和直接调用Java方法对unidbg的JNI环境模拟要求更高可能会遇到ClassNotFound或UnsatisfiedLinkError等问题需要额外补环境Hook系统函数或提供虚假实现。建议先实现一个最简单的静态函数调用验证整个unidbg流程跑通。4. 核心加密函数模拟与算法还原当你能成功调用到native函数并得到返回结果时战斗已经胜利了一大半。但我们的目标不仅是调用更是理解。unidbg强大的调试和追踪功能在此大放异彩。4.1 使用Unidbg进行动态调试与追踪Unidbg内置了简单的指令级跟踪器可以打印出执行的每一条ARM指令。这对于理解函数内部的执行流至关重要。// 在调用函数前开启追踪 emulator.getBackend().traceCode(module.base, module.base module.size); // 追踪整个模块 // 或者更精确地追踪某个函数区间 emulator.getBackend().traceCode(functionAddress, functionAddress 0x1000); // 然后调用你的目标函数 callSign(test, 1234567890, abcdef); // 追踪会输出大量汇编代码可以重定向到文件进行分析通过分析追踪日志你可以看到函数是如何操作输入参数的调用了哪些内存读写指令以及最终返回值是如何被构造的。结合IDA的静态视图你可以快速定位到算法核心循环或关键的标准库函数调用如memcpy,sprintf, 加密函数等。4.2 Hook关键函数与内存监视追踪整个函数可能信息过载。更高效的方法是使用Hook技术。Unidbg允许你Hook任何函数调用或内存地址在调用前后插入你的代码查看或修改参数和返回值。Hook标准库函数如果怀疑加密算法使用了OpenSSL或系统自带的加密函数如MD5_Init,SHA1_Update,AES_encryptHook这些函数可以直接拿到输入数据和结果。import com.github.unidbg.arm.HookStatus; import com.github.unidbg.hook.HookContext; import com.github.unidbg.hook.ReplaceCallback; import com.github.unidbg.hook.IHook; // 例如Hook strlen 函数看看我们的输入字符串是如何被处理的 IHook hook emulator.getBackend().hook(); hook.replace(module.findSymbolByName(strlen), new ReplaceCallback() { Override public HookStatus onCall(ReplaceCallContext context) { // 获取第一个参数字符串地址 long strAddress context.getLongArg(0); String str emulator.getMemory().getString(strAddress); System.out.println([Hook strlen] 输入字符串: str); // 继续执行原函数 return HookStatus.RET(emulator, context.replace(str.length())); } });Hook自定义函数内部的特定地址在IDA中如果你发现某个地址是关键的数据处理循环开始处可以直接Hook该地址。emulator.getBackend().hook().addInterceptor(new Interceptor() { Override public void onIntercept(UnidbgPointer caller, long pc, boolean isThumb) { if (pc (module.base 0x5678)) { // 目标地址 System.out.println(执行到关键循环处当前寄存器状态); // 打印寄存器R0, R1的值它们可能指向输入缓冲区或密钥 Backend backend emulator.getBackend(); System.out.printf(R00x%x, R10x%x\n, backend.reg_read(ArmConst.UC_ARM_REG_R0), backend.reg_read(ArmConst.UC_ARM_REG_R1)); // 还可以读取内存内容 byte[] data emulator.getMemory().read(backend.reg_read(ArmConst.UC_ARM_REG_R0), 16); System.out.println(R0指向的数据前16字节: Hex.encodeHexString(data)); } } });4.3 算法还原与代码复现通过动态调试和Hook加密算法的轮廓会逐渐清晰。常见的模式包括拼接将时间戳、随机数、请求参数按特定格式如key1value1key2value2拼接成一个字符串。哈希/签名对拼接后的字符串进行某种哈希运算MD5, SHA1, SHA256或HMAC运算。加密可能对哈希结果或原始请求体再进行一次对称加密如AES或非对称加密如RSA。编码最后将二进制结果进行Base64或Hex编码作为最终的sign或请求体。你的任务就是记录下每一个步骤拼接顺序、使用的密钥可能从内存中Hook到、哈希算法类型、加密模式和填充方式、编码格式。最终你可以用Java、Python或任何你熟悉的语言完全复现这个算法。// 一个还原后的Java版签名算法示例 public class SignGenerator { private static final String SECRET_KEY your_hooked_key_here; public static String generateSign(String path, MapString, String params, String timestamp, String nonce) { // 1. 参数排序并拼接 ListString keys new ArrayList(params.keySet()); Collections.sort(keys); StringBuilder sb new StringBuilder(); for (String key : keys) { sb.append(key).append().append(params.get(key)).append(); } sb.append(timestamp).append(timestamp).append(); sb.append(nonce).append(nonce).append(); sb.append(path).append(path); String stringToSign sb.toString(); System.out.println(待签名字符串: stringToSign); // 2. 使用HMAC-SHA256进行签名 try { Mac mac Mac.getInstance(HmacSHA256); SecretKeySpec secretKeySpec new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), HmacSHA256); mac.init(secretKeySpec); byte[] hash mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)); // 3. Hex编码 return bytesToHex(hash); } catch (Exception e) { e.printStackTrace(); return null; } } private static String bytesToHex(byte[] bytes) { StringBuilder hexString new StringBuilder(); for (byte b : bytes) { String hex Integer.toHexString(0xff b); if (hex.length() 1) { hexString.append(0); } hexString.append(hex); } return hexString.toString(); } }将复现代码计算出的结果与通过unidbg调用原始so得到的结果进行比对如果完全一致恭喜你算法还原成功5. 实战中的疑难杂症与排查实录理论很美好但实战中总会遇到各种意想不到的问题。下面是我在多次使用unidbg过程中踩过的坑和总结的排查技巧。5.1 常见问题速查表问题现象可能原因排查思路与解决方案调用函数后崩溃日志显示SIGSEGV(段错误)1. 参数传递错误类型、数量不对。2. 函数内部访问了未初始化的或错误的内存地址。3. so文件依赖的其他系统库未加载。1.仔细核对JNI函数签名。使用javap -s查看对应Java方法的签名确保参数类型jint, jstring, jbyteArray等和顺序完全匹配。Unidbg中jstring需要用vm.addLocalObject(new StringObject(...))包装。2.开启详细日志(vm.setVerbose(true))看崩溃前执行了哪些指令。在IDA中查看对应地址的代码检查内存操作。3. 确保所有依赖的Android系统so如libc.so,liblog.so,libssl.so都已通过AndroidResolver正确加载。可以尝试用memory.setLibraryResolver加载更高API版本的库。函数返回错误结果或与预期不符1. 函数内部有环境检查如检测模拟器、调试器。2. 依赖了某些系统属性或文件如/proc/self/status,/system/build.prop。3. 算法需要特定的初始化数据上下文而调用时未设置。1.Hook反调试函数。查找并Hookptrace,fopen(检查特定文件),getprop等函数返回符合预期的值来绕过检测。2.补环境。实现一个FileResolver或IOResolver当so尝试读取特定文件时返回你预先准备好的内容。3. 观察函数是否需要一个上下文对象context作为参数。可能需要先调用一个init或create函数来获取这个上下文然后在后续调用中传入。无法找到目标函数符号1. 函数是动态注册的。2. so文件被混淆导出表被清除或函数名被混淆。3. 加载了错误架构的so文件。1. 重点分析JNI_OnLoad函数找到RegisterNatives的结构体从中获取函数指针偏移量。2. 通过特征码搜索。在IDA中找到函数体的一段独特字节序列特征码然后在unidbg加载so后在内存中进行搜索定位函数地址。3. 确认so文件架构与创建的AndroidEmulator架构for32Bit/for64Bit一致。执行速度极慢Unidbg是纯软件模拟特别是开启指令追踪后速度会非常慢。1.只在必要时开启追踪定位到关键代码段后关闭。2. 尝试使用Unicorn后端如果unidbg版本支持它比默认的Dynarmic后端在某些情况下更快。3. 优化Hook代码避免在Hook回调中执行复杂操作。5.2 高级技巧补环境与对抗检测一些安全性较高的so库会进行运行环境检测。除了上面提到的反调试还可能检测设备指纹如android.os.Build系列字段MODEL, BRAND, FINGERPRINT。特定文件存在性如检测/system/bin/su来判断是否root。网络状态虽然unidbg不模拟网络但so可能调用getifaddrs等函数。应对策略是“补环境”即Hook这些检测函数返回一个“正常”的、符合预期的值。// 示例Hook __system_property_get 函数用于伪造系统属性 hook.replace(emulator.findSymbol(__system_property_get), new ReplaceCallback() { Override public HookStatus onCall(ReplaceCallContext context) { String key context.getPointerArg(0).getString(0); UnidbgPointer valuePtr context.getPointerArg(1); if (key ! null) { switch (key) { case ro.product.model: valuePtr.setString(0, Pixel 6); // 伪造设备型号 context.setIntArg(0, Pixel 6.length()); return HookStatus.LR(emulator, 0); // 返回成功 case ro.build.fingerprint: valuePtr.setString(0, google/...); context.setIntArg(0, google/....length()); return HookStatus.LR(emulator, 0); // ... 处理其他属性 } } // 对于不关心的属性调用原函数 return super.onCall(context); } });5.3 性能优化与脚本化当你需要批量测试不同参数时反复启动unidbg进程会很慢。一个好的实践是保持模拟器实例存活只重置虚拟机的状态或者将核心调用逻辑封装成一个函数在同一个进程中反复调用。public class SignService { private static CarAppEmulator emulatorInstance; public static synchronized String getSign(String data, String time, String nonce) { if (emulatorInstance null) { emulatorInstance new CarAppEmulator(); // 这里可以执行一次性的初始化比如调用so的初始化函数 } // 每次调用前可以清理上一次调用留下的临时对象如果必要 // vm.getObjectTracker().clear(); // 谨慎使用可能会破坏依赖关系 return emulatorInstance.callSign(data, time, nonce); } }最后将整个分析过程脚本化。你可以写一个Python脚本用还原的算法或者直接通过JNI调用你的Java unidbg封装程序来批量生成签名用于自动化测试或协议模拟。至此从加密黑盒到透明算法的完整逆向流程就全部走通了。整个过程需要耐心、细致的观察和不断的试错但一旦成功那种拨云见日的感觉是无与伦比的。