本文是OATmeal on the Universal Cereal Bus: Exploiting Android phones over USB的翻译文章。

前言

最近,围绕智能手机的物理攻击主题引起了一些关注,其中攻击者能通过usb设备连接到被锁定的手机并访问存储在设备上的数据。 此文章描述了如何针对Android设备执行此类攻击(使用Pixel 2进行测试)。

Android手机启动并解锁后(在较新的设备上,使用“解锁所有功能和数据”;在旧设备上,使用“启动Android,输入密码”),它保留了用于解密内核内存中文件的加密密钥,即使屏幕被锁定,加密文件系统区域或分区仍然可访问。因此,攻击者在有足够权限的上下文中能获得在锁定设备上执行代码的能力,而且不仅可以取得设备后门,还可以直接访问用户数据。
(警告:当具有工作资料的用户切换工作资料时,我们没有研究工作资料数据会发生什么。)

本博文中引用的错误报告以及相应的概念验证代码可在以下位置获得:
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(在补丁级别2018-08-01处修复)和CVE-2018-9488(在补丁级别2018-09-01处修复)。

攻击面(The attack surface)

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

本文主要关注U盘,安装不受信任的U盘可在高权限系统组件中提供非常重要的攻击面:内核必须使用包含SCSI子集的协议与USB大容量存储设备通信,解析其分区表,并使用内核的文件系统实现来解释分区内容;用户空间代码必须标识文件系统类型并指示内核将设备安装到某个位置。在Android上,用户空间实现主要是在vold中(其中一个进程被认为具有内核等效权限),例如,它使用限制性SELinux域中的单独进程来确定U盘上分区的文件系统类型。

BUG(第一部分):确定分区属性

在插入U盘后,vold确定了设备上的分区列表,它会试图识别每个分区的三个属性:Label(描述分区用户可读的字符串),UUID(可用于确定U盘是否是之前已插入设备的唯一标识符),和文件系统类型。
在现代GPT分区方案中,这些属性大多可以存储自分区表本身中,然而,U盘更倾向于使用MBR分区方案,而不能存储UUID和标签。对于普通U盘,Android支持MBR分区方案和GPT分区方案。

为了提供标记分区并为其分配UUID的能力,即使使用MBR分区方案,文件系统也实现了一个hack:文件系统头包含这些属性的字段,允许已确定文件系统类型的实现,并知道特定文件系统的头布局以特定的方式提取此信息。当vold想要确定标签,UUID和文件系统类型时,它会调用blkid_untrusted SELinux域中的/system/bin/blkid,它正是这样做的:首先,它尝试使用magic来识别文件系统类型,并(尝试失败)一些启发式,然后,它提取标签和UUID。
它以下列格式将结果打印到stdout:

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

但是,Android使用的blkid版本没有转义标签字符串,负责解析blkid输出的代码仅扫描第一次出现的UUID =“和TYPE =”。 因此,通过创建具有精心制作标签的分区,可以获得对UUID的控制并返回返回到vold的字符串,否则它将始终是有效的UUID字符串和一组固定类型字符串之一。

BUG(第二部分):挂载文件系统

当vold确定新插入的带有MBR分区表的U盘包含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文件系统的U盘插入锁定的手机中,手机会将该U盘挂载到/mnt/##。

然而,这种直接的攻击实施有几个严重的局限性; 其中一些可以克服:

利用:Chameleonic USB大容量存储

如上一节所述,FAT文件系统标签限制为11个字节。 blkid支持一系列具有明显更长标签字符串的其他文件系统类型,但是如果你使用了这样的文件系统类型,那么你必须通过fsck检查vfat文件系统,并在安装时由内核执行文件系统头检查。 一个vfat文件系统。 vfat内核文件系统在分区的开头不需要固定的magic值,所以理论上这可能会以某种方式工作; 但是,由于FAT文件系统头中的几个值对内核来说很重要,同时,blkid也会对superblocks执行一些常规检查,所以PoC采用不同的路径。

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

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

由于Linux内置支持设备端USB,因此在实践中实现起来相对简单。 Andrzej Pietrasiewicz的演讲“制作你自己的USB gadget”是对该主题的有用介绍。 基本上,内核附带了设备端USB大容量存储,HID设备,以太网适配器等的实现; 使用相对简单的基于伪文件系统的配置界面,就可以配置一个复合小工具,为连接的设备提供一个或多个这些功能(可能有多个实例)。 你需要的硬件是运行Linux并支持设备端USB的系统; 为了测试这次攻击,使用了Raspberry Pi Zero W.

f_mass_storage gadget的功能旨在使用普通文件作为后备存储; 为了能够以交互方式响应来自Android手机的请求,使用FUSE文件系统作为后备存储,用direct_io选项/FOPEN_DIRECT_IO标志来确保我们自己的内核不会添加不需要的缓存。

此时,已经能实施可窃取的攻击了,例如存储在外部存储器上的照片。 幸运的是,对于攻击者,在安装U盘后立即启动com.android.externalstorage/.MountReceiver,这是一个SELinux域允许访问USB设备的进程。 因此,在通过/data挂载恶意FAT分区后(使用标签字符串'UUID =“../../ data'),关闭具有适当SELinux上下文和组成员资格的子进程,以允许访问USB设备 然后,这个子进程从/data/dalvik-cache/加载字节码,允许我们控制com.android.externalstorage,它具有泄露外部存储内容的必要权限。

但是,对于不仅要访问照片的攻击者,还要访问存储在设备上的聊天日志或身份验证凭据等攻击者,这种访问级别根本就不够。

处理SELinux:触发两次bug

此时的主要限制因素是,即使可以挂载/data,也不允许在设备上运行的许多高权限代码访问已挂载的文件系统。 但是,一个高权限服务确实可以访问它: vold。

vold实际上支持两种类型的U盘,PublicVolume和PrivateVolume。 到目前为止,这篇博文主要关注PublicVolume; 从这里开始,PrivateVolume变得很重要。PrivateVolume是必须使用GUID分区表格式化的U盘。 它必须包含类型为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实例中使用ext4,我们可以控制文件系统上的DAC所有权和权限; 并感谢PrivateVolume集成到系统中的方式,让我们甚至可以控制该文件系统上的SELinux标签。

总之,此时,我们可以在/data上安装受控文件系统,具有任意文件权限和任意SELinux上下文。 因为我们控制了文件权限和SELinux上下文,所以可以允许任何进程访问文件系统上的文件 - 包括使用PROT_EXEC映射它们。

注入zygote进程

zygote进程的功能相对来说很强大,虽然它未被列为TCB的一部分。根据设计,它以UID 0运行,可以任意改变其UID,并可以执行动态SELinux转换到system_server和普通应用程序的SELinux上下文。换句话说,zygote可以访问设备上几乎所有的用户数据。

当64位zygote在系统boot时启动时,它会从/data/dalvik-cache/arm64/system@framework@boot*.{art,oat,vdex}加载代码。 通常,oat文件(包含将使用dlopen()加载的ELF库)和vdex文件是不可变/系统分区上文件的符号链接; 只有art文件实际存储在/data上。
但我们可以将system@[email protected]system@[email protected]符号链接转换为/system(在不知道设备上正在运行哪个Android版本的情况下绕过一些一致性检查)并同时将我们自己的恶意ELF库放在system@[email protected]里(使用合法的oat文件所具有的SELinux上下文)。
然后,通过在我们的ELF库中放置一个带__attribute __((constructor))的函数,我们可以在启动时调用dlopen()后立即在zygote中执行代码。

这里没有讲到的一步是,当执行攻击时,zygote已经在运行了,而且这种攻击只有在zygote启动时才有效。

崩溃系统

这部分有点不愉快。

当关键系统组件(特别是zygote或system_server)崩溃时(你可以使用kill在eng版本上模拟),Android会尝试重新启动大部分用户空间进程(包括zygote)尝试从崩溃中恢复。 发生这种情况时,屏幕会先显示一点启动动画,然后锁定屏幕,并提示“解锁所有功能和数据”,通常只在启动后显示。 但是,此时仍然存在用于访问用户数据的密钥材料,因为你可以通过在设备上运行“ls/sdcard”来验证ADB是否已打开。

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

当然,在/data上挂载我们自己的文件系统是非常粗糙的,会导致各种崩溃,但令人惊讶的是,系统不会立即崩溃 - 虽然部分UI变得无法使用,但大多数地方都有一些错误处理可以防止系统崩溃,从而导致重新启动。
经过一些实验,发现Android的跟踪带宽利用代码有一个安全检查:如果网络跟踪利用代码无法写入磁盘,并且自上次成功写入以来已观察到>=2MiB(mPersistThresholdBytes)的网络流量, 则抛出致命异常。这意味着如果我们可以创建某种类型的网络连接到设备然后发送>= 2MiB值的ping包,然后通过等待定期回写或更改网络接口状态来触发统计信息回写,设备将重新启动。

要创建网络连接,有两个选项:

为了测试漏洞利用,我们使用手动创建wifi网络连接;为了更可靠和用户友好的漏洞利用,你也许希望使用以太网连接。

此时,我们可以在zygote上下文中运行任意本机代码并访问用户数据; 但是我们还不能读出原始磁盘加密密钥,直接访问底层块设备或进行RAM转储(尽管由于系统崩溃,在RAM转储中的一半数据可能已经消失了)。 但如果我们希望能够做到这些,将不得不进行提权。

从zygote到vold

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

在最近的Android版本中,用于创建崩溃进程转储的机制已更改:
进程执行/system/bin/crash_dump64/system/bin/crash_dump32中的一个帮助程序,而不是要求特权守护程序创建转储,它们具有SELinux标签u:object_r:crash_dump_exec:s0。 目前,当任何SELinux域执行具有此类标签的文件时,会触发到crash_dump域的自动域转换(这自动意味着在辅助向量中设置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不匹配,它仍然无法利用它来攻击系统组件。

如果你一直在仔细阅读,你现在可能想知道我们是否可以在我们的假/数据文件系统上放置我们自己的二进制文件u:object_r:crash_dump_exec:s0,然后执行在crash_dump域中获得代码执行。这不起作用,因为vold - 非常明智 - 在安装USB存储设备时硬编码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,从而危及TCB。 此时,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(),它允许在exec上进行转换,而不是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而不检查它是否类似于有效的UUID时)以及从zygote到TCB的边界(通过利用zygote的CAP_SYS_ADMIN功能)。非常正确地说,软件供应商一直在强调,安全研究人员必须意识到什么是安全边界,什么不是安全边界 - 但供应商选择决定他们想要拥有安全边界的位置,然后严格执行这些边界也很重要。未强制安全边界的使用是有限的 - 例如,作为一种开发辅助,同时更强的隔离正在开发中 - 但它们也可能通过混淆组件对整个系统安全性的重要性而产生负面影响。

在这种情况下,vold和blkid_untrusted之间的弱强制安全边界实际上导致了漏洞,而不是减轻漏洞。 如果blkid代码在vold进程中运行,则没有必要序列化其输出,并且注入假的UUID将不起作用。

源链接

Hacking more

...