导语:本文主要描述了针对Android设备如何成功实现此类攻击,我们选用了Pixel 2设备进行测试。

一、概述

最近,对智能手机的物理攻击话题引起了一定的关注,其中的一个主要关注点是:攻击者是否能够在设备锁定的状态下,成功通过USB连接访问存储在设备上的数据。本文主要描述了针对Android设备如何成功实现此类攻击,我们选用了Pixel 2设备进行测试。

在Android手机启动后,会要求用户进行一次解锁(在较新的设备上,会显示“解锁所有功能和数据”页面;在较老的设备上,会显示“要启动Android,请输入密码”),设备会保存加密密钥,用于解密内核内存中的文件。即使屏幕在锁定状态,加密的文件系统区域或分区仍然可以访问。因此,如果在锁定的设备上成功提升权限,那么攻击者不仅可以在设备上执行任意代码并借助后门访问设备,还可以直接访问用户的数据。

本文相关的漏洞报告及PoC代码,详见以下链接:

https://bugs.chromium.org/p/project-zero/issues/detail?id=1583(通过blkid输出注入USB实现目录遍历)

https://bugs.chromium.org/p/project-zero/issues/detail?id=1590(privesc zygote->init;USB攻击链)

上述两个漏洞编号为CVE-2018-9445( https://source.android.com/security/bulletin/2018-08-01 )和CVE-2018-9488( https://source.android.com/security/bulletin/2018-09-01#system ),分别在2018年8月补丁和9月补丁中实现修复。

二、攻击面

许多Android手机都支持USB主机模式(通常需要使用OTG适配器),该模式允许手机连接到多种类型的USB设备(这个列表不一定完整):

1、USB存储卡:当USB记忆棒插入Android手机时,用户可以在手机和USB存储卡之间复制文件。即使设备被锁定,P版本之前的Android系统仍然会尝试安装USB存储卡。(在报告漏洞后,新发布的Android 9中,设备在锁定状态不会安装USB存储卡。)

2、USB键盘和鼠标:Android支持使用外部输入设备,来替代原有的触摸屏。此类设备也会在锁屏状态下启用,比如需要用这些设备来输入PIN码。

3、USB以太网适配器:当USB以太网适配器连接到Android手机后,手机将会尝试连接到有线网络,并使用DHCP获取IP地址。此类设备在手机锁定状态下同样有效。

本文我们将关注的重点放在USB存储卡上。如果在具有高权限的系统组件中安装不受信任的USB存储卡,将会为攻击者创造重要的攻击面。内核必须使用包含SCSI子集的协议与USB大容量存储设备进行通信,对其分区表进行解析,并使用内核的文件系统实现来解释其分区内容。用户空间代码必须标识文件系统类型,并指示内核将设备安装到某个位置。在Android上,用户空间实现主要是在vold中(其中的一个进程与内核具有相同的权限),它会在SELinux中使用单独的进程,例如:确定USB存储卡上分区的文件系统类型。 

三、漏洞分析:确定分区属性

在插入USB存储卡,且vold确定了设备上的分区列表时,它会尝试识别每个分区的三个属性:标签(描述分区的用户可读字符串)、UUID(唯一标识符,可用于确认USB存储卡是否在此前已经连接过该设备)和文件系统类型。在最新的GPT分区方案中,这些属性大多可以存储在分区表之中,然而USB存储卡倾向于使用MBR分区方案,该方案中不能存储UUID和标签。对于普通USB存储卡,Android同时支持MBR分区方案和GPT分区方案。

为了提供标记分区并为其分配UUID的能力,一旦使用的是MBR分区方案,就会在文件系统的头部保存一个包含这些属性的字段,允许在已经确定文件系统、清除特定文件系统的头部结构的情况下,以特定的方式提取这一信息。如果vold想要确定Label、UUID和文件系统类型时,它会调用blkid_untrusted SELinux域中的/system/bin/blkid。首先,会尝试使用Magic Number和一些启发式识别文件系统类型,随后提取Label和UUID。

它会将结果以下列格式打印到stdout:

/dev/block/sda1: LABEL="<label>" UUID="<uuid>" TYPE="<type>"

然而,Android使用的blkid版本没有对Label字符串进行转义,而负责解析blkid输出的代码部分只会寻找第一次出现的UUID=”和TYPE=”。因此,如果创建一个具有特定Label的分区,就可以实现对UUID的控制,并得到返回到vold的字符串。否则在正常情况下,它将始终是有效的UUID字符串以及一组固定类型字符串中的一个。

四、漏洞分析:挂载文件系统

在使用MBR分区表的新USB存储卡中,当vold确认包含内核的vfat文件系统实现能够挂载的vfat类型分区时,PublicVolume::doMount()会根据文件系统UUID构造一个挂载路径,然后通过尝试确保mountpoint目录存在并具有正确的权限和模式,随后尝试挂载该目录:

   if (mFsType != "vfat") {
       LOG(ERROR) << getId() << " unsupported filesystem " << mFsType;
       return -EIO;
   }
   if (vfat::Check(mDevPath)) {
       LOG(ERROR) << getId() << " failed filesystem check";
       return -EIO;
   }
   // Use UUID as stable name, if available
   std::string stableName = getId();
   if (!mFsUuid.empty()) {
       stableName = mFsUuid;
   }
   mRawPath = StringPrintf("/mnt/media_rw/%s", stableName.c_str());
   [...]
   if (fs_prepare_dir(mRawPath.c_str(), 0700, AID_ROOT, AID_ROOT)) {
       PLOG(ERROR) << getId() << " failed to create mount points";
       return -errno;
   }
   if (vfat::Mount(mDevPath, mRawPath, false, false, false,
           AID_MEDIA_RW, AID_MEDIA_RW, 0007, true)) {
       PLOG(ERROR) << getId() << " failed to mount " << mDevPath;
       return -EIO;
   }

在此过程中,仅仅使用格式字符串来确定装载路径,并不会对blkid提供的UUID字符串进行任何检查。因此,控制UUID字符串的攻击者可疑实现目录遍历攻击,并使得FAT文件系统安装在/mnt/media_rw之外。

这意味着如果攻击者将带有标签字符串为UUID="../##的FAT文件系统的USB存储卡插入锁定的手机中,手机会将该USB存储卡挂载到/mnt/##。

然而,这种直接的攻击方式有几个严重的局限性,其中的一些能够解决,另一些则无法避免:

1、标签字符串长度问题:FAT文件系统的标签限制为11字节,而攻击者要实现注入,UUID =“字符就要占用6个字节,这样一来,目录遍历就只剩下了5个字符,不足以到达挂载结构中的任何关键位置。我们将在下一节介绍如何避免这一问题。

2、SELinux对挂载点的限制:即使vold被认为是与内核具有同等权限,但SELinux策略实际上也会对vold进行一些限制。具体而言,mounton权限仅限于在一组经允许的标签上使用。

3、可写性要求:如果目标目录不是0700权限,并且chmod()失败,那么fs_prepare_dir()也将会失败。

4、访问vfat文件系统的限制:安装vfat文件系统时,其所有文件都被标记为u:object_r:vfat:s0。即使文件系统安装在加载重要代码或重要数据的位置,也不允许太多SELinux上下文与文件系统进行实际的交互。例如,该限制不允许zygote和system_server进行此类操作。最重要的是,没有足够权限绕过DAC检查的进程也需要在media_rw组中。下文中的“处理SELinux:触发两次漏洞”一章将会详细说明如何避免这些限制。

五、漏洞利用:USB大容量存储

正如上一节中所说,FAT文件系统的标签限制为11个字节。然而,blkid支持一系列具有更长标签长度的其他文件系统类型。如果使用了这些文件系统类型,前提是必须要通过fsck对vfat文件系统的检查,并且还需要通过在vfat文件系统装载时由内核执行的文件系统头部检查。Vfat内核文件系统在分区开始时并不需要一个固定的Magic Value,所以理论上这种方式是可以实现的。但是,由于FAT文件系统头部中的几个值对内核而言十分重要,同事blkid也会对一些超级块(Superblocks)进行健全性检查,因此PoC采用的是一种不同的方式。

在blkid读取了部分文件系统并确定文件系统的类型、标签和UUID之后,fsck_msdos和内核文件系统实现将会重新读取相同的数据,并且这些重复读取的内容将会传递到存储设备上。当用户控件直接与块设备进行交互时,Linux内核会缓存块设备的页,但__blkdev_put()会在引用自设备的最后一个已打开文件关闭时,删除与块设备关联的所有缓存数据。

由此,物理攻击者可以通过附加假的存储设备来实现这一功能的滥用,该存储设备返回多次从同一位置读取的不同数据。这样一来,就允许我们将带有长标签字符串的romfs头部返回给blkid,同时向fsck_msdos和内核文件系统实现呈现出完全正常的vfat文件系统。

由于Linux内置支持设备端USB,因此这一点实现起来相对简单。Andrzej Pietrasiewicz曾发表过演讲“制作你自己的USB实用工具”( https://events.static.linuxfound.org/sites/events/files/slides/LinuxConNA-Make-your-own-USB-gadget-Andrzej.Pietrasiewicz.pdf ),其演讲内容可以作为我们的参考。基本上,内核都会附带设备端USB大容量存储、HID设备、以太网适配器等实现。我们利用相对简单的基于伪文件系统的配置界面,就可以定制出一个小工具,从而为连接的设备提供一个或多个上述功能(可能会有多个实例)。我们需要一个运行Linux并支持设备端USB的系统,在实际测试中,我们选用了Raspberry Pi Zero W。

f_mass_storage实用工具的作用在于,使用普通文件作为后备存储(Backing Storage)。为了能够以交互方式响应来自Android手机的请求,我们使用FUSE文件系统作为后备存储,并使用direct_io选项 / FOPEN_DIRECT_IO标志来确保内核中不会增加不需要的缓存。

至此,我们已经可以实施文件窃取攻击,例如我们可以窃取存储在外部存储上的照片。针对攻击者,在安装USB存储卡后,可以立即启动com.android.externalstorage/.MountReceiver,这是SELinux域中一个允许访问USB设备的进程。因此,在恶意FAT分区通过标签字符串UUID="../../data成功在/data挂载之后,zygote进程会fork出一个具有适当SELinux上下文和组成员权限的子进程,以允许访问USB设备。随后,这个子进程从/data/dalvik-cache/加载字节码,最终允许我们控制具有泄露外部存储内容的必要权限的com.android.externalstorage。

但是,如果攻击者不只是想要访问照片那么简单,还想要访问存储在设备上的聊天日志或身份验证凭据等内容,那这种访问级别通常是不够的。

六、处理SELinux:触发两次漏洞

在这里,存在一个主要的限制因素。即使是可以挂载到/data下,但运行在设备上的许多高权限代码也不允许访问已经挂载的文件系统。然而,有一个高权限的服务确实可以,那就是vold。

Vold实际上支持两种类型的USB存储卡,分别是PublicVolume和Private Volume。在这里,我们主要关注PublicVolume。从这里,PrivateVolume也开始变得重要。

PrivateVolume是必须使用GUID分区表格式化的USB存储卡,其中必须包含类型为UUID kGptAndroidExpand(193D1EA4-B3CA-11E4-B075-10604B889DCF)的分区,其中包含着dm-crypt加密的ext4(或f2fs)文件系统。相应的密钥存储在/data/misc/vold/expand_{partGuid}.key中,其中{partGuid}是GPT表中的分区GUID,作为规范化的小写十六进制字符串。

作为攻击者,一般是不应该以这种方式安装ext4文件系统的,因为手机通常不会设置这类的密钥。即使设置了这类密钥,你仍然需要知道正确的分区GUID以及正确的密钥。然而,在这里我们可以在/data/misc上安装一个vfat文件系统,并将我们自己的密钥放在那里,用于我们的GUID。然后,当第一个恶意USB大容量存储设备仍保持连接时,我们可以使用vold从第一个USB大容量存储设备中读取的密钥,连接第二个以PrivateVolume类型加载的存储卡。

由于PrivateVolume实例使用的是ex4,所以我们可以控制文件系统上DAC的所有权和权限。又因为PrivateVolume是集成到系统中的,所以我们甚至可以控制该文件系统上的SELinux标签。

总而言之,至此为止我们可以在/data上装载可以控制的文件系统,并具有任意文件权限和任意SELinux上下文。由于此时已经能控制文件权限和SELinux上下文,所以就可以允许任何进程访问文件系统上的文件,包括使用PROT_EXEC对它们进行映射。

七、注入Zygote

尽管没有被列为TCB的一部分,但Zygote进程也确实足够强大。根据设计,该进程以UID 0运行,可以任意改变其UID,并能够执行动态SELinux转换,转换到system_server或是普通应用程序的SELinux上下文。

当64位zygote在系统启动时运行时,它会从/data/dalvik-cache/arm64/[email protected]@boot*.{art,oat,vdex} 加载代码。通常,oat文件(包含将使用dlopen()加载的ELF库)和vdex文件是指向/system分区上文件的符号链接,只有art文件实际存储在/data上。但是,我们可以将[email protected]@boot.art和[email protected]@boot.vdex符号链接指向/system(以便在不知道设备Android版本的情况下进行一致性检查),同时将我们的恶意ELF库放在[email protected]@boot.oat(使用合法oat文件所具有的SELinux上下文)。

然后,在ELF库中放置一个带有__attribute__((constructor))的构造函数,我们可以在启动时调用dlopen(),之后立即在zygote中执行代码。

在这里,仍然存在一个问题。当执行攻击时,zygote已经在运行,而这种攻击只有在zygote启动时才能有效。

八、产生系统崩溃

这一部分,比较令人头疼。

当关键系统组件(特别是zygote或system_server)崩溃时,Android会尝试通过重新启动大多数用户空间进程(包括zygote)的方式,尝试自动恢复。在发生这种情况时,屏幕首先显示启动动画,然后跳转到锁定屏幕。在这时,该屏幕上还会显示“解锁所有功能和数据”的提示,而这一提示通常仅在系统刚刚启动后显示。然而,此时仍然存在可用于访问用户数据的密钥,我们可以通过在设备上运行“ls /sdcard”来验证ADB是否已打开。

这意味着,如果我们可以以某种方式使system_server崩溃,就可以在用户空间重启期间,将代码注入到zygote中,并且能够访问设备上的用户数据。

当然,在/data上装载我们自己的文件系统是非常粗糙的方式,并且会产生各种各样的问题。但令人惊讶的是,系统不会立即崩溃。尽管部分用户界面无法使用,但大多数地方都有一些容错机制,能保证在这种情况下不会产生太过严重的问题,以至于造成重启。

经过一些尝试,我们发现Android的网络带宽用量跟踪代码具有一个安全检查机制:如果带宽用量跟踪代码无法写入磁盘,并且自上次成功写入以来已累计监测到>= 2MiB(mPersistThresholdBytes)的网络流量,就会抛出一个致命的异常(Fatal Exception)。这意味着,如果我们可以创建某种类型的网络连接到设备,然后发送>= 2MiB的Ping泛洪,随后通过等待定期回写或更改网络接口的状态触发stats回写,就可以造成设备的重启。

至于创建网络连接,我们有两种方法:

1、连接到Wi-Fi网络。在Android 9之前,就算设备被锁定,通常也可以通过从屏幕顶部向下拖动来连接到新的Wi-Fi网络,点击Wi-Fi符号下方的下拉菜单,然后选择Wi-Fi网络的名称。(这种方式对于受WPA保护的网络不起作用,但攻击者其实可以将自己的Wi-Fi网络打开。)许多设备也只能自动连接到具有特定名称的网络。

2、连接到以太网网络。Android支持USB以太网适配器,并将自动连接到以太网网络。

为了测试漏洞,我们手动创建了一个Wi-Fi,并用Android设备与之连接。考虑到更加可靠的网络连接和更加轻松的漏洞利用,可能会有读者更倾向于以太网连接。

此时,我们可以在zygote上下文中运行任意本地代码,并访问用户数据。但是,目前还不能读取原始磁盘加密密钥,直接访问底层块设备或者进行RAM转储(由于刚刚的系统崩溃,可能有一半的转储内容已经丢失)。但在这里,如果我们希望实现上述内容,就必须要升级我们的特权。

九、从Zygote到vold

即使zygote不是TCB的一部分,但它也可以访问初始用户命名空间中的CAP_SYS_ADMIN功能,并且SELinux策略也允许使用此功能。Zygote将此功能用于mount()系统调用,并用于在不设置NO_NEW_PRIVS标志的前提下安装seccomp过滤器。实际上,有多种方法可以滥用CAP_SYS_ADMIN。特别是在Pixel 2上,以下几种方法似乎是可行的:

方法1

我们可以在没有NO_NEW_PRIVS标志的情况下安装seccomp过滤器,然后执行具有权限转换的execve()函数(SELinux exec转换,setuid/setgid执行,或使用允许的文件功能集执行)。Seccomp过滤器可以强制使特定的系统调用失败,并返回错误值0。举例来说,open()函数中的错误值0表明系统调用成功并分配了文件描述符0。这种攻击方式在这里能够成功实现,但显得有点乱。

方法2

可以指示内核使用我们控制的文件作为高优先级交换设备,然后产生存储压力(Memory Pressure)。一旦内核将栈或者堆页从一个特权进程写入交换文件,我们就可以对换出的内存进行编辑,然后让进程加载这部分内存。这种技术的缺点是非常难以预测,由于涉及到存储压力,所以可能会导致系统Kill掉我们想要保留的进程,并且有可能会破坏掉RAM中许多有用内容。同时,需要使用某种方法,来确定换出的页是属于哪个进程、是什么作用。最后,此种方法需要内核支持交换(Swap)。

方法3

我们可以使用pivot_root()替换当前装载的命名空间或新创建的装载命名空间的根目录,从而绕过本应该为mount()执行的SELinux检查。如果我们希望影响之后提升权限的子进程,那么针对新的装载命名空间执行此操作会非常有效。如果跟文件系统是rootfs文件系统,则该方法不起作用。在我们的尝试过程中,使用的是这一种方法。

在最近发布的新版本Android中,用于创建崩溃进程转储的机制已经发生改变:以前是需要一个特权守护进程创建转储,而现在则是进程执行/system/bin/crash_dump64或/system/bin/crash_dump32中的一个助手,其中包含SELinux标签u:object_r:crash_dump_exec:s0。目前,当任何SELinux域执行具有此类标签的文件时,都会触发到crash_dump域的自动域转换。这也意味着在辅助向量(Auxiliary Vector)中设置了AT_SECURE标志,从而指示新进程的加载/链接器需要留意例如LD_PRELOAD这样的环境变量。

https://android.googlesource.com/platform/system/sepolicy/+/master/private/domain.te#1

domain_auto_trans(domain, crash_dump_exec, crash_dump);

在报告此错误时,crash_dump域具有以下SELinux策略:

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()连接到几乎任何域中的进程(如果DAC控件允许,则提供接管进程的能力),并允许它读取procfs中任何进程的属性。ptrace访问的排除列表列出了一些TCB进程。但值得注意的是,vold不在名单上。因此,如果我们可以执行crash_dump64,并以某种方式将代码注入其中,那么我们就可以接管vold。

请注意,实际ptrace()进程的能力仍由正常的Linux DAC检查控制,而crash_dump不能使用CAP_SYS_PTRACE或CAP_SETUID。即使一个普通的应用程序成功将代码注入crash_dump64,但由于UID不匹配,它仍然无法利用它来攻击系统组件。

如果你一直在仔细阅读,你现在可能想知道我们是否可以在我们的假/data文件系统上放置自己的二进制文件u:object_r:crash_dump_exec:s0并执行,从而在crash_dump域中获得代码执行。实际上,并不起作用。因为在装载USB存储设备时,vold会硬编码MS_NOSUID标志,这不仅会使得经典的setuid/setgid二进制文件执行被降级,还会使具有文件功能的文件执行被降级,而后者通常涉及自动SELinux域转换(除非SELinux策略通过授予PROCESS2__NOSUID_TRANSITION明确地选择退出此行为)。

要将代码注入crash_dump64,我们可以使用unshare()创建一个新的mount命名空间(使用CAP_SYS_ADMIN功能),然后调用pivot_root()将进程的根目录指向我们完全控制的目录,然后执行crash_dump64。然后内核解析crash_dump64的ELF头,读取链接器的路径(/system/bin/linker64),从该路径将链接器加载到内存中(所以我们可以在这里提供我们自己的链接器),并执行。

此时,就可以在crash_dump上下文中执行任意代码,并从那里提升到vold。在这时,Android的安全策略会认为我们具有与内核同等的权限。然而,如何又能获得内核中的代码执行呢?我们来继续分析。

十、从vold到init上下文

看起来,似乎没有一种简单的方法可以从vold进入到真正的init进程。但是,有一种进入init SELinux上下文的方法。我们查看SELinux策略,试图寻找一种允许转换到init上下文的策略,最终找到了以下策略:

https://android.googlesource.com/platform/system/sepolicy/+/master/private/kernel.te:

domain_auto_trans(kernel, init_exec, init)

这意味着,如果我们能够获得内核环境的代码执行,执行一个文件,控制该文件的标签为init_exec,并且在未使用MS_NOSUID装载的文件系统上执行,那么该文件就可以在init的上下文中执行。

在内核上下文中运行的唯一代码就是内核,因此,我们必须让内核为我们执行文件。在Linux中,有一种称为“Usermode Helpers”的机制可以做到这一点:在某些情况下,内核会将操作(例如:创建coredump、将密钥加载到内核、执行DNS查找等)委托给用户空间代码。特别是,当查找不存在的密钥时(例如通过request_key()函数),将会调用/sbin/request-key(硬编码,只能在内核创建时使用CONFIG_STATIC_USERMODEHELPER_PATH将其更改为不同的静态路径)。

在vold中,我们可以在没有MS_NOSUID的/sbin上安装自己的ext4文件系统,然后调用request_key(),内核将在init上下文中调用我们的请求密钥。

漏洞利用到此就结束了。下面的章节中描述了如何构建以获得内核中的代码执行。

十一、从init上下文到内核

从init上下文中,可以通过在显式请求域转换后执行适当标记的文件来转换到modprobe或vendor_modprobe上下文(需要注意的是,这里是domain_trans(),而不是domain_auto_trans(),这里是允许在exec上进行转换的):

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

modprobe和vendor_modprobe能够从适当标记的文件加载内核模块:

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现在就不需要内核模块的签名了:

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

因此,我们可以执行带有特定标签的文件,从而在modprobe上下文中执行代码,然后从那里加载带有特定标签的恶意内核模块。

十二、总结

值得注意的是,这次漏洞利用跨越了两个安全边界:从blkid_untrusted到vold的边界(由于vold使用blkid_untrusted提供的UUID而未检查其是否合法),以及从zygote到TCB的边界(通过滥用zygote的CAP_SYS_ADMIN功能)。作为开发者,必须意识到哪些位置是安全边界,哪些位置不是。并且,在标出安全边界后,必须要严格加强这些安全边界。

最后,分析整个漏洞利用的过程,其实是vold和blkid_untrusted之间的安全边界处产生了漏洞,并没有起到任何缓解攻击的作用。如果blkid代码在vold进程中运行,其实没有必要序列化其输出,这样一来,注入假的UUID就不会再起作用。

源链接

Hacking more

...