内核nf_tables漏洞--CVE-2024-1086 复现
字数 6040
更新时间 2026-03-23 12:12:09

CVE-2024-1086 漏洞分析与利用深度教学文档

概述

本漏洞位于Linux内核的nf_tables模块中,是一个由于输入验证不充分导致的Double Free漏洞,最终可通过“脏页目录”(Dirty Pagedirectory)技术实现本地权限提升。

第一部分:基础知识

1.1 nf_tables 架构简介

nf_tables 是 Netfilter 子系统的一部分,用于实现网络数据包过滤(如防火墙)。其核心数据结构为层次化组织:

  • Table(表): 最顶层结构,隶属于一个特定的“族”(family),如 ipip6inet,决定了其处理的数据包类型(IPv4、IPv6等)。
  • Chain(链): 组织在 Table 中,绑定到特定的 Netfilter Hook(钩子点)。当数据包经过内核的对应 Hook 时,所有绑定在此处的 Chain 都会被执行。
  • Rule(规则): 组织在 Chain 中,包含处理数据包的具体逻辑(如检查协议、端口等)和一个默认的裁定(Verdict)。
  • Expression(表达式): 规则的组成部分,是应用于数据包上的具体指令。例如,检查协议是否为UDP、检查端口是否为特定值等。每种表达式都与一个 struct nft_expr_ops 实例(函数跳转表)绑定。

1.2 Verdicts(判定值)

数据包经 Netfilter 处理后,会获得一个判定值来决定其最终命运。标准判定值包括:

  • NF_DROP (0): 静默丢弃数据包,停止处理。
  • NF_ACCEPT (1): 接受数据包,继续后续处理。
  • NF_STOLEN (2): 停止处理,数据包所有权移交 Hook 函数。
  • NF_QUEUE (3): 将数据包送入用户态队列。
  • NF_REPEAT (4): 重新调用当前 Hook。

Verdict 是一个 32 位无符号整数。高 16 位用于存储用户自定义的错误码,低 16 位为标准判定值。例如,设置 verdict0x000f0000 表示丢弃数据包(NF_DROP)并返回自定义错误码 0xf

第二部分:漏洞成因(CVE-2024-1086)

2.1 漏洞点

漏洞存在于 nft_immediate 表达式中。该表达式的作用是:如果规则匹配成功,就将一个值设置到 NFT_REG_VERDICT 寄存器中。
当用户传入的 verdict 值为 0xffff0000 时,nf_hook_slow() 函数中的 NF_DROP_GETERR(verdict) 宏会对其解析。由于 NF_DROP_GETERR(0xffff0000) 的结果为 1 (即 NF_ACCEPT),导致内核在判定数据包应被丢弃(NF_DROP)并执行一次释放(kfree_skb)操作后,误以为数据包应被接受NF_ACCEPT),从而让数据包继续进入正常的网络协议栈处理流程。这导致同一个数据包 (skb) 在内核协议栈处理完成后被再次释放,从而触发 Double Free。

核心漏洞链

  1. 用户通过 nf_tables 设置一条规则,其 verdict 被设为 0xffff0000
  2. 发送符合该规则的数据包,触发 Hook。
  3. 内核路径:nf_hook -> nf_hook_slow
  4. NF_DROP_GETERR(0xffff0000) 返回 NF_ACCEPT (1)。
  5. 内核先因低16位是 NF_DROP 而释放数据包,后又因误判为 NF_ACCEPT 而让数据包继续流转,最终被二次释放。

第三部分:利用原语与挑战

利用 Double Free 原语实现权限提升面临几个核心挑战,Exploit 采用了精妙的技术逐一化解。

3.1 绕过伙伴系统(Buddy System)的分配限制

目标是让被 Double Free 的页面(Order 4, 16个页面)最终能被分配为 PTE(Page Table Entry,页表项,Order 0,1个页面)。

  • 问题:Slab 分配器最大处理 0x2000 字节。大于此值的对象(如本漏洞中大小为 0x8000skb)会由页分配器(伙伴系统)处理,进入 Order >= 1 的空闲列表。而 PTE 通过 alloc_pages(GFP_KERNEL, 0) 分配 Order 0 的页面。两者空闲列表不同。
  • 解决方案排空 PCP 列表
    • PCP(Per-CPU Pageset)是伙伴系统的每CPU缓存。当某个 PCP 列表为空时,会调用 rmqueue_bulk() 从伙伴系统中申请页面进行填充。
    • 该函数会尝试从伙伴系统中分配指定数量(count)的、指定阶数(order)的页面。如果伙伴系统中只有更大阶数(如 Order 4)的页面,它会将其拆分以满足请求。
    • Exploit 通过大量喷射 PTE 对象,清空目标 CPU 的 PCP(Order 0)列表。
    • 随后,当 PCP 需要补充 Order 0 页面时,就会从伙伴系统中取出被 Double Free 的 Order 4 页面,并将其拆分成 16 个 Order 0 页面,其中一些就会被分配为 PTE。

3.2 绕过空闲链表硬化(FREELIST_HARDENED)与避免崩溃

传统 Double Free 会触发 Slab 分配器的检测机制导致内核崩溃。且第一次 free 后 skb 结构会被破坏,后续协议栈处理会读取到损坏字段而崩溃。

  • 解决方案利用 IP 分片队列
    • 发送一个设置了 IP_MF 标志的 IP 分片数据包。内核在收到所有分片前,会将已收到的分片放入 IP 分片队列维护,等待超时(ipfrag_time)或收到全部分片。
    • 第一次 Free:该分片包触发漏洞规则,被第一次释放。但由于它在分片队列中,并未进入常规协议栈流程,避免了因结构损坏导致的崩溃。
    • 堆占位:在数据包于分片队列中期间(即第一次 Free 后,第二次 Free 前),利用堆喷射技术(如释放大量预先分配的 UDP 包 skb)去占用被 Free 的页面,将其转换为“正常”对象。
    • 触发第二次 Free:向分片队列发送一个无效的输入(如错误的分片偏移),导致整个 IP 分片队列(包含我们占位后的 skb)在产生错误的 CPU 上被瞬间释放,完成一次“合法”的 Double Free。此方法不依赖 skb->len,稳定性高。

3.3 实现稳定的任意内存读写

即使获得了 Double Free 原语,也很难找到一个既与 PTE 在同一空闲链表,又允许被用户数据完全覆盖的对象来进行结构体重叠攻击。

  • 解决方案脏页目录(Dirty Pagedirectory)
    • 原理:利用 Double Free,将一个 PMD(Page Middle Directory,页中间目录)和一个 PTE 分配到同一个物理页面,即让 PMD 和 PTE 的页表项指向同一个物理页框
    • 实现
      1. 通过 mmap 创建两个独立的用户空间 VMA 区域,分别映射到进程页表的 PMD 层级和 PTE 层级。例如,区域 A 映射到 mm->pgd[1](PUD),区域 B 映射到 mm->pgd[0][1](PMD)。注意不要让它们在虚拟地址空间上冲突。
      2. 触发 Double Free 和 PCP 排空,使得后续内核为 PMD 和 PTE 分配页面时,有可能分配到被双重释放的同一个物理页。
      3. 通过访问区域 B(PMD 区域)的虚拟地址,内核会分配一个 PMD 页。由于 Double Free,这个 PMD 页可能与我们之前喷射的某个 PTE 页是同一个物理页。此时,mm->pgd[1] (PUD 项) 和 mm->pgd[0][1] (PMD 项) 指向了同一个物理页
    • 效果:此时,对 PMD 区域(用户空间页)的写入,会被内核解释为对 PTE 区域(页表项)的修改。具体来说:
      • 0x40000000 (假设是 PMD 区域的一个用户页) 写入一个值 X
      • 由于物理页重叠,这个值 X 实际上被写入了 mm->pgd[1][0][0] 这个 PTE 项的位置。
      • 这个值 X 可以被构造为一个合法的 PTE 项,包含目标物理页帧号(PFN)和权限位(如可读可写)。
      • 此时,通过读取 0x8000000000 (PUD 区域对应的用户页) 的地址,就能访问到 X 中 PFN 所指向的物理内存页
    • 能力:这实现了从用户空间任意设置 PTE,从而达成对内核任意物理内存的读写。这被称为“内核空间镜像攻击”(KSMA)。

3.4 提高堆喷成功率

页表(PUD, PMD, PTE)是按需分配的。分配一个 PUD 会同时分配其下级的 PMD 和 PTE,它们都从同一个空闲链表(Order 0)分配。

  • 策略:为了减少噪声,提高在重叠页面中分配出 PMD 和 PTE 的成功率,Exploit 选择喷射 PMD+PTE 的组合,而不是单独喷射 PUD 或 PMD。这平衡了成功率和分配复杂性。

3.5 刷新 TLB

修改 PTE 后,必须刷新 TLB(转换后备缓冲器)才能使新映射生效。Exploit 采用了一种高效可靠的用户空间触发方法:

  1. mmap 创建 PMD 和 PTE 内存区域时,使用 MAP_SHARED 标志。
  2. fork() 创建子进程。
  3. 在子进程中,对共享内存区域执行 munmap()。这会触发内核刷新相关 TLB 条目。
  4. 让子进程进入睡眠状态,避免其退出导致资源被回收,从而保持利用的稳定性。

3.6 定位内核与目标地址

获取物理内核基地址

  • 利用内核特性:如果开启了 CONFIG_RELOCATABLE(通常为了 KASLR),物理内核基地址会对齐到 CONFIG_PHYSICAL_START(通常是 0x100000,即 16MiB)。
  • 假设系统有 8GiB 物理内存,则只需要扫描 8GiB / 16MiB = 512 个可能的对齐地址。
  • 利用 Dirty Pagedirectory,一次 PTE 覆盖可以映射 512 个物理页(因为一个 PMD 包含 512 个 PTE)。因此,仅需一次覆盖操作即可扫描全部 512 个候选地址,通过检查每页开头特定字节的特征(如内核魔术字)来确定基址。

获取目标物理地址(如 modprobe_path

  • 找到内核基址后,在其后约 80MiB 的内核物理内存区域内,扫描特定数据特征(如字符串 /sbin/modprobe 后跟 \x00 填充至 256 字节)。
  • 在 8GiB 内存系统上,这大约需要 1 + 80MiB/2MiB ≈ 40 次 PTE 覆盖操作。

第四部分:完整利用流程

  1. 环境准备:通过 fork 让子进程执行提权操作,子进程完成后睡眠,防止内核回收损坏资源导致系统崩溃。
  2. 配置 nftables
    • 创建 table、chain、rule。
    • 关键:设置规则的 verdict0xffff0000
    • 通过 mnl (libmnl) 套接字与内核 netfilter 通信,以原子批处理方式提交规则。
  3. 预分配对象:预先分配一些 skb 对象(如发送 UDP 包到本地套接字但不接收),以减少分配器噪声,提高稳定性。
  4. 触发第一次 Free
    • 发送一个大小超过 0x2000(例如 32768 字节,Order 4)的 IP 分片数据包,并设置 IP_MF 标志。
    • 该数据包匹配漏洞规则,触发 Double Free 漏洞的第一阶段:被识别为 NF_DROP 而释放,进入伙伴系统 Order 4 空闲链表;同时又被误判为 NF_ACCEPT
  5. 堆占位:释放之前预分配的 UDP 包 skb,尝试占用第一次 Free 后产生的空闲页面,为第二次 Free 做准备。
  6. 喷射 PTE:通过访问之前 mmap 的 VMA 区域,触发大量 PTE 页分配,旨在排空 PCP 列表,并希望有 PTE 分配到被 Double Free 的 Order 4 页面拆分后的 Order 0 页面上。
  7. 触发第二次 Free 并分配 PMD
    • 发送一个大小不同的、带有特定错误选项的 IP 数据包,触发 IP 分片队列的无效输入错误处理。
    • 这会导致内核释放整个 IP 分片队列中的所有 skb,完成第二次 Free。
    • 由于 PCP 列表已被排空,内核会从伙伴系统申请 Order 0 页面来填充 PCP,这有可能从被 Double Free 的 Order 4 页面中拆分出页面。此时,如果访问 PMD 区域的 VMA,内核可能会分配一个 PMD 页,且该页与之前喷射的某个 PTE 页共享同一物理页,实现 PMD/PTE 重叠。
  8. 查找重叠的 PTE:遍历所有喷射的 PTE 区域,检查其中 PTE 条目的值。未被修改的是原始值,而被 PMD 数据覆盖的那个 PTE 区域,其 PTE 条目值会发生变化,据此可定位到重叠的页面。
  9. 扫描物理内存
    • 利用找到的重叠页面,通过 Dirty Pagedirectory 技术,将候选的物理内核基地址构造为 PTE 值,写入 PMD 区域。
    • 通过读取 PUD 区域对应的虚拟地址,来检查该物理地址的内容,通过与内核特征比对,找到真正的物理内核基地址。
    • 以类似方法,在内核物理内存区域中扫描 modprobe_path 字符串的特征,找到其物理地址。
  10. 覆盖 modprobe_path
    • 将找到的 modprobe_path 物理地址映射到用户空间可写的虚拟地址上。
    • modprobe_path 的内容覆盖为指向提权脚本的路径,例如 /proc/<exploit_pid>/fd/<script_fd>
  11. 触发提权
    • 执行一个无效的可执行文件(例如通过 memfd_create 创建一个文件并执行)。
    • 内核因无法识别格式,会尝试调用被修改的 modprobe_path 指定的程序(即我们的提权脚本)。
    • 提权脚本以 root 权限执行,完成权限提升(如修改 /etc/passwd 或提供 root shell)。
相似文章
相似文章
 全屏