记一次 LSPosed Native Hook 偶现崩溃
记一次 LSPosed Native Hook 偶现崩溃
发现故障
昨天调试 HMA 的时候重启手机,发现某些加载了 LSPosed 模块的 app 总是无法启动,除非禁用作用于它的所有模块,它们的 tombstone 全指向了同一处地址的 SIGSEGV 。
重启后恢复正常,于是我赶紧去翻查 tombstone ,发现 backtrace 来自某个 zygisk 模块,并且堆栈回溯到这里断掉了。
1 | signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x7196fa5224 |
既然是 LSPosed 模块导致了崩溃,那么这个应当是 lsp 的 zygisk lib 。为了确认,我观察 lr 的地址附近的内存,因为 lr 是返回地址,刚好就是 zygisk lib 。
1 | memory near lr (/memfd:jit-cache (deleted)): |
在所有 zygisk 模块中 grep 8b364279
,确实来自 LSPosed :
1 | LANG=C grep -obUaP "\x79\x42\x36\x8b" /data/adb/modules/*/zygisk/arm64-v8a.so |
另外这一个 tombstone 的内存 dump 字节序和存储的方向是相反的。
知道了是 LSPosed 的问题,接下来就要确认出错代码位置。我用的是 LSPosed CI 7042 Release (6e4ed0a78650748bfbe61a03042773aabf9b1a77) ,对应的 actions https://github.com/LSPosed/LSPosed/actions/runs/6975503656 ,从中可以找到 release 的 debug symbol 。
对 lspd.debug 直接用 llvm-addr2line 1c57c
得到:
1 | DobbyCodePatch |
看起来这个调用来自 Dobby 库,这是 LSPosed 中用于实现 inline hook 的第三方库。因为我对 Dobby 不熟,因此发到 LSPosed 的小群里讨论。
Dobby
经过一番分析,大家认为是 Dobby 寻找内存空洞的时候找错了地址。
首先观察 memcpy 的 dst (x0) ,地址属于一个文件映射:
1 | memory near x0 (/apex/com.android.i18n/etc/icu/icudt66l.dat) |
在正常系统中,这应该是个 shared 映射,而且是 Zygote 启动时就创建了:
1 | 7a12e81000-7a14681000 r--s 00000000 07:98 14 /apex/com.android.i18n/etc/icu/icudt66l.dat |
而找到 DobbyCodePatch 函数中 memcpy 的调用:
显然是生成了代码之后,通过 memcpy 复制到分配好的可执行内存中。
然而 Dobby 没有正确分配可执行内存,反而找到了一处文件映射,代码中 mprotect rwx 显然会失效,而也没有被检查。
mprotect 是否是非原子的?原内存权限 r– ,然而这里被修改成 r-x ,没有 w 固然可以理解,这是因为是 s 映射,但是该函数代码只调用了 mprotext rwx ,却只得到了 r-x 。
寻找内存空洞的主要目的是实现 near call ,需要在被 patch 代码附近寻找一处可执行的区域存放跳板。但是据说这个功能只有单指令 hook 需要,而 LSPosed 并未开启。
于是我在 Dobby 中寻找 DobbyCodePatch 的调用,发现下面的函数可能是寻找内存空洞:
allocateNearExecMemory -> allocateNearBlockFromUnusedRegion
但这里扫描 maps 和寻找空洞的逻辑似乎并无问题,而且找到了正确地址之后也会 mmap MAP_FIXED 进行映射,如果失败不应该走到下面。
LSPosed Native Hook
由于缺少 backtrace ,因此尚不能确定调用来源。不过 Shatyuka 大师经过一通分析找到了来源。
首先,既然 memcpy 目的是复制跳板代码,那么只要看 src (x1) 的内存就可以分析跳板的目的地址。然而并没有那么顺利,且看 tombstone 的寄存器:
1 | signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x7196fa5224 |
x1 的地址被加上了 tag (0xb4) 。在 arm64 上允许给地址加上 tag ,作为地址对待的时候忽略最高字节,然而我手机上的铸币 crash dump 不理解这种地址,所以在下面的寄存器附近内存 dump 找不到它,更不巧的是也没有 crash dump 能理解的其他寄存器的地址恰好在这附近。
Shatyuka 大师问我要了我系统中的 libc ,一开始我还在疑惑和 libc 有什么关系,不应该先分析 dobby 的代码存在什么问题吗?
但大师的思路就是不一样,他分析了 memcpy 函数,发现 x6, x7 寄存器保存了 x1 指向的内容。
图片来自 Shatyuka
所以我们可以从 x6 x7 获取到代码的前 12 字节 d1c305b031f2239120021fd6
,进而进行反汇编(我们假设跳板代码已经重定位好了,位置在 x0):
1 | 0000007196fa5224 <_binary_file_bin_start>: |
因此最终地址就是 0x71a281e8fc
,刚好就是 lspd 的内存区域(之前 lr 是 71a2821580
)
1 | 00000071'a2805000-00000071'a2832fff r-x 0 2e000 /memfd:jit-cache (deleted) |
0x71a281e8fc - 0x71a2805000 = 0x198fc
,用 addr2line 一看:
1 | _ZN4lspd3$_17replaceEPKciPKvS4_ |
因此,hook 的函数是 __dl__Z9do_dlopenPKciPK17android_dlextinfoPKv
,这是 LSPosed Native API 用于监控 dlopen 的 hook 。仅当有模块存在 native hook 才会安装这个 hook 。
到这里我才发现能发生崩溃的进程的模块都是包含 native hook 的,因此这个分析是正确的,不愧是大师。
Symbol Cache & Zygisk Companion Restart
仔细研究了 Dobby 的逻辑,感觉似乎没什么问题,而且 Dobby 的 near branch 默认不打开,LSPosed 也没开,因此怀疑的目光回到 LSPosed 本身上。
观察 linker64 中 do_dlopen 的地址:
1 | readelf -s -W /apex/com.android.runtime/bin/linker64 | grep do_dlopen |
而 memcpy 试图写入的地址 0000007196fa5224
恰好和 do_dlopen 偏移一致,仅仅是基地址不同。
所以我们可以推断,Dobby 是在改写被 hook 函数的内容,但是提供的地址是错误的。那么这个地址怎么来的呢?
Dobby 内部并不解析符号的地址,因此只可能是 LSPosed 提供的,代码如下:
1 | bool InstallNativeAPI(const lsplant::HookHandler & handler) { |
地址来自一个 symbol cache 。在 zygisk-lsposed 中,由于 Zygisk 不允许模块进入 Zygote 进程,无法在它内部提供一个全局性的缓存,所以 LSPosed 使用共享内存提供缓存,而共享内存保存在 Zygisk Companion 中。
我现在用的 Zygisk Next 在重构成 ptrace init 的时候自己删掉了重启 companion 的逻辑,难道真的是因为缓存没有及时更新导致的?
于是我翻找 tombstone ,发现那次开机确实有一次 system_server 崩溃,原因尚不明确。这样一切都能解释得通了,因为产生了 zygote 重启,而下一次启动时缓存的地址刚好是一块 r–s 的内存,所以产生了崩溃。
但是我平常调试也用 Zygisk Next ,也经常重启 zygote ,却一直没观察到这种现象,也许是因为 Native Hook 的使用频率本身就比较低,有可能被缓存的地址恰好是另一块可以被写的内存,没有导致立即崩溃(但是内存说不定已经脏了)。
这么看来,zygote 重启导致 zygisk companion 重启应该是被设计好的行为(不过似乎也不在文档中),Magisk 也有实现 zygote restart 的 hook 。不过我发现原本在 Magisk 26302 及之前 Zygisk companion 还会随着 zygote 重启,但 26402 ,也就是重构成 nbzygisk 之后,这个行为发生了变化。
重构之前重启 companion 的逻辑在这里。看起来原先 zygisk 在 app_process wrapper 向 Magisk 发送 ZygiskRequest::SETUP
通知 zygisk companion 重启,但重构之后,这部分代码和 wrapper 一并被删除了。
这么看来问题同时存在于新版 Zygisk Next 和 Magisk 。不过既然现在 Zygisk 和 Zygisk Next 都不会重启 companion 了,那 LSPosed 的缓存或许才是问题,也许可以用其他方式检测 Zygote 的重启以更新缓存,或者直接不缓存。LSPosed 已经做出了相应更改,采用了后者。
结语
问题的成因并不复杂,但更让我觉得有意思的是分析问题的过程,从一个被 strip 的构建的 library 和残缺的 tombstone 中竟然也能还原出故障的场景。在这个过程中我从 LSPosed 的其他开发者们,特别是从 Shatyuka 大师那学到了很多,因此觉得写这么一篇文来记录一下还是很有意义的。