CVE-2018-9488 - 从zygote到init

概述

上一篇文章中利用cve-2018-9445实现了Android系统在mount外设时的目录穿越CVE-2018-9445 —— Android挂载外设的目录穿越,由于字符长度限制,只有5个字符可控,利用极其有限。

PJ0的Jann Horn,也就是漏洞的原作者对该漏洞进一步分析,发现了更精巧的利用方法,能够基于该漏洞进一步从zygote提权到init,Goole为这一新的利用分配了
新的cve编号 —— CVE-2018-9488。

漏洞分析

cve-2018-9445能够利用的前提是USB设备有MBR分区表,且格式为vfat,这样内核的vfat文件系统才会mount。此外,如需进一步利用,还需要绕过以下几种安全措施:

  • 路径穿越5个字符的限制
  • SELinux限制了vold进程的操作
  • 权限限制,如果目录权限不是0700,fs_prepare_dir和chmod会失败
  • vfat文件系统限制。vfat格式的U盘被mount后,所有文件会被标记为u:object_r:vfat:s0,这意味着即使mount后的目录为/system或/data等一些系统级别的目录,SELinux Context下的进程(如zygote和system_server)是无法和其交互的。
  • media_rw组的进程需要绕过DAC检测

漏洞利用

1.伪造一个USB设备

如上篇文章所述,blkid支持各种不同的文件系统,在解析文件头得到type, label和 UUID之后,fsck_msdos会再次读取文件,检测文件系统是否为vfat格式,我们只需要构造一个动态可控的USB设备使系统在两次读取信息时,返回不同的值即可满足这两种条件。即blkid在读取时,构造一个长字符串的label,使blkid认为是romfs格式;fsck_msdos在读取时,再修改自己成为一个vfat格式的USB设备。

这里使用一个树莓派Zero W去构造一个USB Gadget实现上述功能,USB Gadget实现了 USB 协议定义的设备端的软件功能,Linux 中一切皆文件,因此通过配置一些参数如设备类型,序列号等,就可以被操作系统识别为一个“真实的”USB设备。参考Make your own USB gadget”

注意之所以使用Zero W是因为其支持USB device模式,Zero W有两个MicroUSB接口,分别用来供电和传输数据,市面上稍早些的树莓派,比如树莓派2B,树莓派3等,它们都只支持作为 Host。
upload successful

为了实现目的,我们使用FUSE来实现辅助构造一个USB设备。 FUSE即用户空间文件系统(Filesystem in Userspace),通过FUSE可以修改文件系统读取属性,以实现不同条件下的读取返回不同的内容,核心代码如下:

1
2
3
4
5
6
7
static int readdir(const char *path, char *buf, size_t size, off_t offset, struct fuse_file_info *fi) {
backing_fds[STATE_BLKID] = open("disk_image_blkid", O_RDWR);
backing_fds[STATE_MOUNT] = open("disk_image_mount", O_RDWR);
if (offset == 0 && size == 0x1000) switch_to_fd_idx(STATE_BLKID);
if (offset == 0x0011e000 && size == 0x2000) switch_to_fd_idx(STATE_MOUNT);
...
}

这里构造了两个image,第一个imgae为romfs格式,这里命名为”disk_image_blkid”,通过命令修改其label值以触发漏洞。

1
# echo -e '-rom1fs-########TYPE="vfat" UUID="../../data"\0' > /dev/sdd1

第二个image为vfat格式,这里命名为”disk_image_mount”,放入需要替换的文件。

编译fuse_intercept.c

1
gcc -Wall fuse_intercept.c `pkg-config fuse --cflags --libs` -o fuse_intercept

新建一个目录”mount”,并在控制台中运行fuse_intercept。

在另一个控制台运行

1
2
sudo modprobe dwc2
sudo modprobe g_mass_storage file=/home/pi/mount/wrapped_image stall=0

告诉内核将mount目录作为一个usb设备。

2.绕过SELinux

即使成功的利用漏洞monut到了/data目录,由于Android的一些安全限制,很多代码是无法访问mount的文件系统的。

基于Android的访问限制,代码访问mount文件系统需要绕过DAC和SELinux两种限制:

  • DAC (默认mount的文件属性为media_rw组)

upload successful

  • SELinux (zygote和system_server中的代码无权限加载mount的文件,所有文件为u:object_r:vfat:s0)

upload successful

事实上我们挂载的vfat文件系统由于是被动挂载,无法修改文件属性和绕过SELinux限制,利用上述的目录穿越漏洞,除了覆盖一些文件外,是无法实现代码执行的。

为了实现代码执行,必须切换一下思路。我们知道Android(Linux)的分区是通过mount实现的,既然我们mount的文件无法绕过DAC和SELinux,那么系统mount的文件是如何实现的呢?这就引出了一个新的利用思路:PrivateVolume。

Android系统的vold支持两种形式的USB —— PublicVolume和PrivateVolume(也就是外部存储和内部存储),之前的漏洞利用我们一直基于PublicVolume,然而vold还可以挂载为PrivateVolume。PrivateVolume有一个很重要的特点就是可以控制文件的SELinux标签,这是因为PrivateVolume属于ext4文件格式,而ext4是支持修改SELinux的。因此如果能挂载为PrivateVolume,我们就可以绕过SELinux检测,进而实现代码执行。

挂载成为PrivateVolume需要一些条件:PrivateVolume是由dm-crypt-encrypted加密的ext4文件系统,PrivateVolume必须为GPT分区格式,且包含一个独特的UUID kGptAndroidExpand (193D1EA4-B3CA-11E4-B075-10604B889DCF)。key保存在/data/misc/vold/expand_{partGuid}.key这个路径,其中{partGuid}为GPT分区的GUID。

正常情况下攻击者不可能挂载一个PrivateVolume,因为手机上没有这个key,即使有,我们也不知道具体的GUID。但是攻击者可以利用之前的目录穿越漏洞覆盖/data/misc 目录,把自己的key和GUID预置在那里。

这里有人可能会质疑,这个key文件难道没有SELinux限制么?这是因为挂载是由vold进程实现的,正如本节开头所述,vold进程是用来处理mount事件的,因此vold是有权限访问的。

本节通过挂载成为PrivateVolume,我们可以绕过DAC和SELinux限制,进而实现代码执行。

3.注入Zygote

绕过了DAC和SELinux限制,那么如何实现代码执行呢?通过利用漏洞覆盖/data,我们可以替换一些高权限进程加载的第三方库,进而注入我们自己的代码。

zygote进程具有很高的权限,可以任意修改自己的UID和context,几乎可以访问所有的user数据。zygote在启动时,会加载/data/dalvik-cache/arm64/system@framework@boot*.{art,oat,vdex}三个文件,其中oat和vdex为/system分区的软连接,oat为一个elf文件。

我们在mount到/data目录时,为保证zygote运行,需要准备这3个文件,其中oat和vdex直接设置为对应的软连接即可,对于oat文件,在attribute((constructor))函数中注入我们的代码,这样只要dlopen()加载oat文件时便会执行我们的代码。

到目前为止我们实现了Zygote进程的代码执行,但只是理论上实现,因为Zygote只要在启动时才会加载这些第三方库,而我们的漏洞在触发时,Zygote已经运行起来了,因此我们需要让Zygote再加载一次。

4.Crash system_server

为了实现Zygote重新加载一次,重启手机显然是不行的,Zygote倒是重新加载了,但vold进程也会重启,意味着我们漏洞利用也重新来过。因此只能采取软重启,但又保持vold进程不变的方式,来重启Zygote进程。

Android中有一段用来跟踪宽带占用的代码,这段代码会不断的向/data分区写入数据,但超过2M的数据(mPersistThresholdBytes)写入失败时,就会导致system_server重启,也就意味着Zygote会重启。

利用这个逻辑,我们可以使用Ping flood来触发Zygote重启。

使用Ping flood需要基于一个前提,即被攻击设备连接到我们控制的网络中,这有两种办法可以实现:

  • Android 9.0之前,在锁屏界面是可以控制手机连接到一个无需密码的wifi,以Pixel为例,只需要下滑屏幕,点击wifi图标下的小三角即可选择要连接的wifi。
  • 在手机上插一个USB无线网卡

上述两种办法都可以实现利用Ping flood来crash system_server。

到目前为止,我们漏洞利用做到了Zygote下的代码执行,Android中的应用都是由Zygote进程孵化而来,因此目前的漏洞利用代码可以访问所有的用户数据,但zygote毕竟权限有限,如不能打开一些块设备文件、访问部分内存空间受限等等,为了扩大化攻击,我们需要进一步提权。

5.从zygote到vold

早期的Android通过一个高权限的守护进程来生成crash dump文件,现在的andorid采用/system/bin/crash_dump64和/system/bin/crash_dump32来生成crash dump文件,这两个文件的SELinux标签为u:object_r:crash_dump_exec:s0,当这个标签的文件任何SELinux domain执行时,其context都会变为crash_dump domain。

crash_dump 的SELinux策略如下:

1
2
3
4
5
6
7
8
9
10
11
12
https://android.googlesource.com/platform/system/sepolicy/+/a3b3bdbb2fdbb4c540ef4e6c3ba77f5723ccf46d/public/crash_dump.te:
[...]
allow crash_dump {
domain
-init
-crash_dump
-keystore
-logd
}:process { ptrace signal sigchld sigstop sigkill };
[...]
r_dir_file(crash_dump, domain)
[...]

这个策略允许crash_dump通过ptrace去attach绝大多数其他进程,包括vold进程,因此如果我们在crash_dump context下就可以通过attach拿到了vold进程权限。

到这里也许我们想把一个预置一个带u:object_r:crash_dump_exec:s0标签的文件,然后去执行它,进而转移到crash_dump domain,然而这是行不通的。这是因为vold在mount时对文件做了权限降级,无法实现SELinux domain transitions。

因此,现在只能通过注入代码到crash_dump64,我们使用unshare()创建一个新的挂载点,然后调用pivot_root()将根目录指向一个我们完全可控的目录,之后再执行crash_dump64。这样kernel会解析crash_dump64的文件头,获取linker的路径(/system/bin/linker64),并从这个路劲加载linker并执行,如果这个linker是我们自己实现的,这个过程就会执行我们的代码。之后,我们的代码再通过attach到vold进程,拿到vold进程的执行权限。

6.从vold到init context

到这里为止我们已经通过attach的方式控制了vold进程,作者又从vold提权到init context,注意这里是context,并非init进程。通过查找代码中所有能转为init contect的SELinux策略,发现kernel context可以转为init context:

1
2
https://android.googlesource.com/platform/system/sepolicy/+/master/private/kernel.te:
domain_auto_trans(kernel, init_exec, init)

这意味着如过kernel context的代码执行一个带有init_exec标签的文件,这个文件的context将变为init context。

运行在kernel context的代码只能是kernel层了,即我们需要想办法让kernel执行一个带init_exec的文件。作者进一步发现当查找一个不存在的key时(例如调用request_key()),/sbin/request-key文件将会被kernel调用,所以利用之前的漏洞,我们替换/sbin目录,并预置我们自己构造的request-key,在获得vold执行权限后,调用request_key(),这时候kernel会调起我们构造的request-key,进而变为init context。

7.从init context到kernel

理论上从init context还可以进一步提权到kernel权限,但作者到这一步便没有再进行研究。

通过查找源码中的domain_trans,发现init context可以转为modprobe或vendor_modprobe

1
2
domain_trans(init, { rootfs toolbox_exec }, modprobe)
domain_trans(init, vendor_toolbox_exec, vendor_modprobe)

modprobe或vendor_modprobe有加载kernel module的权限:

1
2
3
4
allow modprobe self:capability sys_module;
allow modprobe { system_file }:system module_load;
allow vendor_modprobe self:capability sys_module;
allow vendor_modprobe { vendor_file }:system module_load;

Android目前对kernel modules的加载还没有签名验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
walleye:/ # zcat /proc/config.gz | grep MODULE
CONFIG_MODULES_USE_ELF_RELA=y
CONFIG_MODULES=y
# CONFIG_MODULE_FORCE_LOAD is not set
CONFIG_MODULE_UNLOAD=y
CONFIG_MODULE_FORCE_UNLOAD=y
CONFIG_MODULE_SRCVERSION_ALL=y
# CONFIG_MODULE_SIG is not set
# CONFIG_MODULE_COMPRESS is not set
CONFIG_MODULES_TREE_LOOKUP=y
CONFIG_ARM64_MODULE_CMODEL_LARGE=y
CONFIG_ARM64_MODULE_PLTS=y
CONFIG_RANDOMIZE_MODULE_REGION_FULL=y
CONFIG_DEBUG_SET_MODULE_RONX=y

因此,在init context下执行一个文件变为modprobe context,进而加载一个kernel module可以提权到kernel权限。

总结

这套利用可谓是极其精巧,把一个简单的目录穿越变为了kernel下的代码执行。我总结了漏洞利用过程中几个关键的节点,如下图:

upload successful

完成这套利用需要对Android的文件系统、DAC和SELinux有很深的理解,每一个关键环节都能看出作者扎实的基础和功底,是我们学习的榜样!

参考资料

  1. https://googleprojectzero.blogspot.com/2018/09/oatmeal-on-universal-cereal-bus.html
  2. https://bugs.chromium.org/p/project-zero/issues/detail?id=1583 (“directory traversal over USB via injection in blkid output”)
  3. https://bugs.chromium.org/p/project-zero/issues/detail?id=1590 (“privesc zygote->init; chain from USB”)
  4. 使用树莓派 Zero 实现带回显的新型 Bad USB http://shumeipai.nxez.com/2018/06/26/using-raspberry-pi-zero-to-implement-new-bad-usb-with-echo.html
  5. FUSE http://man7.org/linux/man-pages/man4/fuse.4.html