资讯详情

GPU驱动开发安全边界:KMD权限提升与UMD不可信防护

📅 2026/10/11 2:41:39 | 华诺云谱 👁 阅读
GPU驱动开发安全边界:KMD权限提升与UMD不可信防护
做 GPU KMD 的开发者几乎没有人敢说没跟安全边界打过架。很多人一开始学 UMD、KMD 和 DDK 的协作关系第一反应是“接口怎么调、回调怎么注册、队列怎么提交”等真遇到线上崩溃、安全评审、或者别人用边界问题把你堵到墙角时才意识到那只是冰山一角。这一篇是系列 1.2 的下篇单独把最绕不开的部分抽出来讲权限提升与安全边界。这里的“权限提升”不是攻防文章里那种钻漏洞的提权而是 UMD 把请求合法地送进内核、由 KMD 以更高权限执行的过程。怎么保证这个过程不越权、不崩溃、不成为系统后门才是 KMD 开发的基本功。这篇文章适合正在学 KMD 驱动开发的人也适合做图形栈安全评审或性能边界排查的人。读完你会发现DDK 真正提供的不是一堆“能跑就行”的接口而是一套必须遵守的调用契约KMD 要做的也不是“尽量执行用户态的命令”而是“在每一个入口都默认用户态不可信”。1. 先理清协作链路谁在提权、提的是什么权1.1 UMD、KMD、DDK 的分工先讲一个很多初学者问过的问题既然 UMD 也是驱动为什么不做成内核态的答案不是技术做不到而是安全模型不允许。UMD 会被加载到应用进程里而应用进程对系统来说是不可信的。如果让不可信代码直接拥有内核能力等同于给每一个普通程序发一张万能通行证这显然不是图形系统该有的姿态。这三者的分工很清晰角色运行位置信任级别核心职责UMD用户态随应用进程加载半可信默认不可信把 API 调用翻译成具体的资源分配、状态设置和命令提交KMD内核态由系统管理加载完全可信系统安全边界内的守门人校验请求、管理 GPU 对象、提交硬件命令、处理中断DDK开发阶段静态使用不参与运行契约文本提供 DDI 回调定义、数据结构、构建与调试工具链UMD 负责“翻译”KMD 负责“执行”DDK 负责“约定”。翻译得再像样最后都要过 KMD 这一关。可以打一个比方UMD 是发货方把货物按清单装进集装箱KMD 是港口安检员开箱检查后才允许装船。如果安检员只核对了箱单上的数字不看箱子里实际是什么迟早会出问题。1.2 一次合法提权调用的完整路径一次常规图形调用的路径大概是这样应用调用图形 API比如“画一帧”。UMD 在用户态完成参数拆解分配或复用资源生成一组命令缓冲区。UMD 通过系统服务进入内核这时的 CPU 特权级发生切换也就是所谓“权限提升”的瞬间。系统图形调度层提取 UMD 的请求把它转交给对应的 KMD 回调。KMD 对请求做层层校验然后把它送入 GPU 硬件队列。GPU 执行完成后通过中断或同步机制通知 KMDKMD 再返回完成状态给 UMD。注意第 3 步和第 5 步的区别。第 3 步只是“借道”请求的执行上下文从用户态切换到了内核态但并不是说 UMD 变成了 KMD。真正干活的是 KMD它在这个时刻拥有了高权限的执行环境。第 5 步才是安全边界起效的地方KMD 必须假设自己收到的每一份请求都可能被篡改过。权限提升在这里的含义是“代码在一个高特权环境下运行”而不是“某个用户获得了管理员身份”。KMD 的每个入口函数都在做这件事代用户态代码进入内核态执行。也正因为如此同一个进程上一次合法操作通过不代表下一次请求也合法。很多漏洞就出在开发者把“上次检查过了”当成“这次检查过了”。2. 安全边界到底拦了什么2.1 边界之一入口参数校验KMD 回调拿到的不论是结构体指针、长度字段还是标志位都不能直接信任。应用进程自己构造的参数可能因为内存被改写、版本升级后结构体变长、或者单纯是程序员手误而变得完全不合逻辑。常见的入口校验必须覆盖这些点长度字段是否小于结构体本身。结构体版本是否匹配当前 DDI 版本。标志位是否包含未知枚举值。对齐方式是否满足硬件要求。缓冲区指针是否为空是否明显越界。拿长度来说很多崩溃不是因为缓冲区真的不够大而是因为 KMD 在回调入口处用了一个“差不多够用”的校验值。比如结构体加了一个新字段UMD 端已经按新格式填充KMD 端还在按旧格式解析后面所有偏移量就全错了。DDK 的价值就在这里它把结构体布局、回调数量、版本号都固化成契约KMD 升级时不能只改一侧。我总结过一个非常朴素的习惯参数校验宁可多不能少。多校验一次不产生功能问题少校验一次可能直接引起系统崩溃。2.2 边界之二句柄权限与生命周期GPU 资源在内核态里通常不是一个裸指针而是一个句柄或对象 ID。UMD 拿到的只是一个不透明的数值它不应该能猜出这个数值背后对应哪个内核对象。句柄机制的三个关键点第一句柄必须经过查表解析。KMD 内部维护一张对象表每个对象记录类型、状态、引用计数、所属进程、访问掩码。UMD 传来的句柄必须能在表里找到一个有效项类型还必须匹配当前接口的预期。第二访问权限必须单独检查。就算句柄存在如果调用者只有读取权限却发来一个写操作请求KMD 必须拒绝。句柄本身不代表“什么都能做”。第三引用计数必须非常精确。GPU 是异步设备提交到硬件队列的命令可能还在执行此时资源如果被释放就会出现“用户态以为没事、内核态已经释放、GPU 还在读”的典型问题。KMD 在入队时要给资源增加引用在完成点之后才能释放。少加一次引用就是 use-after-free多加一次就是资源泄漏。这里也顺带解释一下为什么不透明句柄比直接传地址安全。如果一个接口允许 UMD 传任意内存地址攻击者只需要改地址就能访问内核数据。句柄表的存在让“地址”变成了“凭据”而凭据是可以查证、可以撤销、可以限定范围的。2.3 边界之三GPU 命令缓冲区并非“免检区”很多人以为 UMD 提交的命令缓冲区只是“给硬件看的”KMD 做个转发就行。这是最危险的想法。命令缓冲区是 CPU 与 GPU 之间的共享数据它本质上是一段用户态可控的内容。GPU 会按照命令缓冲区里的指令去读写显存、跳转地址、触发 DMA所以必须把命令缓冲区当成输入安全检查的中心对象。KMD 在把命令缓冲区交给 GPU 之前至少要检查命令类型是否在支持范围内。每个命令的长度是否和剩余空间一致。命令里引用的资源是不是已经分配且属于当前上下文。命令里出现的地址是不是被限定在当前 GPU 上下文可见的地址空间内。不能允许 UMD 直接填写物理地址或内核地址。如果命令缓冲区里允许一条“把数据写到指定地址”的命令而这个地址没有经过资源映射校验就相当于用户态可以驱动 GPU 对任意内存发起 DMA 写操作。这是图形驱动安全里最不能碰的红线。生活化一点说命令缓冲区就像一个快递包裹外层写的是“易碎品”但快递员不能只看标签就扔上车。KMD 得拆开抽查确认里面不是易爆物。2.4 边界之四地址空间与 DMA 隔离最后一个边界也是现代 GPU 驱动里越来越重要的一层地址空间隔离。GPU 有自己的页表机制通过 IOMMU 或 GPU MMU把设备访问的地址映射到真实的物理内存。这个机制的好处是GPU 不需要也不应该知道完整物理内存布局。KMD 在创建上下文时会为每个进程或每类上下文准备一张独立的 GPU 地址空间保证不同应用之间的显存不会互相污染。KMD 要做的关键操作包括为 GPU 资源分配地址空间范围时记录该范围属于哪个对象。在提交命令前验证目标地址落在已分配的范围内。上下文切换时刷新 GPU TLB防止上一个进程的页表残留影响到下一个进程。对 DMA 操作使用重映射避免直接暴露物理地址。很多稳定性问题表面上看是“显存接错了”实际是 TLB 没有刷干净。开发者在调试时经常遇到同一个地址在两颗 GPU 上行为不一致就是因为不同硬件对页表的缓存粒度不同。把地址空间隔离做好不只是安全问题也是可移植性问题。这四个边界合在一起构成了 KMD 对 UMD 的完整态度参数不可信、句柄不可猜、命令不可直接执行、地址不可直达物理内存。谁把这个态度丢掉了谁就是在给安全边界开门。3. 实操示例把权限校验点落到 KMD 代码里这一节我用一个模拟的显存分配与命令提交流程把上面的边界概念变成代码。示例刻意精简但校验点都是 KMD 里真实会遇到的。3.1 分配对象从用户态拿参数开始就默认它是坏数据假设 UMD 向 KMD 发出一个“分配显存对象”的请求。用户态发送的参数结构长这样typedef struct _GPU_ALLOCATION_REQUEST { ULONG RequestSize; // 请求分配的字节数 ULONG Alignment; // 对齐要求 ULONG Flags; // 资源标志 ULONG Reserved; // 保留字段必须为 0 } GPU_ALLOCATION_REQUEST;KMD 收到的回调入口第一件事就是校验长度和字段合理性NTSTATUS KmdAllocateResource( PGPU_DEVICE Device, PGPU_ALLOCATE_REQUEST Request, SIZE_T BufferLength, PGPU_HANDLE OutHandle ) { // 第一道结构体长度不能凭想象 if (BufferLength ! sizeof(*Request)) { return STATUS_INVALID_BUFFER_SIZE; } // 第二道请求大小必须落在合理范围内 if (Request-RequestSize 0 || Request-RequestSize Device-MaxAllocationSize) { return STATUS_INVALID_PARAMETER; } // 第三道对齐必须是 2 的幂且不能大于硬件上限 if (Request-Alignment 0 || (Request-Alignment (Request-Alignment - 1)) ! 0 || Request-Alignment Device-MaxAlignment) { return STATUS_INVALID_PARAMETER; } // 第四道保留字段必须为 0 if (Request-Reserved ! 0) { return STATUS_INVALID_PARAMETER; } // 到这里才允许创建内核对象并返回不透明句柄 return CreateAllocationObject(Device, Request, OutHandle); }注意这里的BufferLength不是用户态随便传的数字而是调度层根据实际缓冲区大小带到回调里的。即便如此KMD 也要做一次不折不扣的比较。如果直接拿结构体里的RequestSize当成整个缓冲区的长度一个恶意或损坏的 UMD 就可能让 KMD 读取到结构体后面的未知内存。3.2 提交命令谁、凭什么、往哪里写再看一个命令提交场景。UMD 会传一个上下文句柄以及一串命令缓冲区的指针和长度。KMD 收到后不能直接入队必须按顺序做权限检查NTSTATUS KmdSubmitGpuCommand( PGPU_DEVICE Device, GPU_HANDLE ContextHandle, PGPU_COMMAND_HEADER Header, SIZE_T BufferLength ) { PGPU_CONTEXT Context; SIZE_T Offset 0; // 上下文句柄先查表 Context LookupContext(Device, ContextHandle); if (Context NULL) { return STATUS_INVALID_HANDLE; } // 查表的句柄不等于有权限 if ((Context-AccessMask GPU_ACCESS_SUBMIT) 0 || Context-OwningProcess ! CurrentProcess) { return STATUS_ACCESS_DENIED; } // 缓冲区最小长度判断 if (BufferLength sizeof(*Header)) { return STATUS_INVALID_BUFFER_SIZE; } // 魔术字和版本判断防止完全不认识的数据进来 if (Header-Magic ! GPU_COMMAND_MAGIC || Header-Version ! GPU_COMMAND_VERSION) { return STATUS_INVALID_PARAMETER; } // 逐条遍历命令校验类型和长度 Offset sizeof(*Header); while (Offset BufferLength) { PGPU_COMMAND_ITEM Item GetCommandAt(Header, Offset); if (!IsSupportedCommand(Item-Type)) { return STATUS_INVALID_PARAMETER; } if (Item-Size 0 || Offset Item-Size BufferLength) { return STATUS_INVALID_PARAMETER; } if (!ValidateCommandResources(Device, Context, Item)) { return STATUS_ACCESS_DENIED; } Offset Item-Size; } return SubmitToGpu(Device, Context, Header, BufferLength); }这段代码里的每一个判断都很朴素但把它们组合起来就构成了对外部输入的一整套过滤网。LookupContext管“这个句柄存不存在”AccessMask管“这个调用者有没有权限”ValidateCommandResources管“命令里引用的资源是不是当前上下文能碰的”。这里特别提醒一个细节Offset Item-Size BufferLength这个加法本身也可能溢出。严谨一点的写法应该写成Item-Size BufferLength - Offset或者用长度更宽的SIZE_T做计算。这种溢出问题在 KMD 里非常常见因为缓冲区长度往往是 32 位字段而解析时又可能被转换成 64 位中间的符号扩展和截断都有可能出错。3.3 一张可以贴到工位上的“防呆”清单我从实际项目里总结过一份清单每次新增回调时对照过一遍能挡掉绝大多数低级问题所有入口长度字段直接用宽类型比较不要反复截断。所有枚举值必须有default分支明确返回失败。所有句柄先查表再查权限两者缺一不可。所有物理地址一律不允许由 UMD 直接传递。所有命令里的资源引用必须和当前上下文做过绑定校验。所有可能被 GPU 异步访问的内核对象出入队时都要正确操作引用计数。所有地址范围必须回到资源对象这张“地图”里核对不能只看一个裸地址。看到这里可能有读者会觉得“这也太麻烦了”。没错KMD 开发的真实状态就是麻烦。很多看似“多此一举”的检查都是在留下一次现场事故的教训之后补上去的。4. 常见问题与排查技巧实录4.1 故障现象对照表图形驱动的问题有时候很难一眼定位。下面这张表是我经常拿来对症状的速查表故障现象最可能的边界问题排查方向系统随机崩溃蓝屏信息指向 KMD入口长度校验缺失或长度字段截断检查所有回调的BufferLength比较逻辑某应用退出后其他应用出现花屏上下文切换时 TLB 未刷新在切换上下文后强制刷新页表缓存同一份数据在不同应用间互相污染资源句柄权限没查共享语义有误打印句柄表比对进程归属GPU 长时间卡死提交不返回命令缓冲区里混入了非法命令逐条解析命令定位第一条失败命令资源释放后仍被 GPU 读取引用计数少加或完成点未同步在提交、完成、释放处加计数日志这份对照表不是用来“猜答案”的而是帮我们快速决定第一步该看哪里。真正的定位还得靠日志和调试器。4.2 一条能复现问题的排查路径遇到安全问题或偶发崩溃我比较推荐这样的排查顺序第一步把所有 KMD 回调入口打上日志。不需要打完整参数只要记录“哪个进程、哪个回调、缓冲区长度、句柄值、返回值”。这些信息足够缩小范围。第二步开启驱动验证器或等价的特殊内存检测机制。它会在内核对象周围填充特定标记内存越界访问时能更快暴露出来。第三步构造一组边界值输入。重点测长度极小、长度极大、句柄为空、句柄被释放、Flags 全为 1 这类情况。不要心疼代码简陋KMD 就是要在脏数据面前站得住。第四步复现失败后把句柄表和资源对象状态导出来。很多 use-after-free 问题都是引用计数多一跳或少一跳造成的看代码不容易看出来但看对象生命周期非常直接。这套路径不是银弹但它能保证你不会在问题面前手足无措。4.3 一个实际案例32 位截断造成的越界读我遇到过一个问题现象是驱动只在高负载场景下偶发崩溃频率不高但一崩就是系统级崩溃。最后定位到某个命令结构体里的长度字段是UINT32KMD 在解析时把它拼到了一个SIZE_T的加法表达式中。问题出在一种极端情况用户态传了一个接近 0xFFFFFFFF 的长度值Header PayloadSize在 32 位计算中溢出原本只有几十字节的头部被当成了几 GB 的命令缓冲区去遍历。更麻烦的是这个溢出并不会在任何入口处触发长度校验失败因为在它看来长度字段本身就那么大。修复方式也很简单在 KMD 内定义一个新的、宽度足够的内部结构来承载长度并在入口处就做一次BufferLength - HeaderSize的剩余量检查后续所有解析都基于这个内部值。这个案例给我的教训是不要让长度字段在多次赋值中改变宽度更不要指望 UMD 会传“合理”的数字。5. 关于权限提升与安全边界我给新入行同学的三句话第一句话把每一个 KMD 入口都当成“第一次见到这份数据”。哪怕同一个 UMD 上一秒刚提交过合法请求这一秒也不要假设它不会传一个坏句柄。安全边界不是拦截一次就完事而是每次都要拦。第二句话安全边界不是一张拦截清单而是一套信任模型。你真正要想清楚的问题不是“这行代码会不会崩溃”而是“如果这个输入变成任意值系统会发生什么”。把这个问题反复问几轮很多被忽略的漏洞就自己浮出来了。第三句话把校验逻辑集中封装不要在十几个回调里复制粘贴同一段长度判断。分散的校验意味着有人会漏改一处会让升级变成挖坑。做一个公共的入参校验模块再复杂也能追得回来。我在实际开发中的体会是权限提升本身并不可怕可怕的是只看到“提权”这个动作没看到动作背后的边界。把边界守住KMD 就是图形系统最可靠的地基守不住任何花哨的硬件加速都会变成埋在地雷上的表演。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑