Unidbg学习笔记(十一):初始化问题环境补得完美无缺、函数调用也没有报错,但返回值是空的、或者是错的 —— 这几乎必然是初始化问题。这是从初级到中级的分界线:能稳定地排查并解决初始化问题,你才算真正进入了 Unidbg 的“中段使用阶段”。上一篇把你留在了哪里前面四篇我们把“补环境”这件事翻来覆去讲了一遍:第七篇:JNI 层 — 让 Java 反射调用不再返回 null第八篇:文件系统层 — 让 SO 拿到一个干净的 Linux第九篇:系统调用层 — 给那些走野路径的 SO 兜底第十篇:库函数层 — 在 libc 这一层做最优雅的拦截读到这里你大概会觉得:“工具我都有了,环境我也补全了,剩下的就是把函数调出来。”然后你就会遇到这本系列书里第一个真正难受的问题:函数能调出来,没报错,但返回值是null。或者更阴险一点:返回值看起来“像那么回事”,但和真机对一下不一样。这就是初始化问题。它不是补环境的延伸,而是补环境之外的另一个世界。一个让人崩溃的小故事先来一段我自己踩过的坑。有一次我在分析某 App 的加密 SO,目标函数是Java_com_xxx_Sec_encrypt(env, thiz, byte[] input),应该返回加密后的字节数组。我做完了所有该做的事:JNI 补全 —findClass/getMethodID/callXXXMethod都不报错文件系统补全 —/proc/self/maps假数据已经返回系统调用兜底 — 没有unsupported syscall警告库函数补全 —__system_property_get都返回了真机值跑下来,函数返回了null。没有报错。emulator 没有抛任何异常。Unidbg 老老实实地告诉我:“你的函数执行完了,返回值是 null。”我一开始以为是某个 JNI 字段没补全,检查一遍,没有。然后怀疑是byte[]编码问题,检查一遍也没有。然后我开始怀疑人生。后来怎么解决的?我把 SO 在 Frida 里附加上去,先调用了一次Java_com_xxx_Sec_init,然后调用encrypt,发现可以了。进一步看 init 里在做什么 —— 它在校验签名,签名通过后设置一个全局变量g_initialized = 1。encrypt进来第一步就检查这个变量,不为 1 直接return null。这就是初始化问题。没有任何报错,唯一的症状是返回值不对。为什么会有初始化问题回到上面的故事 ——encrypt返回 null 的真正原因是什么?Java 层本该调的init没有被调。真机上 App 启动时,System.loadLibrary之后,Java 代码会接着调一串 native 函数(init/prepare/setupKeys/ …),把全局状态准备好。然后业务函数才能跑。Unidbg 不执行 DEX,所以这一串由 Java 主动调的 native 函数永远不会自动发生。你必须手动模拟。Unidbg 模拟的是”被加载进 ART 进程后的 SO 视角”,它不模拟 ART 自己。这就是初始化问题的根源 —— 一句话能说清,但排起来是地狱。别和 JNI_OnLoad 混为一谈很多人会问:”JNI_OnLoad 不是会自动执行吗?”这里得把真机和 Unidbg 分开看:真机上,JNI_OnLoad 是 linker 加载 SO 时自动触发的回调Unidbg 里,JNI_OnLoad不会自动执行—— 你必须在loadLibrary之后显式调dm.callJNI_OnLoad(emulator)(注意方法名带下划线)。如果你拿不到DalvikModule实例,等价的低层写法是module.findSymbolByName("JNI_OnLoad").call(emulator, vm.getJavaVM(), null)——前者就是后者的封装即便 JNI_OnLoad 跑过,它也只是 SO 自己的”装载完毕通知”,和”业务 init”(Java 代码主动调的那一串 native)依旧是两回事。被”真机上 JNI_OnLoad 会自动执行”的直觉误导、以为 SO 在 Unidbg 里也已经就绪 —— 这是初始化问题 debug 的首要死因。loadLibrary第二个参数到底控制什么容易和 JNI_OnLoad 混起来的是vm.loadLibrary("xxx", true)的第二个 boolean。源码里叫forceCallInit(BaseVM.java:314)——它控制的不是 JNI_OnLoad,是 ELF 的.init_array/DT_INIT,也就是 SO 里所有__attribute__((constructor))函数和全局对象的构造器。绝大多数时候保持true就对了。SO 常在构造器里做RegisterNatives、设置全局变量、启动内部子系统 —— 跳过的话后续JNI_OnLoad乃至业务调用都可能直接挂掉。什么时候要关掉?只有一种情况:构造器本身在 Unidbg 里过不去。典型场景:构造器启动了持续运行的后台线程(心跳 / 上报 / 反调试),Unidbg 单线程模型直接卡死构造器做了 Unidbg 环境下失败的检测(读/proc/self/status的TracerPid、pthread_create失败等),抛异常退出这两种情况的标准姿势是”先关掉构造器、补环境、再手动触发”:DalvikModuledm=vm.loadLibrary("xxx",false);// forceCallInit=false, 跳过 .init_array// 在这里补上构造器会用到的环境(syscall 拦截、反调试绕过等)dm.callJNI_OnLoad(emulator);// 显式触发 JNI_OnLoad (方法名带下划线)代码里的两件事要分清:loadLibrary(x, false)—— 延后.init_array/ 构造器dm.callJNI_OnLoad(emulator)—— 显式触发 JNI_OnLoad。无论forceCallInit传什么,这一行都是必要的—— JNI_OnLoad 在 Unidbg 里从来不是自动跑的这是”暂停 - 补环境 - 继续”的流程控制。它只影响 SO 自己的构造器和 OnLoad ——解决不了 Java 层主动调的那一串 init,那才是本篇要讲的主题。四步定位法下面是我个人沉淀的工作流程。每一步都有明确的输入和输出,按顺序执行。代码约定:下面几段 JS / Java 代码以意图示意为主 —— 像callViaJava/callTarget/decodeArgs这类函数名是占位符,实际实现随样本而异(类名、签名、参数类型都得按你手头的样本补)。另外每一步开头我会标注@Frida或@Unidbg,表示这一步要在哪个环境里做 —— 新手最容易搞混工位。第一步:@Frida — 直接 Call 目标函数目的:确认在“完整运行环境”下,目标函数的正确行为是什么。为什么必须做这一步:你需要一个“标准答案”。否则你在 Unidbg 里得到的结果是对是错,永远没法判断。// Frida 脚本Java.perform(function(){varSec=Java.use("com.xxx.Sec");varinst=Sec.$new();varinput=[0x01,0x02,0x03];varoutput=inst.encrypt(input);console.log("standard answer:",output);});如果这一步在 Frida 里都得不到正确结果 —— 那说明你 Frida 的调用姿势就不对,没必要继续 Unidbg。先把 Frida 里调通。第二步:@Frida — JNI_OnLoad 之后立即 Call,确认是否是初始化问题目的:区分“环境补全没做好”和“初始化没跑”。在 Frida 里 Hookandroid_dlopen_ext+JNI_OnLoad,在 JNI_OnLoad 返回之后立刻直接调用目标函数(此时 Java 层的 init 还没机会跑):Java.perform(function(){
Unidbg学习笔记(十一):初始化问题
Unidbg学习笔记(十一):初始化问题环境补得完美无缺、函数调用也没有报错,但返回值是空的、或者是错的 —— 这几乎必然是初始化问题。这是从初级到中级的分界线:能稳定地排查并解决初始化问题,你才算真正进入了 Unidbg 的“中段使用阶段”。上一篇把你留在了哪里前面四篇我们把“补环境”这件事翻来覆去讲了一遍:第七篇:JNI 层 — 让 Java 反射调用不再返回 null第八篇:文件系统层 — 让 SO 拿到一个干净的 Linux第九篇:系统调用层 — 给那些走野路径的 SO 兜底第十篇:库函数层 — 在 libc 这一层做最优雅的拦截读到这里你大概会觉得:“工具我都有了,环境我也补全了,剩下的就是把函数调出来。”然后你就会遇到这本系列书里第一个真正难受的问题:函数能调出来,没报错,但返回值是null。或者更阴险一点:返回值看起来“像那么回事”,但和真机对一下不一样。这就是初始化问题。它不是补环境的延伸,而是补环境之外的另一个世界。一个让人崩溃的小故事先来一段我自己踩过的坑。有一次我在分析某 App 的加密 SO,目标函数是Java_com_xxx_Sec_encrypt(env, thiz, byte[] input),应该返回加密后的字节数组。我做完了所有该做的事:JNI 补全 —findClass/getMethodID/callXXXMethod都不报错文件系统补全 —/proc/self/maps假数据已经返回系统调用兜底 — 没有unsupported syscall警告库函数补全 —__system_property_get都返回了真机值跑下来,函数返回了null。没有报错。emulator 没有抛任何异常。Unidbg 老老实实地告诉我:“你的函数执行完了,返回值是 null。”我一开始以为是某个 JNI 字段没补全,检查一遍,没有。然后怀疑是byte[]编码问题,检查一遍也没有。然后我开始怀疑人生。后来怎么解决的?我把 SO 在 Frida 里附加上去,先调用了一次Java_com_xxx_Sec_init,然后调用encrypt,发现可以了。进一步看 init 里在做什么 —— 它在校验签名,签名通过后设置一个全局变量g_initialized = 1。encrypt进来第一步就检查这个变量,不为 1 直接return null。这就是初始化问题。没有任何报错,唯一的症状是返回值不对。为什么会有初始化问题回到上面的故事 ——encrypt返回 null 的真正原因是什么?Java 层本该调的init没有被调。真机上 App 启动时,System.loadLibrary之后,Java 代码会接着调一串 native 函数(init/prepare/setupKeys/ …),把全局状态准备好。然后业务函数才能跑。Unidbg 不执行 DEX,所以这一串由 Java 主动调的 native 函数永远不会自动发生。你必须手动模拟。Unidbg 模拟的是”被加载进 ART 进程后的 SO 视角”,它不模拟 ART 自己。这就是初始化问题的根源 —— 一句话能说清,但排起来是地狱。别和 JNI_OnLoad 混为一谈很多人会问:”JNI_OnLoad 不是会自动执行吗?”这里得把真机和 Unidbg 分开看:真机上,JNI_OnLoad 是 linker 加载 SO 时自动触发的回调Unidbg 里,JNI_OnLoad不会自动执行—— 你必须在loadLibrary之后显式调dm.callJNI_OnLoad(emulator)(注意方法名带下划线)。如果你拿不到DalvikModule实例,等价的低层写法是module.findSymbolByName("JNI_OnLoad").call(emulator, vm.getJavaVM(), null)——前者就是后者的封装即便 JNI_OnLoad 跑过,它也只是 SO 自己的”装载完毕通知”,和”业务 init”(Java 代码主动调的那一串 native)依旧是两回事。被”真机上 JNI_OnLoad 会自动执行”的直觉误导、以为 SO 在 Unidbg 里也已经就绪 —— 这是初始化问题 debug 的首要死因。loadLibrary第二个参数到底控制什么容易和 JNI_OnLoad 混起来的是vm.loadLibrary("xxx", true)的第二个 boolean。源码里叫forceCallInit(BaseVM.java:314)——它控制的不是 JNI_OnLoad,是 ELF 的.init_array/DT_INIT,也就是 SO 里所有__attribute__((constructor))函数和全局对象的构造器。绝大多数时候保持true就对了。SO 常在构造器里做RegisterNatives、设置全局变量、启动内部子系统 —— 跳过的话后续JNI_OnLoad乃至业务调用都可能直接挂掉。什么时候要关掉?只有一种情况:构造器本身在 Unidbg 里过不去。典型场景:构造器启动了持续运行的后台线程(心跳 / 上报 / 反调试),Unidbg 单线程模型直接卡死构造器做了 Unidbg 环境下失败的检测(读/proc/self/status的TracerPid、pthread_create失败等),抛异常退出这两种情况的标准姿势是”先关掉构造器、补环境、再手动触发”:DalvikModuledm=vm.loadLibrary("xxx",false);// forceCallInit=false, 跳过 .init_array// 在这里补上构造器会用到的环境(syscall 拦截、反调试绕过等)dm.callJNI_OnLoad(emulator);// 显式触发 JNI_OnLoad (方法名带下划线)代码里的两件事要分清:loadLibrary(x, false)—— 延后.init_array/ 构造器dm.callJNI_OnLoad(emulator)—— 显式触发 JNI_OnLoad。无论forceCallInit传什么,这一行都是必要的—— JNI_OnLoad 在 Unidbg 里从来不是自动跑的这是”暂停 - 补环境 - 继续”的流程控制。它只影响 SO 自己的构造器和 OnLoad ——解决不了 Java 层主动调的那一串 init,那才是本篇要讲的主题。四步定位法下面是我个人沉淀的工作流程。每一步都有明确的输入和输出,按顺序执行。代码约定:下面几段 JS / Java 代码以意图示意为主 —— 像callViaJava/callTarget/decodeArgs这类函数名是占位符,实际实现随样本而异(类名、签名、参数类型都得按你手头的样本补)。另外每一步开头我会标注@Frida或@Unidbg,表示这一步要在哪个环境里做 —— 新手最容易搞混工位。第一步:@Frida — 直接 Call 目标函数目的:确认在“完整运行环境”下,目标函数的正确行为是什么。为什么必须做这一步:你需要一个“标准答案”。否则你在 Unidbg 里得到的结果是对是错,永远没法判断。// Frida 脚本Java.perform(function(){varSec=Java.use("com.xxx.Sec");varinst=Sec.$new();varinput=[0x01,0x02,0x03];varoutput=inst.encrypt(input);console.log("standard answer:",output);});如果这一步在 Frida 里都得不到正确结果 —— 那说明你 Frida 的调用姿势就不对,没必要继续 Unidbg。先把 Frida 里调通。第二步:@Frida — JNI_OnLoad 之后立即 Call,确认是否是初始化问题目的:区分“环境补全没做好”和“初始化没跑”。在 Frida 里 Hookandroid_dlopen_ext+JNI_OnLoad,在 JNI_OnLoad 返回之后立刻直接调用目标函数(此时 Java 层的 init 还没机会跑):Java.perform(function(){