570.86.15 的 NVIDIA 开源 Linux 驱动中,一个任务栈上的线程状态节点会被登记进全局红黑树。若内核异常打断正常清理,任务栈后来被回收,而树仍保存旧地址,空指针故障便可能留下释放后使用(use-after-free,UAF)的条件。
关键不只是触发 Oops,而是追踪异常前后的对象寿命、虚拟地址复用和树操作。以下以 Ubuntu Noble、6.11.0-24-generic、T550 Laptop GPU 的历史记录为环境依据,结合固定版本源码与纯数据模型;没有在当前机器加载驱动或重跑内核提权链。
范围与证据边界
| 材料 | 能支持的结论 | 边界 |
|---|---|---|
| 570.86.15 与 570.195.03 发布源码 | 注册、清理和空指针检查的差异 | 发布快照对比,不等于逐个修复提交 |
| 上游 Linux v6.11 | 栈缓存、延迟回收与 vmalloc 池的实现 | 不冒充 Ubuntu 发行版完整构建 |
| 历史终端记录 | 当时的驱动、GPU 与权限结果 | 不作为当前版本的复现结果 |
| 离线模型 | 布局、阈值、指针写入和相对距离 | 不衡量内核竞态成功率 |
NVIDIA 2025 年 10 月公告 将 CVE-2025-23280 列为 Linux 驱动 UAF,评分 7.0;同一公告还有 CVE-2025-23300、CVE-2025-23330 等空指针问题。公告未给出逐函数映射,因此不把其中任一编号直接等同于下述全部调用链。公告将 Robin Bastide 列为 CVE-2025-23280 的报告者。
全局树为什么还握着栈地址
flowchart TD accTitle: 线程状态节点的寿命分叉 accDescr: 正常路径移除节点后返回,异常路径可能跳过清理,留下指向最终回收任务栈的全局引用。 A["栈上 THREAD_STATE_NODE"] --> B["threadStateInit:插入全局树"] B --> C["执行内存操作"] C --> D["正常返回:threadStateFree"] D --> E["从树移除,再结束栈帧"] C --> F["Oops:正常清理被打断"] F --> G["任务终止,栈最终回收"] G --> H["树仍引用旧虚拟地址"]
dupMemory 在栈上声明 THREAD_STATE_NODE,先调用 threadStateInit,再取得 GPU 操作相关锁。注册函数使用树自己的自旋锁插入节点,返回前已经释放这把锁;GPU 操作后来遗留的锁与树锁是不同对象。正常出口会调用 threadStateFree,异常终止则可能绕过这条出口。
外部映射路径经过 nvUvmInterfaceDupMemory 进入该函数。NoDeviceMemory 创建 ADDR_SYSMEM 描述符时允许 pGpu == NULL;旧版 IOMMU 判断没有排除这种对象。若执行走到 memdescMapIommu 的已分配系统内存检查,代码会继续把空 GPU 指针交给 DMA 地址范围查询:
OBJGPU *pGpu = pMemDesc->pGpu;
RmPhysAddr dmaWindowEndAddr = gpuGetDmaEndAddress_HAL(pGpu);gpuGetDmaEndAddress_HAL 随后进入物理地址宽度查询,其分发函数访问 pGpu 中的函数指针。这是源码中可定位的空指针风险,不应凭相邻调用名猜测故障点:同版 gpumgrCheckIndirectPeer_IMPL 在 x86 路径直接返回 NV_FALSE,其使用远端 GPU 字段的分支属于 PPC64LE。
释放不等于立即复用
flowchart TD
accTitle: 任务栈从存活到可复用
accDescr: 栈缓存、RCU 和虚拟区间的延迟回收都会影响旧地址何时可用于后续分配。
A["任务栈不再使用"] --> B{"进入每 CPU 栈缓存?"}
B -->|"是"| C["保留给栈复用"]
B -->|"否"| D["RCU 延迟释放"]
D --> E{"回调再次尝试缓存"}
E -->|"成功"| C
E -->|"未命中"| F["vfree 与 lazy 回收"]
F --> G["purge 后进入尺寸池或全局空闲管理"]
这里首先争夺的是 vmalloc 虚拟地址区间,而不是某个 slab 类型,也不是物理连续内存。上游 v6.11 的 kernel/fork.c 为每个 CPU 缓存 2 个栈;延迟释放回调还会再次尝试缓存。因此,任务退出、实际 vfree 和区间重新可分配是三个不同事件。
mm/vmalloc.c 又将延迟回收与尺寸池分开。小请求能否进入靠前空洞、大请求能否跨过小空洞,受可用区间、对齐和池状态共同影响;仅用“最低地址优先”的简图会漏掉这些前提。
def lazy_page_threshold(online_cpus, page_size=4096):
if online_cpus <= 0 or page_size <= 0:
raise ValueError("positive parameters required")
return online_cpus.bit_length() * (32 * 1024 * 1024 // page_size)
assert lazy_page_threshold(1) == 8192
assert lazy_page_threshold(8) == 32768上游 v6.11 的阈值是 fls(online_cpus) × (32 MiB / PAGE_SIZE)。4 KiB 页、8 个在线 CPU 对应 32768 页,即 128 MiB。计数必须大于阈值才会在该分支调度清理工作,达到阈值不等于同步完成 purge;其他路径也可能推进回收。
两种分配如何连接用户态与树
视频缓冲区的价值在于共享映射:v6.11 的 videobuf2-vmalloc 用 vmalloc_user 分配,再通过 remap_vmalloc_range 建立用户映射。同一后端页面因此可被内核树操作与用户态观察共同涉及。
这不是任意视频设备都具备的能力。设备必须实际选用该内存后端,进程需要访问权,并且缓冲区大小、数量及映射方式要符合设备约束。
| 阶段 | 保留对象与变化 | 必须确认的条件 |
|---|---|---|
| 建立间隔 | 保留任务栈,分隔可用空洞 | 小请求和大请求面对的区间不同 |
| 标记位置 | 标记任务、缓冲区与待回收栈并存 | 相对位置来自观测,而非示意图 |
| 推进回收 | 释放候选区域,等待缓存与 purge | 异常前后可用的驱动路径可能不同 |
| 重占区间 | 缓冲区覆盖原节点所在地址 | 全局树的旧指针确实落入共享映射 |
用于维持间隔的“保留栈”是仍被占用的真实分配,与 VMAP_STACK 检测越界的未映射 guard page 不是同一概念。历史链在 Oops 后还依赖设备打开路径触发树操作,而不是假设所有 ioctl 都继续可用。
从子指针变化到地址校准
| 偏移 | 名称 | 类型 | 大小 |
|---|---|---|---|
| 0x00 | key | NvU64 | 8 |
| 0x08 | pParent | MapNode * | 8 |
| 0x10 | pLeft | MapNode * | 8 |
| 0x18 | pRight | MapNode * | 8 |
| 0x20 | bIsRed | NvBool | 1 |
| 0x21 | padding | 7 |
THREAD_STATE_NODE 内嵌上述 MapNode。NvBool 为 1 字节;在此 64 位、非 checked 模型中,父指针位于 +8,左右子指针分别为 +16、+24,整体含尾部填充共 40 字节。checked 构建还可能追加字段,这不是整个线程状态结构的布局。
悬空节点所在区域若被共享缓冲区覆盖,后来插入的栈节点可能使其中的子指针短暂出现新任务栈地址。观察到的是容器字段变化,尚不是内核映像基址;节点从插入到移除的可见窗口也很短。
进一步校准需要把一个候选内核地址与用户映射中实际改变的偏移关联起来。若写入地址为 A,观测偏移为 o,则缓冲区基址满足 B = A - o。例如纯算术示例 0x1000e000 - 0xe000 = 0x10000000;这些数值不是历史机器地址,也不是跨构建偏移。
右旋只给出受限指针写入
MapNode *y = x->pLeft;
x->pLeft = y->pRight;
if (y->pRight)
y->pRight->pParent = x;这段来自 _mapRotateRight。正常情况下,它只是修复旋转后的父子关系;风险来自已失去寿命保证、又被外部内容替换的节点,而不是右旋算法本身。
flowchart TD accTitle: 右旋前 accDescr: X 的左孩子为 Y,Y 的孩子为 A 与 B。 X["X"] --> Y["Y"] Y --> A["A"] Y --> B["B"]
flowchart TD accTitle: 右旋后 accDescr: Y 成为局部根,X 是其右孩子,B 的父节点变为 X。 Y["Y"] --> A["A"] Y --> X["X"] X --> B["B"]
def model_parent_update(memory, right_node_address, x_address):
memory[right_node_address + 8] = x_address
mem = {}
model_parent_update(mem, 0x2000, 0x8000)
assert mem == {0x2008: 0x8000}模型只验证赋值的数据流:写入位置是所选节点地址加父字段偏移,值是 x 的地址。控制这类指针,并不意味着能直接写入任意 64 位常数。真实树操作还会读写其他链接,并受键值次序、颜色及父子关系约束。
相对距离固定,竞态仍在
系统调用入口的随机栈偏移会移动本次调用链。对固定二进制与固定路径,节点和某个保存槽位可能一起移动,二者距离保持一致;更换编译器、配置或调用路径后,这个距离需要重新核对。
历史方案通过插入修正中的重复重新着色增加处理步骤,将过程分成“出现节点地址—延长窗口—旋转写入”。它延长窗口,而非消除竞态;额外 GPU 调用、任务创建和系统负载都会改变时序。
| 层次 | 需要成立的条件 |
|---|---|
| 栈槽位 | 对应构建中准确的寄存器保存位置 |
| 替代 file 对象 | 结构布局、标志与引用计数满足后续使用 |
| 地址判定 | 文件操作表比较确实构成可观察结果 |
| 读写能力 | 有效操作入口与调用参数相互匹配 |
因此,覆盖保存的 file 指针与获得完整内核读写能力之间仍有距离。文件类型检查中对 f_op 的比较,只能在具体路径和返回行为下构成地址线索,不是“控制对象即任意调用”的通用规则。
历史终端的阶段输出如下。完整记录共 105 帧,均已检查;主机标识、无关组列表及后续地址细节不在这里展示。
Triggering oops
Hopefully got in control of the UAF
Searching for UAF ...最终 id 结果仅摘录 UID/GID 字段,其后的组列表省略:
$ id
uid=0(root) gid=0(root)这证明该记录展示过 root 结果,不证明当前驱动仍存在同样行为,也不提供成功率。当前验证仅执行布局和算术模型:
MapNode x64: key=0 parent=8 left=16 right=24 red=32 size=40
lifecycle=4 threshold=8 boundary=8 rotation=3 shared-shift=4 calibration=1: PASS
lazy_pages(1)=8192; lazy_pages(8)=32768; scheduling requires > threshold修复要同时覆盖入口与寿命
对比 570.86.15 与 570.195.03,dupMemory 有两组关键变化:对 deviceless 对象增加目标明确的空指针检查;改用 threadStateAlloc 分配状态节点。以下是只保留寿命相关调用的对比摘要,省略中间操作,不是可直接应用的补丁。
新实现通过非分页堆分配创建节点,并让 threadStateFree 识别堆对象后释放。即使异常仍然跳过清理,节点也不再随着任务栈回收而自动变成悬空引用;但节点泄漏、遗留锁和其他半完成状态仍需单独处理。旧 threadStateInit API 还存在,所以新增 API 不等于所有调用点都已迁移。
| 分支 | 修复版本 |
|---|---|
| R580 | 580.95.05 |
| R570 | 570.195.03 |
| R535 | 535.274.02 |
这些是该公告的历史修复边界,适用于表列 Linux 显示驱动产品;不要与 Windows 或 vGPU 管理器版本混用。维护时应选择硬件和发行版支持的、包含修复的更新。
审查同类问题时,优先找出异常发生前已经登记到全局的数据、持有的锁以及清理责任。核心不变量始终是:全局容器中的引用,必须在其对象寿命结束前移除;单独修掉崩溃指令并不替代这个保证。