Loading... ## 前言 **隐藏注入**一直是 Android 各种检测和反检测斗争的战场。实际上早在很久以前,各种外挂制作者就已经认识到,要在用户态中隐藏一个注入到 App 的动态库是不可能的,因此现在的外挂都是纯粹的内核挂。然而,现实是网络上几乎无法找到完整的用户态注入检测原理,或者说这些都被藏在各大游戏安全 SDK 内部。本文在这里列出了各大隐藏方案和完整的注入检测流程,以及说明为什么 Android 用户态注入隐藏是不可能的。 注意,本文并非着重于讨论游戏外挂,而是对最近的 Zygisk 模块隐藏一事盖棺定论。实际上,游戏外挂在隐藏对抗方面领先 Root 社区至少 3 年,讨论这个是没有意义的。 ## 为什么需要隐藏 对于一个正常环境的 Android App 而言,其内存中的可执行库只可能来自只读的系统分区或应用自带的库。如果 `/proc/self/maps` 中发现来自 `/data/adb` 等“不应存在的位置”的库,即可认为环境异常,从而拒绝工作。因此,注入框架诞生伊始,这场与安全厂商的猫捉老鼠游戏就打响了。 ## 隐藏方案 ### 早期方案 Root 社区最早的 Zygote 注入框架是 [Riru](https://github.com/RikkaApps/Riru)。Riru 加载模块的方式是直接在 Zygote 中 `dlopen` 模块 `so` 的路径。因此如果不进行隐藏,App 能够发现自己 `maps` 中堂而皇之的出现了路径包含 `.magisk` 的库。 Riru 自带了名为 `riru_hide` 的功能,其原理如下: 1. 扫描 `maps`,对于路径是需要隐藏的项,如果可读,则将其信息放入 `data` 数组中。 2. 对于每一个要隐藏的内存区域,创建等长的 `private` 匿名备份内存,将原内存拷贝到备份内存。 ```cpp data->backup_address = (uintptr_t) FAILURE_RETURN( mmap(nullptr, length, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0), MAP_FAILED); if (!procstruct->is_r) { FAILURE_RETURN(mprotect((void *) start, length, prot | PROT_READ), -1); } memcpy((void *) data->backup_address, (void *) start, length); ``` 3. `unmap` 掉原内存,再在原内存处重新 `map` 一片等长的 `private` 匿名内存。 ```cpp FAILURE_RETURN(munmap((void *) start, length), -1); FAILURE_RETURN(mmap((void *) start, length, prot, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0), MAP_FAILED); ``` 4. 将备份内存拷贝到新创建的匿名内存。 ```cpp FAILURE_RETURN(mprotect((void *) start, length, prot | PROT_WRITE), -1); memcpy((void *) start, (void *) data->backup_address, length); if (!procstruct->is_w) { FAILURE_RETURN(mprotect((void *) start, length, prot), -1); } ``` 可以看到,这个隐藏方案非常粗糙,并且存在大量的 bug 和无意义操作。 首先,`riru_hide` 函数中仅将可读内存加入隐藏列表中,忽略了仅执行内存,然而又在后续的 `do_hide` 函数中判断是否可读。其次,多了一次无意义拷贝。`unmap` 原内存再 `map` 一次等长匿名内存是没必要的,可以直接把备份内存 `remap` 到原内存位置。最后,备份内存没有被 `unmap`,造成内存泄漏。 ### Shamiko 前期方案 进入 Zygisk 时代后,[Shamiko](https://github.com/LSPosed/LSPosed.github.io/releases) 提供了最早的隐藏方案。值得注意的是,实际上直到 1.0 之前,Shamiko **并没有**采用类似 `riru_hide` 的方案,而是主要处理了 `maps` 以外别的痕迹(如最近抄袭争议的 `soinfo`,我们在 **2 年前**就已经在 Shamiko 中加入了此项隐藏)。这是由于以下的原因: 1. Zygisk 和 Riru 不同,它使用 `magiskd` 传来的 `fd` 进行 `dlopen` 来加载模块,而非在 Zygote 中直接通过路径打开。Zygisk 使用 `fd` 来 `dlopen` 模块 `so`,并将其路径设为 `/jit-cache`。 2. 在加载完成并关闭 `fd` 后,在 `maps` 中模块 `so` 的路径会显示为 `/memfd:jit-cache (deleted)`。App 进程本身就存在 `jit-cache`,Zygisk 模块与真正的 `jit-cache` 在 `maps` 中的路径完全相同,因此“似乎”并没有必要进行额外的操作以去掉这片内存的名称。 ### Shamiko 后期方案 Shamiko 1.0 中,最后还是加入了类似 `riru_hide` 的隐藏。其原因是近期 Xposed 模块 `Bootloader Spoofer` 大火,很多用户对某个粉色 App 启用 Xposed 模块,发现仍然能够检测到 Zygisk 注入的痕迹,因此怀疑 Shamiko 能力不行。在这篇文章发表之前,我们暂且为了回应某些质疑加入了无意义,但能应付某些检测程序的隐藏,其原理如下: 1. 对于每一个要隐藏的内存区域,创建等长的 `shared` 匿名内存,将原内存拷贝到匿名内存。 ```cpp void * m = mmap(nullptr, len, map.perms | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, -1, 0); memcpy(m, reinterpret_cast<void *>(map.start), len); mprotect(m, len, map.perms); ``` 2. `remap` 匿名内存到原模块内存位置。 ```cpp mremap(m, len, len, MREMAP_FIXED | MREMAP_MAYMOVE, reinterpret_cast<void *>(map.start)); ``` 聪明的读者到这里应该能够发现其本质就是优化版的 `riru_hide`。 ## 注入隐藏已死 回到最开始的问题,为什么说用户态注入隐藏在理论上是不可能的?接下来的一节将详细讨论为什么以上的隐藏方案皆行不通。 ### 匿名可执行内存 需要首先强调的是,一个正常的 Android App 环境除了 `jit-cache`、`jit-zygote-cache` 和 `[vdso]` 外,**不应该**存在任何的**非文件**可执行内存。因此,如果发现了任何的匿名可执行内存,均可直接认为存在注入! 对于 `MAP_PRIVATE` 的匿名内存,在 `maps` 中路径会显示为空,而对于 `MAP_SHARED` 的匿名内存,在 `maps` 中路径会显示为 `/dev/zero (deleted)`。有人可能会问:这里的路径是否能够改变呢?答案是:可以,但没有意义。Linux 提供了接口 `prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, start, len, name)` 改变匿名内存名称,但会在 `maps` 中显示为 `[anon:name]`。很遗憾,正常环境是不可能存在 `[anon:` 开头的可执行内存的。 ### jit-cache 既然 Zygisk 模块与真正的 `jit-cache` 在 `maps` 中的路径完全相同,那么为什么某粉色 App 能够仍然能够检测到 Zygisk 注入呢?原理在这里。 以下是一个典型 App 的内存布局: ```plaintext 95f82000-97f82000 r--s 00000000 00:01 4099 /memfd:jit-zygote-cache (deleted) 97f82000-99f82000 r-xs 02000000 00:01 4099 /memfd:jit-zygote-cache (deleted) 99f82000-9bf82000 r--s 00000000 00:01 181166 /memfd:jit-cache (deleted) 9bf82000-9df82000 r-xs 02000000 00:01 181166 /memfd:jit-cache (deleted) 7b95e92000-7b99e92000 rw-s 00000000 00:01 181166 /memfd:jit-cache (deleted) 7b99ec0000-7b99fb7000 r-xp 00000000 00:01 1158 /memfd:jit-cache (deleted) 7b99fb7000-7b99fb9000 r--p 000f6000 00:01 1158 /memfd:jit-cache (deleted) 7b99fb9000-7b99fbe000 rw-p 000f7000 00:01 1158 /memfd:jit-cache (deleted) ``` 以下是包含了一个未卸载的 Zygisk 模块的同一个 App 的内存布局。 ```plaintext 95f82000-97f82000 r--s 00000000 00:01 4099 /memfd:jit-zygote-cache (deleted) 97f82000-99f82000 r-xs 02000000 00:01 4099 /memfd:jit-zygote-cache (deleted) 99f82000-9bf82000 r--s 00000000 00:01 184001 /memfd:jit-cache (deleted) 9bf82000-9df82000 r-xs 02000000 00:01 184001 /memfd:jit-cache (deleted) 7b95e98000-7b99e98000 rw-s 00000000 00:01 184001 /memfd:jit-cache (deleted) 7b99ec1000-7b99fb8000 r-xp 00000000 00:01 1158 /memfd:jit-cache (deleted) 7b99fb8000-7b99fba000 r--p 000f6000 00:01 1158 /memfd:jit-cache (deleted) 7b99fba000-7b99fbf000 rw-p 000f7000 00:01 1158 /memfd:jit-cache (deleted) 7b9a143000-7b9a170000 r-xp 00000000 00:01 2048 /memfd:jit-cache (deleted) 7b9a170000-7b9a172000 r--p 0002c000 00:01 2048 /memfd:jit-cache (deleted) 7b9a172000-7b9a173000 rw-p 0002d000 00:01 2048 /memfd:jit-cache (deleted) ``` 那么,问题出在哪呢?让我们从源码出发。`jit-cache` 的创建来自 `JitMemoryRegion::Initialize` 函数([链接](https://cs.android.com/android/platform/superproject/main/+/main:art/runtime/jit/jit_memory_region.cc;l=85;drc=54417d2c7e5254f8941119f8f16476c1a45e028a)): ```cpp mem_fd = unique_fd(art::memfd_create("jit-cache", /* flags= */ 0)); ``` 可以发现,该函数的调用链总共只有**两条** ,一条最终来自 `Runtime::Start`,一条来自 `ZygoteHooks_nativePostForkChild`,分别用于初始化 `shared region` 和 `private region`,而很明显,这两条调用链的起点对于一个 App 来说只会**分别有一次**(不考虑 App Zygote 的特殊情况): ![JitMemoryRegion::Initialize 调用链](https://nullptr.icu/usr/uploads/2024/01/3344200755.png) 相信眼尖的读者这时候已经发现问题了:`shared/private` 的 `jit-cache` 的 `inode` 必须分别是一致的!后者一个示例中出现了 `inode` 不一致的 `private region`,因此可以断定,`jit-cache` 中存在李鬼。`jit-zygote-cache` 原理类似,不再赘述。 考虑另一种情况:手动创建一个名为 `jit-cache` 的 `memfd`,并把真正的 `jit-cache` 和 Zygisk 模块都塞到里面再 `remap`,保证 `inode` 一致,是否可行呢?很可惜,答案是仍然不行。根据 AOSP 代码,`jit-cache` 有非常特殊的结构。 > Dual view of JIT code cache case. Create an initial mapping of data pages large enough for data and non-writable view of JIT code pages. We use the memory file descriptor to enable dual mapping - we'll create a second mapping using the descriptor below. The mappings will look like: ```plaintext VA PA +---------------+ | non exec code |\ +---------------+ \ | writable data |\ \ +---------------+ \ \ : :\ \ \ +---------------+.\.\.+---------------+ | exec code | \ \| code | +---------------+...\.+---------------+ | readonly data | \| data | +---------------+.....+---------------+ ``` > In this configuration code updates are written to the non-executable view of the code cache, and the executable view of the code cache has fixed RX memory protections. > > This memory needs to be mapped shared as the code portions will have two mappings. > > Additionally, the zygote will create a dual view of the data portion of the cache. This mapping will be read-only, whereas the second mapping will be writable. 从上面可以看到,一个 `jit-cache` 区域最多存在四个段,且可执行的段仅有一个。因此试图将 Zygisk 模块伪装成`inode`一致的`jit-cache` 仍然是不可行的。 ### vdso 虽然 `vdso` 和匿名内存的 `inode` 都是 `0`,但是无法将匿名内存的名称设置为 `[vdso]`。而 `vdso` 的内存区域很小,通常只有一页,不可能装得下 Zygisk 模块的可执行内存。`vdso` 的起始地址还可以通过 `getauxval(AT_SYSINFO_EHDR)` 获取。 ### 综合检测方案 综上所述,我们得出了完整的注入检测流程: 1. 扫描 `maps` 中所有可执行内存,如果路径既不是以 `/` 开头,也不是 `[vdso]`,或者路径以 `/dev/zero` 开头,则认为存在注入。 2. 如果路径以 `/memfd:jit-cache` 或 `/memfd:jit-zygote-cache` 开头,且对应区域的 `inode` 存在不一致,或存在多于一个的可执行段,则认为存在注入。 3. 剩余的项如果 `maps` 中的 `inode` 和 `stat` 对应路径的 `inode` 不一致,或常规路径检查发现端倪,则认为存在注入。 在实际生产环境中,以上的流程是不完整的,还需要对一些旧版本 Android 系统的特殊情况做出兼容,在这里就留给读者自己思考。 ## 出路 至此,似乎在用户态进行注入隐藏已经不可能了。最好的选择当然是放弃注入,而在应用能够发觉之前就完成自己的工作并卸载。事实上,Shamiko 就始终卸载自己,并在此之前尽可能为 Zygisk 本身和其他模块擦屁股。不过,笔者在此仍然遗留了一个可能的方案,我自己没有能力实现,并且也不知道实现的效果能否达到预期。 从前面的 `jit-cache` 结构可以看出,实际上整体结构前后两大块是分开的,因此前端存在可利用空间。我们可以事先 hook `jit-cache` 的创建过程,在两大块头部预留足够加载所有 Zygisk 模块可执行区域的空间,然后 hook linker 的 `mmap` 过程将所有模块的可执行区域都映射到预留空间中。这样,就能规避 `jit-cache` 可执行段只能有一个的限制。当然,这个方法只是理论可行,处理起来十分复杂,且不保证不会造成 ART 工作异常,感兴趣的读者可以尝试自己实现。 最后修改:2024 年 01 月 16 日 © 允许规范转载 赞 100 如果觉得我的文章对你有用,请随意赞赏
18 条评论
为什么不考虑”来自只读的系统分区或应用自带的库。“呢?
随机选择一个比较大的系统库文件,做 PRIVATE 映射(对其的改动不会影响到映射这个文件的页),然后把里面的内容改成所需的(inode号不会改变)。
”猫“如果希望做黑名单,那就可能需要知道所有版本的 Android、自己 App 的各种状态下会不会映射这些文件,达到藏木于林的效果;至于检查所有不可写映射是否和so文件内容相同(还要考虑relro),则会使得正常运行消耗较大量的时间。
如果有什么是我没有注意到的,还望指出。
Private_Dirty: 你好
Which is the name of the Pink app?
momo
:)
谢谢大佬这么久的付出 谢谢
感谢大佬的无私奉献!
感谢你的付出与科普
那要是把read的结果给改了呢,把读map的结果过滤一下
成为KernelSU的一部分呢,从内核操纵maps隐藏,虽然远超出"KernelSU"这个名字该做的事了
APatch 有个 KPM (Kernel Patch Module) 或许是个好方案?
借助 APatch 的 KPM,成功的隐藏了 maps 中所有 [anon:*] 的执行权限,但还是被 某粉色 APP (Momo 吗?) 扫描到了异常,看来实际上需要隐藏的可能不止这些?
B站吧
666啊
谢谢大佬的付出
感谢大佬付出,辛苦科普了
Thank you, big fan of your work on lsposed , HMA and Shamiko
谢谢大佬这么久的付出,辛苦了
great