记一次 LSPosed Native Hook 偶现崩溃

发现故障

昨天(12-05)调试 HMA 的时候重启手机,发现某些加载了 LSPosed 模块的 app 总是无法启动,除非禁用作用于它的所有模块,它们的 tombstone 全指向了同一处地址的 SIGSEGV 。

重启后恢复正常,于是我赶紧去翻查 tombstone ,发现 backtrace 来自某个 zygisk 模块,并且堆栈回溯到这里断掉了。

1
2
3
4
5
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x7196fa5224

backtrace:
#00 pc 00000000000859b0 /apex/com.android.runtime/lib64/bionic/libc.so (__memcpy+96) (BuildId: a790cdbd8e44ea8a90802da343cb82ce)
#01 pc 000000000001c57c /memfd:jit-cache (deleted)

既然是 LSPosed 模块导致了崩溃,那么这个应当是 lsp 的 zygisk lib 。为了确认,我观察 lr 的地址附近的内存,因为 lr 是返回地址,刚好就是 zygisk lib 。

1
2
memory near lr (/memfd:jit-cache (deleted)):
00000071a2821560 8b364279aa1703e0 8a0803362a1603f8 ....yB6....*6...

在所有 zygisk 模块中 grep 8b364279 ,确实来自 LSPosed :

1
2
LANG=C grep -obUaP "\x79\x42\x36\x8b" /data/adb/modules/*/zygisk/arm64-v8a.so
/data/adb/modules/zygisk_lsposed/zygisk/arm64-v8a.so:116068:

另外这一个 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
2
DobbyCodePatch
/home/runner/work/LSPosed/LSPosed/magisk-loader/.cxx/Release/5y6o41i3/arm64-v8a/../../../../../external/dobby/source/Backend/UserMode/ExecMemory/code-patch-tool-posix.cc:0

看起来这个调用来自 Dobby 库,这是 LSPosed 中用于实现 inline hook 的第三方库。因为我对 Dobby 不熟,因此发到 LSPosed 的小群里讨论。

Dobby

经过一番分析,大家认为是 Dobby 寻找内存空洞的时候找错了地址。

首先观察 memcpy 的 dst (x0) ,地址属于一个文件映射:

1
2
3
4
5
memory near x0 (/apex/com.android.i18n/etc/icu/icudt66l.dat)

00000071'96d80000-00000071'96fa4fff r-- 0 225000 /apex/com.android.i18n/etc/icu/icudt66l.dat
--->00000071'96fa5000-00000071'96fa5fff r-x 225000 1000 /apex/com.android.i18n/etc/icu/icudt66l.dat
00000071'96fa6000-00000071'9857ffff r-- 226000 15da000 /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 的调用:

https://github.com/LSPosed/Dobby/blob/6813ca76ddeafcaece525bf8c6cde7ff4c21d3ce/source/Backend/UserMode/ExecMemory/code-patch-tool-posix.cc#L23

显然是生成了代码之后,通过 memcpy 复制到分配好的可执行内存中。

然而 Dobby 没有正确分配可执行内存,反而找到了一处文件映射,代码中 mprotect rwx 显然会失效,而也没有被检查。

mprotect 是否是非原子的?原内存权限 r– ,然而这里被修改成 r-x ,没有 w 固然可以理解,这是因为是 s 映射,但是该函数代码只调用了 mprotext rwx ,却只得到了 r-x 。

寻找内存空洞的主要目的是实现 near call ,需要在被 patch 代码附近寻找一处可执行的区域存放跳板。但是据说这个功能只有单指令 hook 需要,而 LSPosed 并未开启。

于是我在 Dobby 中寻找 DobbyCodePatch 的调用,发现下面的函数可能是寻找内存空洞:

https://github.com/LSPosed/Dobby/blob/6813ca76ddeafcaece525bf8c6cde7ff4c21d3ce/source/MemoryAllocator/NearMemoryAllocator.cc#L118

allocateNearExecMemory -> allocateNearBlockFromUnusedRegion

但这里扫描 maps 和寻找空洞的逻辑似乎并无问题,而且找到了正确地址之后也会 mmap MAP_FIXED 进行映射,如果失败不应该走到下面。

LSPosed Native Hook

由于缺少 backtrace ,因此尚不能确定调用来源。不过 Shatyuka 大师经过一通分析找到了来源。

首先,既然 memcpy 目的是复制跳板代码,那么只要看 src (x1) 的内存就可以分析跳板的目的地址。然而并没有那么顺利,且看 tombstone 的寄存器:

1
2
3
4
5
6
7
8
9
10
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x7196fa5224
x0 0000007196fa5224 x1 b400007190b89760 x2 000000000000000c x3 0000007fef9fb7b0
x4 b400007190b8976c x5 0000007196fa5230 x6 9123f231b005c3d1 x7 d61f02209123f231
x8 0000007234f887f0 x9 89a77bc212c871d9 x10 00000072323f1120 x11 0000000000000019
x12 00000072323961e3 x13 0000000000000080 x14 0000007232467be2 x15 0000000000000002
x16 00000071a2834498 x17 00000072323f1ac0 x18 000000723443a000 x19 0000007196fa5224
x20 0000000000001000 x21 b400007190b89760 x22 0000007196fa5000 x23 0000007196fa5000
x24 000000000000000c x25 0000007196fa5230 x26 000000000000000c x27 0000000000000001
x28 0000007fef9fbe00 x29 0000007fef9fbe00
lr 00000071a2821580 sp 0000007fef9fbbf0 pc 00000072323f19b0 pst 0000000020001000

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
2
3
4
0000007196fa5224 <_binary_file_bin_start>:
7196fa5224: b005c3d1 adrp x17, 0x71a281e000 <_binary_file_bin_end+0xb878dd0>
7196fa5228: 9123f231 add x17, x17, #0x8fc
7196fa522c: d61f0220 br x17

因此最终地址就是 0x71a281e8fc ,刚好就是 lspd 的内存区域(之前 lr 是 71a2821580

1
2
3
00000071'a2805000-00000071'a2832fff r-x         0     2e000  /memfd:jit-cache (deleted)
00000071'a2833000-00000071'a2834fff r-- 2d000 2000 /memfd:jit-cache (deleted)
00000071'a2835000-00000071'a2835fff rw- 2e000 1000 /memfd:jit-cache (deleted)

0x71a281e8fc - 0x71a2805000 = 0x198fc ,用 addr2line 一看:

1
2
_ZN4lspd3$_17replaceEPKciPKvS4_
/home/runner/work/LSPosed/LSPosed/magisk-loader/.cxx/Release/5y6o41i3/arm64-v8a/../../../../../core/src/main/jni/src/native_api.cpp:93

https://github.com/LSPosed/LSPosed/blob/6e4ed0a78650748bfbe61a03042773aabf9b1a77/core/src/main/jni/src/native_api.cpp#L93

因此,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
2
readelf -s -W /apex/com.android.runtime/bin/linker64 | grep do_dlopen
219: 0000000000035224 2440 FUNC LOCAL HIDDEN 11 __dl__Z9do_dlopenPKciPK17android_dlextinfoPKv

而 memcpy 试图写入的地址 0000007196fa5224 恰好和 do_dlopen 偏移一致,仅仅是基地址不同。

所以我们可以推断,Dobby 是在改写被 hook 函数的内容,但是提供的地址是错误的。那么这个地址怎么来的呢?

Dobby 内部并不解析符号的地址,因此只可能是 LSPosed 提供的,代码如下:

1
2
3
4
5
6
7
8
bool InstallNativeAPI(const lsplant::HookHandler & handler) {
LOGD("InstallNativeAPI: {}", symbol_cache->do_dlopen);
if (symbol_cache->do_dlopen) [[likely]] {
HookSymNoHandle(handler, symbol_cache->do_dlopen, do_dlopen);
return true;
}
return false;
}

地址来自一个 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 大师那学到了很多,因此觉得写这么一篇文来记录一下还是很有意义的。