Unidbg学习笔记(十):库函数层补环境
Unidbg学习笔记(十):库函数层补环境库函数是系统调用的上层封装。很多看起来“必须改 Unidbg 内核”的问题,在库函数层用一个 hook 就能优雅解决。这一层不仅是补环境的“第四个通道”,还是前面三个通道的瑞士军刀。上一篇把你留在了哪里第九篇结尾我反复强调一句话:优先在库函数层 hook,少碰 SyscallHandler。那一篇没解释清楚 “怎么 hook”。这一篇就是来填这个坑的。读完之后你会明白:为什么 libc 层 hook 几乎总是比 syscall 层修改更优三大 hook 框架(xHook / HookZz / Whale)各自的能力边界为什么__system_property_get值得单独拉一章为什么free和munmap在 Unidbg 里有时需要特别处理这一篇是补环境四篇里最“工程化”的一篇 —— 没有大道理,全是实战姿势。一个开胃菜:同一个问题,两种解法先看一段 SO 代码:intdo_check(){intfd=openat(AT_FDCWD,"/system/bin/su",O_RDONLY);if(fd=0){close(fd);return1;// 检测到 Root}return0;}这是一段经典的 Root 检测。在 Unidbg 里跑会发生什么?取决于你前面的工作:情况 A:你按第八篇写了IOResolver,把/system/bin/su拦下来返回failed。openatsyscall 进入 SyscallHandler,handler 通过 IOResolver 拿到failed,最终返回 -1。SO 拿到 -1,认为没 Root。情况 B:你忘了写 IOResolver。openat真的去查宿主机文件系统,可能返回成功也可能返回失败 —— 行为不可预期。但还有情况 C:如果我能在 SO 调openat这个 libc 函数的入口处直接拦下来,根本不让它进入 syscall 流程呢?这就是库函数层 hook 的核心想法。来看代码:// 在 libc 的 openat 入口处直接 hook 替换Modulelibc=emulator.getMemory().findModule("libc.so");SymbolopenatSym=libc.findSymbolByName("openat");IHookZzhookZz=HookZz.getInstance(emulator);hookZz.replace(openatSym,newReplaceCallback(){@OverridepublicHookStatusonCall(Emulator?emulator,longoriginFunction){RegisterContextctx=emulator.getContext();// openat 第二个参数是 pathPointerpathPtr=ctx.getPointerArg(1);Stringpath=pathPtr.getString(0);// 黑名单路径直接返回 -1, 不让请求传到下层if(path.equals("/system/bin/su")||path.equals("/sbin/magisk")){returnHookStatus.LR(emulator,-1);}// 其它路径走原函数, 让 IOResolver / 默认 syscall 处理returnHookStatus.RET(emulator,originFunction);}});这段代码的威力在于:不需要写 IOResolver不需要改 SyscallHandler不依赖 Unidbg 的内部行为自带条件分支(“黑名单路径返回 -1,其他路径走原函数”),逻辑清晰这就是这一篇要讲的方法论:库函数层是补环境的“超级入口”。下面看为什么。库函数和系统调用的关系要理解为什么库函数层好用,先看这两层在调用栈上的位置。关键观察:同一个动作(“打开一个文件”)在两个层级都能拦截两个拦截点拿到的“信息密度”不同:libc 层:能拿到 C 函数的参数(路径字符串),是高级语义syscall 层:只能拿到寄存器值(路径指针、flags、mode),是低级语义libc 层在更“早”的位置 —— 你可以完全绕过 syscall 流程用一个比喻:补环境像是给一栋楼的外卖订单做拦截。syscall 层= 厨房门口拦截。订单已经到厨房了,你只能看到一张订单纸条libc 层= 大堂前台拦截。客户刚进门,你能看到客户的脸、需求、表情,能做出更聪明的决策libc 层为什么几乎总是赢:维度libc 层 hooksyscall 层 patch信息丰富度直接拿 C 字符串、结构体指针只有寄存器值,要自己解析修改入侵性项目本地代码,零侵入改 Unidbg 内核或反射 hack可移植性升级 Unidbg 完全不受影响升级时要重新合并 patch调试体验Java 代码,加 println 即可隔着一层 backend,难调试自带条件分支是(基于参数判断)是(基于寄存器判断,但难写)唯一不能做的拦截 SO 内联的 SVC(不走 libc)-唯一一个 syscall 层赢的场景:当 SO 不走 libc 包装,而是用内联汇编直接发 SVC 指令。这种情况下 libc 层根本不会被调用。但这种 SO 不多,且你可以用 Frida 提前侦察。库函数的四种类型:分清才好下手不是所有库函数都需要补。先把库函数分成四类,每类的处理策略截然不同。类型一:系统调用包装型 — 通常不用动例子:open、close、read、write、mmap、getpid、gettimeofday特征:libc 函数体非常薄,几乎只是把参数搬到寄存器然后发 SVC。为什么不用动:Unidbg 的 SyscallHandler 已经处理好了底层 syscall。SO 调open→ libc 进 syscall → Unidbg 把请求转给 IOResolver / 默认实现 → 返回。整条链路畅通,你不需要插手。例外:当默认 syscall 行为不对(语义偏差,第九篇类型三),且修 syscall 不方便时,可以在这一层 hook。类型二:环境信息型 — 高频处理对象例子:__system_property_get、getenv、gethostname、uname、sysconf特征:返回值是设备 / 系统环境信息,App 经常用来做设备指纹。为什么必须 hook:Unidbg 默认实现可能返回空字符串(getenv("HOME")→ null)或者返回不真实的值(__system_property_get("ro.product.model")→ 默认 “google_sdk_gphone”)App 拿这些值做指纹时,会得到和真机不一样的结果这是库函数层最大宗的工作。下面专门用一节讲__system_property_get。类型三:外部依赖型 — 需要虚拟模块例子:AAsset_open(libandroid.so)、ANativeWindow_lock、AHardwareBuffer_create、SLObjectItf_*特征:函数来自 Android 提供的 native 库(libandroid.so / libGLESv2.so / libOpenSLES.so),不是 libc。为什么麻烦:这些库 Unidbg 默认不加载。SOdlopen("libandroid.so")时拿到 NULL,或者拿到一个空模块。处理思路:提供这些 SO 的真实文件,让 Unidbg 加载(最干净)或者用ModuleAPI 创建一个虚拟模块,把符号填充进去,hook 每个符号或者把 SO 调用改写绕过这些库(侵入 SO 二进制,不推荐)实际工作量:通常占库函数层工作的 10% 左右,主要是音视频、AR、游戏类 SO。类型四:纯计算型 — 几乎不用碰例子:strlen、memcpy、memset、strcmp、malloc/calloc(绝大多数情况)、abs、sin/cos特征:纯算法,不涉及外部状态。为什么不用动:Unidbg 直接用 ARM 指令执行 libc 里这些函数的代码,结果和真机完全一致。这一类 hook 是徒劳的。例外:free和munmap在某些加壳 / 反检测场景会异常,需要特殊处理(稍后讲)。三大 Hook 框架:选哪一个?Unidbg 生态里有三个主流的 hook 框架。它们的能力边界不同,理解差异之后选型就不再纠结。xHook — PLT Hook 的代表原理:修改 ELF 文件的PLT/GOT 表,把对外部符号的调用重定向到你的函数。本质是改“我对你的引用”。// 用 xHook 拦截 SO 对 strlen 的调用IxHookxHook=XHookImpl.getInstance(emulator);xHook.register("libapp.so",// 在哪个 SO 里查找对 strlen 的调用"strlen",newReplaceCallback(){@OverridepublicHookStatusonCall(Emulator?emulator,longoriginFunction){// 这里只会拦到 libapp.so 里调 strlen 的那些点// libapp.so 内部自己实现的 strlen 拦不到PointerstrPtr=emulator.getContext().getPointerArg(0);System.out.pri