Android 用户态注入隐藏已死

发布于 2024-01-17  2,493 次阅读


前言

隐藏注入一直是 Android 各种检测和反检测斗争的战场。实际上早在很久以前,各种外挂制作者就已经认识到,要在用户态中隐藏一个注入到 App 的动态库是不可能的,因此现在的外挂都是纯粹的内核挂。然而,现实是网络上几乎无法找到完整的用户态注入检测原理,或者说这些都被藏在各大游戏安全 SDK 内部。本文在这里列出了各大隐藏方案和完整的注入检测流程,以及说明为什么 Android 用户态注入隐藏是不可能的。
注意,本文并非着重于讨论游戏外挂,而是对最近的 Zygisk 模块隐藏一事盖棺定论。实际上,游戏外挂在隐藏对抗方面领先 Root 社区至少 3 年,讨论这个是没有意义的。

为什么需要隐藏

对于一个正常环境的 Android App 而言,其内存中的可执行库只可能来自只读的系统分区或应用自带的库。如果 /proc/self/maps 中发现来自 /data/adb 等“不应存在的位置”的库,即可认为环境异常,从而拒绝工作。因此,注入框架诞生伊始,这场与安全厂商的猫捉老鼠游戏就打响了。

隐藏方案

早期方案

Root 社区最早的 Zygote 注入框架是 Riru。Riru 加载模块的方式是直接在 Zygote 中 dlopen 模块 so 的路径。因此如果不进行隐藏,App 能够发现自己 maps 中堂而皇之的出现了路径包含 .magisk 的库。
Riru 自带了名为 riru_hide 的功能,其原理如下:

  1. 扫描 maps,对于路径是需要隐藏的项,如果可读,则将其信息放入 data 数组中。
  2. 对于每一个要隐藏的内存区域,创建等长的 private 匿名备份内存,将原内存拷贝到备份内存。
    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 匿名内存。
    FAILURE_RETURN(munmap((void *) start, length), -1);
    FAILURE_RETURN(mmap((void *) start, length, prot, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0),
                  MAP_FAILED);
  4. 将备份内存拷贝到新创建的匿名内存。
    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 提供了最早的隐藏方案。值得注意的是,实际上直到 1.0 之前,Shamiko 并没有采用类似 riru_hide 的方案,而是主要处理了 maps 以外别的痕迹(如最近抄袭争议的 soinfo,我们在 2 年前就已经在 Shamiko 中加入了此项隐藏)。这是由于以下的原因:

  1. Zygisk 和 Riru 不同,它使用 magiskd 传来的 fd 进行 dlopen 来加载模块,而非在 Zygote 中直接通过路径打开。Zygisk 使用 fddlopen 模块 so,并将其路径设为 /jit-cache
  2. 在加载完成并关闭 fd 后,在 maps 中模块 so 的路径会显示为 /memfd:jit-cache (deleted)。App 进程本身就存在 jit-cache,Zygisk 模块与真正的 jit-cachemaps 中的路径完全相同,因此“似乎”并没有必要进行额外的操作以去掉这片内存的名称。

Shamiko 后期方案

Shamiko 1.0 中,最后还是加入了类似 riru_hide 的隐藏。其原因是近期 Xposed 模块 Bootloader Spoofer 大火,很多用户对某个粉色 App 启用 Xposed 模块,发现仍然能够检测到 Zygisk 注入的痕迹,因此怀疑 Shamiko 能力不行。在这篇文章发表之前,我们暂且为了回应某些质疑加入了无意义,但能应付某些检测程序的隐藏,其原理如下:

  1. 对于每一个要隐藏的内存区域,创建等长的 shared 匿名内存,将原内存拷贝到匿名内存。
    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 匿名内存到原模块内存位置。
    mremap(m, len, len, MREMAP_FIXED | MREMAP_MAYMOVE, reinterpret_cast<void *>(map.start));

聪明的读者到这里应该能够发现其本质就是优化版的 riru_hide

注入隐藏已死

回到最开始的问题,为什么说用户态注入隐藏在理论上是不可能的?接下来的一节将详细讨论为什么以上的隐藏方案皆行不通。

匿名可执行内存

需要首先强调的是,一个正常的 Android App 环境除了 jit-cachejit-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-cachemaps 中的路径完全相同,那么为什么某粉色 App 能够仍然能够检测到 Zygisk 注入呢?原理在这里。
以下是一个典型 App 的内存布局:

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 的内存布局。

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 函数(链接):

mem_fd = unique_fd(art::memfd_create("jit-cache", /* flags= */ 0));

可以发现,该函数的调用链总共只有两条 ,一条最终来自 Runtime::Start,一条来自 ZygoteHooks_nativePostForkChild,分别用于初始化 shared regionprivate region,而很明显,这两条调用链的起点对于一个 App 来说只会分别有一次(不考虑 App Zygote 的特殊情况):
JitMemoryRegion::Initialize 调用链
相信眼尖的读者这时候已经发现问题了:shared/privatejit-cacheinode 必须分别是一致的!后者一个示例中出现了 inode 不一致的 private region,因此可以断定,jit-cache 中存在李鬼。jit-zygote-cache 原理类似,不再赘述。
考虑另一种情况:手动创建一个名为 jit-cachememfd,并把真正的 jit-cacheZygisk 模块都塞到里面再 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:

        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 中的 inodestat 对应路径的 inode 不一致,或常规路径检查发现端倪,则认为存在注入。

在实际生产环境中,以上的流程是不完整的,还需要对一些旧版本 Android 系统的特殊情况做出兼容,在这里就留给读者自己思考。

出路

至此,似乎在用户态进行注入隐藏已经不可能了。最好的选择当然是放弃注入,而在应用能够发觉之前就完成自己的工作并卸载。事实上,Shamiko 就始终卸载自己,并在此之前尽可能为 Zygisk 本身和其他模块擦屁股。不过,笔者在此仍然遗留了一个可能的方案,我自己没有能力实现,并且也不知道实现的效果能否达到预期。
从前面的 jit-cache 结构可以看出,实际上整体结构前后两大块是分开的,因此前端存在可利用空间。我们可以事先 hook jit-cache 的创建过程,在两大块头部预留足够加载所有 Zygisk 模块可执行区域的空间,然后 hook linker 的 mmap 过程将所有模块的可执行区域都映射到预留空间中。这样,就能规避 jit-cache 可执行段只能有一个的限制。当然,这个方法只是理论可行,处理起来十分复杂,且不保证不会造成 ART 工作异常,感兴趣的读者可以尝试自己实现。

来源:Nullptr's Blog