拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
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.so

1.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检测模块,一开Frida,so直接走异常分支或者崩溃给你看。另外,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.xml,Unidbg就会去打开宿主机上这个路径的文件。如果你没准备,文件不存在,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调用demo

4.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,能跑完再往下加业务函数。这个小习惯帮我省掉了不知道多少无效的调试时间,也推荐给你们试一下。

返回列表