0/6 已展开

LLM 分析

sched/debug:引入 per-CPU debugfs 文件并修复 lockless UAF / RCU 遍历违规

系列概况

  • 标题[PATCH v3 0/4] sched/debug: Introduce per-CPU debugfs files
  • 作者:Aaron Tomlin atomlin@atomlin.com
  • 版本:v3(2026-08-08)
  • 规模:4 个 patch,覆盖 2 个文件(kernel/sched/debug.ckernel/sched/fair.c
  • 代码统计:+65 / -6 行(cover letter 中给出的 stat)
  • Message-ID20260808235522.380038-1-atomlin@atomlin.com 起 5 封 patch 邮件(第 0/4 到第 4/4),另加 1 封作者本人回复请求 ignore
  • 完整性:patch 文本完整给出,diffstat 给齐;最后一封由作者本人在 2026-08-10 撤回整个 v3,要求 ignore,因为 Sashiko 又报告了更多 pre-existing 问题

补丁目的

系列同时做两件事:

  1. 修复三处已经在 sched debugfs handler 中存在的内存安全问题:分别针对 rq->currrq->rd 的无锁解引用,以及 rq->leaf_cfs_rq_list 上的非 RCU 安全遍历。Reported-by 都是 sashiko-bot,说明是机器扫描出来的潜在 UAF/RCU 违例。
  2. 引入 per-CPU debugfs 文件 /sys/kernel/debug/sched/cpu/cpu<N>/debug,在大 SMP 上不必再读全量 /sys/kernel/debug/sched/debug,可直接拿到目标 CPU 的 runqueue 视图。

旧流程的问题

  • print_cpu() 直接以 task_pid_nr(rq->curr) 解引用 rq->curr,未持 RCU 读锁。任务并发退出时,put_task_struct() 通过 call_rcu() 调度 __put_task_struct_rcu_cb();宽限期可能在 reader 仍在解引用期间完成,free_task() 释放 task_struct -> UAF。
  • print_dl_rq() 直接读 cpu_rq(cpu)->rd。CPU 热拔除或 cpuset 重新划分时,partition_sched_domains() -> rq_attach_root() -> call_rcu(free_rootdomain),reader 未持 RCU 读锁即可能观察到 kfree(old_rd) 后的 dl_bw->bw -> UAF。
  • print_cfs_stats() 使用 for_each_leaf_cfs_rq_safe()(即 list_for_each_entry_safe())遍历 RCU 保护的 leaf_cfs_rq_list_safe 变体不强制 READ_ONCE(),编译器/CPU 可能重排或重复读取,使 reader 在 writer smp_store_release() 发布之前就"看到"新插入节点的指针,导致读到未初始化字段。
  • 全局 /sys/kernel/debug/sched/debug 把全部 online CPU 信息汇总输出,做针对性诊断既慢又容易在大输出里翻页出错。

新流程

  • 三处关键访问全部包入 rcu_read_lock() / rcu_read_unlock() 之间,并以 READ_ONCE() 重新取值,使 reader 处于 RCU 读端临界区,阻挡并发的 call_rcu() 回调执行。
  • 新增 for_each_leaf_cfs_rq_rcu() 宏(基于 list_for_each_entry_rcu()),配合已有的 rcu_read_lock() 安全遍历 leaf CFS runqueue 列表。
  • 新增 /sys/kernel/debug/sched/cpu/cpu<N>/debug,由 debugfs_cpu_init()sched_init_debug() 阶段创建 cpu/cpu<N>/debugsched_debug_cpu_show() 内通过 cpu_online(cpu) 决定输出或返回 -ENODEV,复用现有 print_cpu() 避免重复逻辑。
                    BEFORE (lockless reader)                          AFTER (rcu_read_lock + READ_ONCE)
                    ----------------------------                     ------------------------------------------

  print_cpu()       task_pid_nr(rq->curr)                            rcu_read_lock()
    |                  |                                             SEQ_printf(... READ_ONCE(rq->curr) ...)
    |                  | put_task_struct()                            rcu_read_unlock()
    |                  v
    |               call_rcu(free_task)
    |                  |
    |                  +--> grace period elapses concurrently
    |                       -> free_task() -> UAF on rq->curr
    v
  UAF risk

  print_dl_rq()     cpu_rq(cpu)->rd->dl_bw                          rcu_read_lock()
    |                  |                                             rd = READ_ONCE(cpu_rq(cpu)->rd)
    |                  | partition_sched_domains                      if (rd) { ...use rd->dl_bw... }
    |                  v                                             rcu_read_unlock()
    |               rq_attach_root()
    |                  |
    |                  v
    |               call_rcu(free_rootdomain)
    |                  |
    |                  +--> kfree(old_rd) concurrent -> UAF on dl_bw
    v
  UAF risk

  print_cfs_stats() for_each_leaf_cfs_rq_safe()                      rcu_read_lock()
                     (= list_for_each_entry_safe)                    for_each_leaf_cfs_rq_rcu(...)
                       |   no READ_ONCE                                |   uses READ_ONCE via list_for_each_entry_rcu
                       v                                              v
                     sees new node before fields published            waits for smp_store_release() publish
                     -> uninitialised read                            safe iteration

  /sys/kernel/debug/sched/debug                                       /sys/kernel/debug/sched/cpu/cpu<N>/debug
    : all CPUs, big dump                                              : single CPU, print_cpu(m, cpu), -ENODEV if offline

Patch 概览

Patch文件关键改动
1/4kernel/sched/debug.cprint_cpu()rcu_read_lock() + READ_ONCE(rq->curr) 保护 rq->curr 解引用
2/4kernel/sched/debug.cprint_dl_rq()rcu_read_lock() + READ_ONCE(cpu_rq(cpu)->rd) 保护 rq->rd;引入 rd 局部变量与 NULL 检查
3/4kernel/sched/fair.c新增 for_each_leaf_cfs_rq_rcu() 宏(list_for_each_entry_rcu()),并在 print_cfs_stats() 中替换原 for_each_leaf_cfs_rq_safe()
4/4kernel/sched/debug.c新增 debugfs_cpu_init()sched_debug_cpu_show(),在 sched_init_debug() 中创建 /sys/kernel/debug/sched/cpu/cpu<N>/debug,offline CPU 返回 -ENODEV

关键实现

Patch 1/4 -- print_cpu()

rcu_read_lock();
SEQ_printf(m, " .%-30s: %ld\n", "curr->pid",
           (long)(task_pid_nr(READ_ONCE(rq->curr))));
rcu_read_unlock();

READ_ONCE() 阻止编译器/CPU 把 rq->curr 缓存到寄存器;rcu_read_lock() 让 reader 处于 RCU 宽限期,因此 __put_task_struct_rcu_cb() 不会在这一段执行,task_struct 在解引用期间始终有效。Fixes: 指向最初引入 debug 文件的 commit 02968ccf7b80

Patch 2/4 -- print_dl_rq()

struct root_domain *rd;
...
rcu_read_lock();
rd = READ_ONCE(cpu_rq(cpu)->rd);
if (rd) {
    dl_bw = &rd->dl_bw;
    SEQ_printf(m, " .%-30s: %lld\n", "dl_bw->bw",      dl_bw->bw);
    SEQ_printf(m, " .%-30s: %lld\n", "dl_bw->total_bw", dl_bw->total_bw);
}
rcu_read_unlock();

相比之前的版本,新版把 rd 提到外层并加 NULL 检查:在 rq_attach_root() 已经把 rd 摘下但 call_rcu() 还没真正释放的窗口里,rd 可能为 NULL,直接读 dl_bw->bw 就会崩;同时显式 rcu_read_lock()free_rootdomain() 推迟到 reader 退出临界区。

Patch 3/4 -- for_each_leaf_cfs_rq_rcu()

#define for_each_leaf_cfs_rq_rcu(rq, cfs_rq) \
    list_for_each_entry_rcu(cfs_rq, &(rq)->leaf_cfs_rq_list, \
                            leaf_cfs_rq_list)

for_each_leaf_cfs_rq_rcu(cpu_rq(cpu), cfs_rq)

宏展开基于 list_for_each_entry_rcu(),每次 next 指针的读取都过 READ_ONCE(),与 writer 侧的 smp_store_release() 形成 happens-before,杜绝看到未发布字段。_safe 变体允许并发删除/再插入,本场景 leaf_cfs_rq_list 只在 rq->lock 下增删,RCU 读端临界区足够保护。

Patch 4/4 -- per-CPU debugfs

static int sched_debug_cpu_show(struct seq_file *m, void *v)
{
    unsigned long cpu = (unsigned long) m->private;

    if (!cpu_online(cpu))
        return -ENODEV;

    print_cpu(m, cpu);
    return 0;
}

m->private 携带 CPU 编号,复用既有 print_cpu(),避免逻辑分叉。debugfs_cpu_init()for_each_possible_cpu() 一次性创建 cpu/cpu<N>/ 目录与 debug 文件,让 possible 但当前 offline 的 CPU 也保留入口,由 read 时再校验。

类比

把 debugfs 想成医院的病历室:

  • 旧版 /sys/kernel/debug/sched/debug 是一本大部头合订病历,要查某个病人(CPU)必须整本翻出来 -- 既慢,又容易在翻页过程中某页被回收站收走(UAF)。
  • 新版的 /sys/kernel/debug/sched/cpu/cpu<N>/debug 像是按病人拆出来的小册子,要看谁直接抽谁。
  • rcu_read_lock() 像图书馆前台在你查资料时挂出一块"请勿收架"牌子;call_rcu() 的回收动作要等你把这块牌子摘下来(rcu_read_unlock())才会执行。
  • list_for_each_entry_safe() 的问题则像用一把没有护栏的扫描枪读实时更新的登记表 -- 前一行刚填到一半,你就能扫到名字。list_for_each_entry_rcu() 给扫描枪装上同步护栏:作者把整行(含 tgload)写完、按下发布键(smp_store_release())之前,你是看不到这一行的。

Highlight:风险与注意点

  1. 作者已正式撤回 v3:最后一封回复里 Aaron 自己请求 ignore,并指向 Sashiko 报告的另一些问题。这意味着即便 v3 方向正确,仍需在 v4 把那些新报告的 pre-existing 问题一起修;任何 maintainer 不应在 v3 上直接 AC。
  2. rcu_read_lock() 范围必须覆盖到所有解引用:patch 1/2/3 都在同一函数内闭环,OK;但调用链若上层(如 sched_debug_next/seq_read)在已经 rcu_read_unlock() 之后继续用 cfs_rq 指针,就会再次把临界区打开,需要在 review 中确认 print_cfs_rq 不缓存 cfs_rq
  3. ENODEV 是 best-effort 信号cpu_online(cpu)for_each_possible_cpu() 创建目录不同步,可能出现"目录已存在但目标 CPU 立刻下线"的极短窗口;脚本读到 -ENODEV 不应等同于"CPU 配置错误",而应理解为"此刻 offline"。
  4. 单文件视图仍是 /proc/sched_debug 的子集:per-CPU 文件只走 print_cpu(),未覆盖 leaf_cfs_rq/rt_rq/dl_rq 的完整视图;文档或 commit message 应明确"targeted inspection"定位,避免用户期待等价于全量 debug。
  5. Fixes: 标签面广02968ccf7b80 自最初引入 debug 文件就有问题,可能涉及 stable 回溯;review 时要确认 RCU 注解是否在所有受影响内核版本都成立。
  6. print_dl_rq()rd NULL 检查的可见性rq_attach_root()rq->rd 置 NULL 与 call_rcu(free_rootdomain) 之间存在窗口;旧代码只取一次值,新代码取完再 NULL 检查,更稳,但理论上仍可能恰好在 READ_ONCE 之后、if (rd) 之前被释放 -- 这是 rcu_read_lock() 在起兜底作用,review 时要确认它确实在整个 if 块内仍然持有。

版本变化

  • v1 -> v2:把 commit message 动机改写为"在大型 SMP 拓扑上做有针对性的交互式调试"(来自 Peter Zijlstra 与 Zhan Xusheng 的反馈);sched_debug_cpu_show() 增加 cpu_online(cpu) 守卫,离线 CPU 返回 -ENODEV(Zhan Xusheng)。
  • v2 -> v3:① 在 print_cpu() 中加入 rcu_read_lock() + READ_ONCE(rq->curr);② 在 print_dl_rq() 中加入 rcu_read_lock() + READ_ONCE(cpu_rq(cpu)->rd) 并对 CPU 热拔除/cpuset 重划分做防护;③ 新增 for_each_leaf_cfs_rq_rcu() 宏(基于 list_for_each_entry_rcu()),并把 RCU 遍历问题单独抽成 patch 3/4。
  • v3 -> 撤回:作者在 2026-08-10 因 Sashiko 报告了更多 pre-existing 问题而请求 ignore 当前 v3,预计出 v4 时一并修复。

一句话总结

v3 系列同时修掉 sched debugfs 里三处 lockless UAF / RCU 遍历违规,并补上一个 per-CPU debugfs 入口,但作者本人在最后一封邮件里已经要求 ignore、准备出 v4 处理 Sashiko 报告的更多问题,因此评审应当暂停,等 v4 再继续。