ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unidbg实战:从JNI_OnLoad到main203的Android so补环境全攻略

Unidbg实战:从JNI_OnLoad到main203的Android so补环境全攻略 兄弟们今天聊点实战的。最近我在折腾Unidbg模拟执行美团libmtguard.so从JNI_OnLoad一路把环境补到main203前前后后踩了几十个坑。这篇文章会把完整思路、代码骨架、排障方法都整理出来给准备用Unidbg处理同类Android so的朋友一个能直接上手的参考。它适合谁呢正在做Android协议分析、需要在PC端脱离真机快速验证加密算法、被so各种环境检测搞到头大的人以及单纯想搞明白so从加载到业务函数调用之间到底发生了什么的新手。Unidbg说白了就是让so跑在JVM里面用Unicorn模拟CPU指令执行所有设备环境和系统调用都得靠我们“演”给它看这个过程就是常说的补环境。而补环境这件事往往占掉整个项目80%的时间你真正要写的业务逻辑可能就几十行代码剩下的全是在回答so向系统提出的各种“拷问”。1. 项目背景与整体拆解为什么是Unidbg libmtguard.so1.1 核心目标与适合人群这个项目的核心目标不是“破解美团”也不是要绕过它的风控去做什么不合规的事而是把libmtguard.so放到一个完全可控的模拟环境里让它正常执行完初始化流程最终能稳定调用到main203这个业务函数。在这个过程中我们需要理解so依赖了哪些系统能力、JNI回调、文件访问、线程同步、时间逻辑然后把这些依赖全部用代码模拟出来。为什么我会选libmtguard.so作为素材因为这类安全SDK非常典型。它往往集成了加壳、自解密、反调试、环境检测、动态JNI注册、多线程初始化、字符串加密等一系列操作。只要你把这类so跑通了再去看市面上其他加固或风控的so基本就是同一套解题思路只是细节不同罢了。如果你只是想在模拟器里跑一跑App那完全不需要看Unidbg直接装个模拟器就行。但如果你要做算法还原、协议分析、参数批量生成或者需要在CI环境里自动化验证某个so的逻辑Unidbg这条路几乎是绕不过去的。它最大的价值在于so永远跑在同一套可控环境里每一次执行的结果都稳定可复现你可以随意修改一个系统函数的返回值然后立刻看到so的反应。1.2 方案选型真机、Frida、Unidbg怎么选在做这类so分析的时候最传统的方案是真机调试。真机的优势是环境天然完整Android系统API、传感器、系统服务都是真真实实存在的so跑起来不会因为缺环境而崩溃。但真机的劣势也很明显每次执行都要准备特定型号、特定系统的设备设备指纹会变so内部的检测逻辑会感知到你是root过的还是hook过的甚至时间一长设备状态完全不可控。对于需要大量重复调用的场景来说真机调度效率太低了。Frida是动态分析的利器可以实时hook任意函数、查看参数和返回值尤其在定位“哪个检测导致崩溃”的时候非常好用。但Frida的问题在于它本身就是一个被检测的特征很多商业SDK里都有专门的Frida检测模块一开Fridaso直接走异常分支或者崩溃给你看。另外Frida依赖设备批量化、自动化、版本复现都不是它擅长的事。Unidbg就显得非常优雅了。它本质上是纯CPU模拟没有真实操作系统没有调试器特征so里的反调试逻辑放在Unidbg里多半会失效因为它找不到它熟悉的那些“敌人”。同时Unidbg可以完全由代码控制时间、文件、系统属性、JNI回调、线程行为全部可以自己定义。这种“绝对可控”是其他方案做不到的。方案优势劣势典型场景真机/沙箱环境真实、一次性通过率高设备指纹乱、批量成本高、状态难统一单次手动调试、验证真实环境行为Frida动态hook能力强、定位问题快反调试检测明显、依赖设备、不适合批量动态分析具体检测点、逻辑定位Unidbg可控性强、无设备依赖、可自动化初始补环境成本高、少量模拟语义差异算法还原、参数生成、CI回归我这次的最终选择就是Unidbg。理由很简单我要从JNI_OnLoad走到main203这两个点之间涉及大量初始化逻辑我需要一步一步看清楚每一步的依赖并且要反复跑。真机做不到这样的“重复且可控”Frida又太容易被发现只有Unidbg能让我把每一行依赖都扒出来然后用自己的代码去替代它。1.3 从JNI_OnLoad到main203的完整执行链路很多人拿到一个so之后会有一个误区直接搜main203然后调用它发现崩溃了就觉得很奇怪。实际上一个商业SDK的so在业务函数被调用之前会做非常多的初始化工作这些工作从JNI_OnLoad那里就开始了。正常流程是这样的Java层通过System.loadLibrary加载so然后linker会找so里的JNI_OnLoad函数并调用它。JNI_OnLoad里通常会做几件事第一检测运行环境包括root、调试器、模拟器、Frida等特征第二读取系统属性、设备信息因为这些信息后续要作为风控字段的一部分第三动态注册一些native方法方便Java层后续调用第四初始化全局变量、秘钥、解密表等。等这些全部做完了so才会进入“待命”状态。此时Java层调用某个native方法才会走到main203这种具体业务函数。在Unidbg里这个链路是一样的。我们先调用JNI_OnLoad把初始化流程跑通然后通过符号或地址调用main203并传入合适的JNI参数。不要一上来就想着调main203先把JNI_OnLoad跑通再逐步推进这个顺序非常重要。2. 环境准备与加载libmtguard.so的初始坑2.1 Unidbg工程初始化与so导入环境搭建其实不难难的是版本匹配。我用的Unidbg版本是0.9.x系列依赖unicorn、dobby、hookzz等底层库。Maven里直接把unidbg的依赖引进来就行建议同时引入unidbg-android模块因为AndroidEmulatorBuilder和DalvikVM这些核心类都在里面。创建模拟器的代码大概是这样的AndroidEmulator emulator AndroidEmulatorBuilder.for32Bit() .setProcessName(com.meituan.books) .build(); Memory memory emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23));这里有两个非常关键的细节。第一for32Bit()表示模拟32位ARM环境。美团App虽然主流是64位但很多so会同时打包32位和64位版本而Unidbg对32位的支持比64位成熟得多所以我建议优先用32位样本。第二AndroidResolver(23)里的23指的是API Level也就是Android 6.0这个数值决定了Unidbg会加载哪些Android系统库来满足so的依赖。如果so依赖的某个系统库API版本太高可以适当调高但要注意版本太高时Unidbg不一定支持完整。2.2 JNI_OnLoad入口初始化时会发生什么加载libmtguard.so本身很简单DalvikModule dm vm.loadLibrary(mtguard, true); dm.callJNI_OnLoad(emulator);但这一行代码跑下去通常会直接崩给你看。因为JNI_OnLoad里会瞬间触发一连串环境检查第一次跑基本是必崩的。以这类安全SDK的常见行为来看JNI_OnLoad里会做这几类事第一反调试检测。它可能会读取/proc/self/status中的TracerPid检查是否大于0也可能会遍历/proc/self/maps看看里面有没有frida、xposed、substrate之类的库路径还可能尝试调用ptrace判断自己是否已经被附加。这些行为在真机上都会正常执行但在Unidbg模拟环境下很多系统调用会返回一个默认值或者直接异常。第二读取系统信息。它会通过JNI调用Java层代码比如读取Build.MODEL、Build.BRAND、Build.VERSION.RELEASE等。如果这些JNI调用没有补全so就会因为找不到对应方法而崩溃。第三字符串解密和初始化。很多so的字符串是加密存储的JNI_OnLoad里会先解密字符串再把它们放到全局变量里。如果解密过程依赖了随机数、时间戳、内存地址等模拟环境下不一致就会导致解密失败。第四动态注册native方法。JNI_OnLoad里通常会调用RegisterNatives把一些函数指针绑定到Java层声明的方法上。如果这部分代码需要访问Java层的类或者方法结构我们也要提前准备好。2.3 初始崩溃的三大常见原因我把加载阶段最常见的崩溃原因总结了一下基本就三类其他的都是这三类的变种。第一类so导出表缺失或者符号命名被混淆过。有些so会把导出表strip掉或者用一堆无意义的符号名这时候dm.loadLibrary是能加载成功的但callJNI_OnLoad就找不到入口地址了。解决办法也简单先用IDA打开so手动找到JNI_OnLoad的地址然后用dm.callFunction(emulator, address)去调用不要依赖符号名。另外用nm -D libmtguard.so或者readelf -s看一下导出表信息心里有个底。第二类栈溢出。Unidbg默认的栈空间并不大有些so的初始化逻辑会申请比较大的栈变量或者递归调用比较深直接就把栈撑爆了。如果崩溃时PC指针停在某个奇怪的地方同时LR附近是连续的栈操作指令那大概率就是栈不够用。解决办法是在初始化时手动调整栈大小emulator.getMemory().setStackSize(0x200000);这里0x200000是2MB如果还不够就继续往上加。我遇到过一些算法so需要4MB栈才能跑完整个初始化所以不要心疼内存。第三类依赖的其他so没有被加载。libmtguard.so不是孤儿它可能会依赖libc.so、liblog.so、libz.so甚至一些内部的小so。Unidbg的AndroidResolver会自动加载一些标准系统库但如果它依赖了非标准的库加载时就会报找不到。解决办法是把依赖的so也放到Unidbg的虚拟文件系统里或者干脆hook掉dlopen让它返回一个假的module handle。这个操作可以在memory.addHookListener里实现也可以直接用vm.loadLibrary提前把依赖库load进来。初始崩溃这块我个人的经验是不要一上来就去调抠算法先把JNI_OnLoad跑通再说。如果JNI_OnLoad一直崩那说明最基础的环境还没有搭好后面什么东西都跑不了。3. 核心补环境方案从符号解析到系统调用钩子3.1 先建立“符号-地址”地图补环境不是瞎猜你要有一个清晰的“依赖地图”。我会先把so的导出表、导入表全部列出来然后对照Unidbg的日志逐个标记哪些符号已经被Unidbg自动处理了哪些符号是需要我自己处理的。在IDA里加载so之后我第一件事就是把所有导出函数列表导出来重点看JNI_OnLoad和main203这类目标符号。在Unidbg里我们可以通过dm.getSymbol(name)来获取某个符号的地址也可以通过dm.callFunction(emulator, name)直接按符号名调用。但问题来了很多商业so的导出表并没有main203这个名字它可能是混淆后的名字也可能根本没有作为导出符号。这时候就需要从JNI_OnLoad的逻辑里去追。一般RegisterNatives的时候会把JNI方法名映射到函数地址我们可以hook一下RegisterNatives把映射表打出来这样就能知道Java层的方法名和so内部函数地址的对应关系。在Unidbg里找到JNI_OnLoad地址之后我通常会先在vm.setVerbose(true)打开JNI日志看它调了哪些Java方法、哪些系统属性。这一步会给你一个非常清晰的“待办清单”。接下来就把日志里出现的所有JNI调用逐个补上实现。3.2 时间、线程、文件与系统属性处理这一块是补环境的重灾区很多so卡住不是因为它有多高级而是因为它读系统时间的时候发现时间不对或者创建线程之后发现调度不了直接卡死在等待上。时间方面的处理要非常小心。如果so用gettimeofday或clock_gettime获取时间然后拿去做超时判断或者种子生成那么时间值不一致会导致后续结果不稳定。我建议先hook住这两个函数让它们返回一个固定值保证执行过程可复现。等整个流程跑通之后再改为真实时间看逻辑是否依然正常。IHookZz hookZz HookZz.getInstance(emulator); hookZz.replace(emulator, gettimeofday, new ReplaceCallback() { Override public void postCall(Emulator? emulator, HookStatus status) { // 让时间固定在某一天 status.setResult(0); } });这段代码只是示意实际用HookZz的时候需要对gettimeofday的参数做处理往timeval结构体里写入固定的time_t值。但思路就是这样把全局时间固定下来排除时间因素对初始化逻辑的干扰。线程处理是另一个难点。很多so初始化时会创建子线程在子线程里做一些检测或者初始化工作然后用pthread_join或者pthread_mutex_lock等待子线程完成。Unidbg的线程模型和真实操作系统不一样它没法真正并发执行线程所以经常会出现等待锁导致死循环的情况。面对这种问题我的思路通常是看子线程里到底干了什么。如果只是打印日志、读取某个系统文件、更新一个计数那我可以直接hook掉pthread_create让“子线程”的逻辑直接在当前线程同步执行一遍然后返回成功如果子线程做的事情对主线业务影响不大那就直接让pthread_create返回一个错误码让so走“创建失败”的分支往往也能绕过去。具体用哪种方式取决于子线程里的工作是否影响main203的入参或结果这个需要结合IDA静态分析来判断。文件访问也要专门处理。Unidbg会把so对文件路径的访问映射到宿主机文件系统也就是说如果so尝试读取/data/data/com.meituan.books/shared_prefs/xxx.xmlUnidbg就会去打开宿主机上这个路径的文件。如果你没准备文件不存在so可能直接崩溃。最简单的方式是在宿主机上创建对应的目录和文件内容就按它期望的格式填。但有一些文件是不希望在宿主机上真实创建的比如/proc/self/maps、/proc/self/status这类文件的内容是根据当前进程动态变化的在宿主机上根本没有对应文件或者内容完全不对。这时候就得hookopen和read把路径拦截下来直接返回一段我们准备好的内存数据。系统属性方面Android的__system_property_get是so最常见的读属性的方式。它读取的是Android系统的一些属性比如ro.build.version.release、ro.debuggable、ro.secure等。Unidbg默认可能没有完整实现这些属性所以我们需要手动hook。简单点的做法是直接根据属性名字返回固定字符串比如在HookZz的回调里判断name是ro.debuggable就返回0。3.3 JNI函数补全让so能正常调回Java层JNI补环境是整个项目里最耗时的部分。libmtguard.so为了拿到设备信息会频繁地通过JNI调用Java层的代码比如获取IMEI、获取Android ID、获取Build属性、获取网络状态等。这些方法在Unidbg的DalvikVM里不存在需要我们一个一个补。Unidbg里补JNI调用的核心方式是vm.setJniResolver当so尝试调用某个Java方法时Unidbg会把这个请求交给我们我们在这里返回一个DvmObject作为结果。vm.setJniResolver((vm1, dvmClass, signature, methodName, varArg) - { switch (methodName) { case getDeviceId: return new StringObject(vm1, 358240051111110); case getIntValue: return new IntObject(vm1, 1); } return null; });这里的关键是返回值类型必须正确。如果Java层方法的签名是()Ljava/lang/String;那就要返回StringObject如果签名是()I就要返回IntObject如果签名是()[B就要返回ByteArray。类型不匹配会导致so解析返回值时取错偏移结果自然不对。除了JniResolver还有一个更底层的处理方式直接hook JNIEnv里暴露的那些函数指针比如FindClass、GetMethodID、CallObjectMethod。这种方式更暴力但也更灵活。说实话绝大多数情况下用JniResolver就够了只有当so使用了非常规JNI调用方式比如通过函数指针表动态获取JNI函数地址时才需要直接操作JNIEnv表。补JNI的时候我强烈建议把所有JNI调用的日志全部打出来包括类名、方法名、签名、参数值、返回值。否则你都不知道so在问什么只能瞎猜。Unidbg开启verbose之后会自动打印一部分JNI调用但不全有必要的话可以自己写一个JniResolver的子类在每次resolve的时候打印一行日志。3.4 和Web端“原型链补环境”的对照聊到这儿我想提一个很多前端逆向过来的朋友会很熟悉的概念——原型链补环境。Web端做JS逆向的时候经常会在浏览器里构造一个假的window、document、navigator环境把风控脚本运行起来。但很多人发现环境补了一堆属性最后风控还是识别了理由往往是你补的属性挂在了错误的位置上或者属性虽然在但是它的getter行为、装饰器特性、原型链层级和真实环境不一致。Android侧的so补环境也是一样的道理。so通过JNI调用Build.MODEL如果你的模拟层返回了一个StringObject但它的类签名、对象结构、所在类的位置和真实的android.os.Build不一致那so内部再往下走两步可能就会踩空。你以为补了一个值就够了实际上它还需要这个值在“正确的对象层级”里待着。这也是为什么很多人说“补环境不就是写if else返回固定值吗”却补不成功的原因。JNI的环境补全是必须按照类名方法名签名的维度去精确模拟的少一个字段、多一个静态标记都不行本质上和前端补原型链是一模一样的逻辑。想通了这一点很多问题就豁然开朗了。现在社区里聊akamai、iv8、阿里系Web风控补环境经常会提到“环境一致性”和“隐藏链路”。Android侧libmtguard这类so也是一样它不会把所有检测点摆在明面上很多校验藏在你不注意的对象层级和调用链深处只有等真正跑起来你才会知道它到底还缺什么。4. 实操过程写一个能跑到main203的Unidbg调用demo4.1 Java主类代码骨架一个完整可用的调用demo大概长这样我用伪代码注释的形式来说明实际API以你引入的Unidbg版本为准。public class MtguardDemo { public static void main(String[] args) { // 1. 创建模拟器32位ARM AndroidEmulator emulator AndroidEmulatorBuilder.for32Bit() .setProcessName(com.meituan.books) .build(); Memory memory emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); // 2. 创建DalvikVM这是JNI环境的基础 DalvikVM vm emulator.createDalvikVM(); vm.setVerbose(true); vm.setJniResolver(new MyJniResolver()); // 3. 加载libmtguard.so并调用JNI_OnLoad DalvikModule dm vm.loadLibrary(mtguard, true); dm.callJNI_OnLoad(emulator); // 4. 调用业务函数main203 StringObject input new StringObject(vm, test_input_data); DvmObject? result dm.callFunction(emulator, Java_com_meituan_security_mtguard_MTGuardNative_203, vm.getMainThread().getEnv(), vm.getMainThread(), input); System.out.println(result result.getValue()); } }这里面有一个很容易踩的坑调用JNI函数时前两个参数必须是JNIEnv和Jobject。在Unidbg里JNIEnv就是当前VM环境可以用vm.getMainThread().getEnv()取出来第二个参数如果你不确定传vm.getMainThread()基本不会错。这两个参数不传对的话so拿到的是一个空的env指针后续调用JNI函数直接就崩了。4.2 hook关键函数与验证执行路径demo能跑起来之后下一步是把关键系统函数hook住保证初始化流程不会卡死。要返回固定时间的可以提前适配pthread_create如果在初始化过程中被调用也要提前处理。不过我强烈建议先不处理任何hook直接跑一次让Unidbg在第一次崩溃的地方停下来然后根据崩的位置去补。这样做最坏的情况是崩几十次但每次你都能精确知道它需要什么。一开始就把所有hook都挂上你反而不知道到底是哪一个hook让流程变得正常了这对排查依赖关系是不利的。我每次都是先纯跑记录第一个崩溃点补完一个再跑记录第二个崩溃点循环往复。这个过程有点像解迷宫补环境补到最后你会对so的初始化顺序了如指掌。验证执行路径还有一个技巧在JNI_OnLoad执行之后、main203调用之前先打印出so里所有已注册的JNI方法表看看它的动态注册是否成功。动态注册是很多安全SDK的标配如果注册失败后面即使调main203也调不出来。4.3 从JNI_OnLoad到main203的调用参数处理main203这个函数根据场景不同可能接收的参数也不一样。有的版本接收一个待签名的字符串有的版本接收一组设备信息拼接的byte数组。无论哪种调用的时候都要通过Unidbg的内存API构造对应的参数对象。如果参数是字符串就直接用StringObject包装如果参数是byte数组就用ByteArray包装如果函数返回值是byte数组接回来之后可以通过getValue()拿到一个byte[]再转成十六进制字符串看结果。还有一个容易忽略的点main203的入口地址不一定和导出符号对应。前面说了有些so导出表里根本没有main203名字需要通过RegisterNatives的映射日志去拿真实地址。拿到地址之后调用时不传符号名而是直接传地址Number result dm.callFunction(emulator, 0x1A2B3C4D, vm.getMainThread().getEnv(), vm.getMainThread(), input);从JNI_OnLoad到main203这个过程本质上就是在回答两个问题这个so怎么初始化以及它怎么接收数据。第一关JNI_OnLoad过了第二关main203就只是一个普通的函数调用只是参数处理比普通函数要麻烦一点。5. 常见问题与排查技巧实录5.1 崩溃、死循环、结果不对的排查顺序我把自己跑libmtguard.so过程中遇到的高频问题整成了一张速查表排查的时候按顺序查能省很多时间。现象可能原因排查方法加载so后立刻崩溃栈空间不足、依赖so缺失调大stack size用readelf检查依赖JNI_OnLoad找不到导出表被strip用IDA搜地址直接按地址调用JNI_OnLoad进入死循环pthread创建后等待、时间随机导致算法异常hook掉pthread_create不要让so创建子线程调用main203时crash前两个参数没有传JNIEnv和Jobject检查callFunction前两个参数返回值是空/乱码JniResolver返回值类型不对、字符串编码不对打印JNI调用日志比对签名结果与真机不一致系统时间/随机数/设备信息不匹配固定gettimeofday和随机数统一设备信息反复触发环境检测分支/proc/self/maps内容不对hook open/read返回伪造maps文件内容这中间最让人头疼的是“结果不正确”但又不崩溃的情况。如果返回值解出来是一堆乱码先不要怀疑算法大概率是你给它的设备信息和它期望的格式不一致导致内部生成的密钥或签名因子不对。这时候去比对你的设备信息字段名称看看是不是少了某个字段或者某个字段的格式不是它期望的那样。5.2 独家排坑技巧日志先行、断点辅助、dump内存这几个技巧是我在实战里最依赖的分享出来都是文档里不会告诉你的。第一日志先行。开启Unidbg的verbose日志是第一步但更关键的是给JniResolver加上自己的日志。这样每次so调用Java层方法时你都能看到方法名、签名、返回值后续排查依赖关系就是翻日志的过程。日志里出现频率异常高的方法往往就是so用来校验环境一致性的关键点。第二断点辅助。Unidbg基于Unicorn可以直接对某个地址下断点。如果崩溃时日志显示PC跑到了一个未知区域不要慌在崩溃地址附近用IDA反编译一下然后重新跑等执行到关键跳转指令时用寄存器状态判断它到底走了哪个分支。有时候你只需要改一个标志位就能让so放弃环境检测。第三dump内存。如果so返回了一个byte数组看起来像加密结果但又和真机对不上很可能是so内部某个中间状态不对。这时可以在so的某个中间函数执行完后用Unidbg把对应内存段dump下来和真机dump的中间值做对比。差值在哪里问题就在哪里。还有一个非常实用的技巧把so在模拟器里跑出来的日志和真机跑出来的日志做diff。我在补环境过程中会打印所有关键函数的入口和出口参数然后在真机上用Frida做同样的事情只要能绕开检测两边一对比很快就能发现哪些地方的环境模拟不到位。5.3 沉淀与复用把“补环境”做成可复用的工程能力补环境这件事最大的成本是“第一次跑通”。一旦跑通后续其实可以沉淀成一套可复用的代码框架。我个人的做法是写一个BaseSoEmulator基类把创建模拟器、初始化DalvikVM、配置JniResolver、设置系统时间、hook关键系统函数这些基础能力全部封装进去。下次拿到新的安全SDK so只需要在子类里补充这个so独有的JNI回调、文件内容和初始化钩子几个小时就能跑起来。项目结构大概是这样的src/main/java/ ├── BaseSoEmulator.java ├── demo/ │ └── MtguardDemo.java ├── hook/ │ ├── TimeHook.java │ ├── PThreadHook.java │ └── SystemPropertyHook.java ├── jni/ │ ├── MyJniResolver.java │ └── FakeDeviceInfo.java └── util/ └── HexUtil.java这样分层之后每次补环境不再是从零开始而是在之前积累的地基上继续搭建。我后来再处理其他so基本都能在两三天内跑通全部流程这比一开始从零搭建节约了太多时间。另外建议把每个so的“待补清单”记录下来。比如libmtguard.so需要补哪些JNI方法、读取哪些系统属性、依赖哪些文件、需要hook哪些系统函数全部列成一个Markdown或Excel表格。下次遇到同家族、同SDK的样本直接对照清单逐项适配就行。积累几个月后你就拥有一份自己的Android so补环境知识库了。说实话把mtguard踩到这个程度收获最大的不是某一个函数的调用结果而是对Android so初始化过程、JNI调用机制、Unidbg模拟原理的整体理解。最后再分享一个小习惯每次补环境前先写一个最小demo只跑JNI_OnLoad能跑完再往下加业务函数。这个小习惯帮我省掉了不知道多少无效的调试时间也推荐给你们试一下。
返回列表