0/10 已展开

LLM 分析

sched/debug: per-CPU debugfs 文件 + 5 处 RCU/UAF 修复 (v4)

系列概况

  • 标题:[PATCH v4 0/5] sched/debug: Introduce per-CPU debugfs files
  • 作者:Aaron Tomlin atomlin@atomlin.com
  • 版本:v4
  • 规模:5 patch + 1 cover letter,10 封邮件
  • 修改文件:kernel/sched/debug.c、kernel/sched/fair.c、kernel/sched/topology.c
  • 代码统计:+76 / -9
  • Message-ID:20260810015812.428999-1-atomlin@atomlin.com
  • 完整性:完整(cover + 5 patch + 4 reply)

补丁目的

本系列一次性修复 sched debugfs 中 5 处 RCU/内存安全问题,并新增 per-CPU debugfs 接口 /sys/kernel/debug/sched/cpu/cpu<N>/debug,便于大 SMP 拓扑上只读目标 CPU 的 runqueue:

  1. print_dl_rq() 无锁访问 rq->rd → UAF。
  2. print_cpu() 无锁访问 rq->curr → 任务退出时 UAF。
  3. sched_show_numa() p->mm 双重求值 → TOCTOU。
  4. print_cfs_stats() 用非 RCU 宏遍历 RCU 链表 → RCU stall。
  5. 新增按 CPU debugfs 视图(离线 CPU 读返回 -ENODEV)。

旧流程的问题

/sys/kernel/debug/sched/debug 是全量 dump,读一次输出所有 online CPU 的 runqueue 状态。在大核数服务器上 dump 体量巨大,且 reader 与 hot-unplug、cpuset repartition、task exit 等事件完全无同步保护。写入侧用普通赋值 rq->rd = rd,既无 rcu_assign_pointer() 的 release barrier,reader 解引用时也没 RCU critical section:

// print_dl_rq() - 旧实现
dl_bw = &cpu_rq(cpu)->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);

reader 读 dl_bw->bw 期间,老 root_domaincall_rcu(&old_rd->rcu, free_rootdomain) 后被 kfree → UAF;reader 拿到的也可能是部分初始化的字段。

类似问题:print_cpu()task_pid_nr(rq->curr) 无锁;sched_show_numa()if (p->mm) P(mm->numa_scan_seq); 中宏对 p->mm 二次求值;print_cfs_stats()list_for_each_entry_safe() 遍历 RCU 链表,缺 READ_ONCE(),且 print_cfs_rq() 在 seq_file I/O 期间会 drop rq->lock,可能触发 RCU stall。

新流程

写入侧把普通指针升级为 RCU 指针,reader 侧加 RCU 临界区;新增 per-CPU debugfs 目录并以 cpu_online() 拦截:

                 writer                          reader
           rq_attach_root()               print_dl_rq / print_cpu
                   |                              |
  rcu_assign_pointer(rq->rd, rd)        rcu_read_lock()
        [release barrier]                READ_ONCE / rcu_dereference
  list_add_rcu(leaf_cfs_rq)             list_for_each_entry_rcu + cap
                   |                              |
         (RCU grace period)              read dl_bw / curr->pid / stats
                   |                              |
           kfree(old_rd)                      rcu_read_unlock()
                                                  |
                                     return 0 | -ENODEV (cpu offline)

per-CPU 路径:

/sys/kernel/debug/sched/cpu/
   +-- cpu0/debug
   +-- cpu1/debug
   ...
   +-- cpuN/debug   (read -> sched_debug_cpu_show -> print_cpu())

Patch 概览

  • Patch 1/5 print_dl_rq():写入侧改用 rcu_assign_pointer();reader 加 rcu_read_lock() + READ_ONCE() + NULL 检查。
  • Patch 2/5 print_cpu():用 rcu_dereference(rq->curr) 替代 READ_ONCE(),保留 __rcu 注解(v3→v4 关键调整)。
  • Patch 3/5 sched_show_numa()READ_ONCE(p->mm) 一次取值复用于局部指针,消除宏内二次求值。
  • Patch 4/5 print_cfs_stats():新增 for_each_leaf_cfs_rq_rcu() 宏(基于 list_for_each_entry_rcu()),加 SCHED_DEBUG_MAX_ITER=1024 硬上限。
  • Patch 5/5 新增 debugfs_cpu_init(),在 sched_init_debug() 末尾调用;新增 sched_debug_cpu_show/open/fops

关键实现

rq_attach_root() 写入侧:

// topology.c
-       rq->rd = rd;
+       rcu_assign_pointer(rq->rd, rd);

print_dl_rq() 读取侧:

// debug.c
+       struct root_domain *rd;
        ...
-       dl_bw = &cpu_rq(cpu)->rd->dl_bw;
+       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();

print_cpu()

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

sched_show_numa()

-       if (p->mm)
-               P(mm->numa_scan_seq);
+       struct mm_struct *mm = READ_ONCE(p->mm);
+       if (mm)
+               __PS("mm->numa_scan_seq", mm->numa_scan_seq);

print_cfs_stats()

+ #define SCHED_DEBUG_MAX_ITER 1024
-       struct cfs_rq *cfs_rq, *pos;
+       struct cfs_rq *cfs_rq;
+       int max_iter = SCHED_DEBUG_MAX_ITER;
-       for_each_leaf_cfs_rq_safe(cpu_rq(cpu), cfs_rq, pos)
+       for_each_leaf_cfs_rq_rcu(cpu_rq(cpu), cfs_rq) {
+               if (--max_iter < 0)
+                       break;
                print_cfs_rq(m, cpu, cfs_rq);
+       }

新增 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;
}

static __init void debugfs_cpu_init(void)
{
        struct dentry *d_cpu_dir = debugfs_create_dir("cpu", debugfs_sched);
        for_each_possible_cpu(cpu) {
                char buf[16];
                struct dentry *d_cpu;
                snprintf(buf, sizeof(buf), "cpu%lu", cpu);
                d_cpu = debugfs_create_dir(buf, d_cpu_dir);
                debugfs_create_file("debug", 0444, d_cpu,
                                    (void *)cpu, &sched_debug_cpu_fops);
        }
}

类比

/sys/kernel/debug/sched/debug 想成 医院大厅的"今日挂号总表":每次查询都要把所有诊室翻一遍。新增的 /sys/kernel/debug/sched/cpu/cpu<N>/debug 相当于 每个诊室门口的自助叫号屏:只看诊室 3,不被 1~127 干扰。

RCU 修复:rcu_assign_pointer() 像"图书管理员上架新书时拉一根临时警戒绳,所有读者必须等他拉好绳才被允许拿书";READ_ONCE() 则是"读者隔着警戒绳看一眼目录,确保拿到的是完整上架的新书,而不是半本";SCHED_DEBUG_MAX_ITER=1024 像"自助叫号屏最多翻 1024 行就强制回到首页",避免被恶意循环拖死。

Highlight:风险与注意点

  • rq->rd__rcu 注解:Aaron 在回复 Prateek 时承认 struct rq::rd 没有 __rcu,直接 rcu_dereference() 会触发 sparse 警告;既然写入侧已用 rcu_assign_pointer(),需要补 __rcu,v5 应顺手补上。
  • rcu_dereference_all() 风格统一:Prateek 建议改用 rcu_dereference_all() 与写入侧对称;Aaron 同意。
  • SCHED_DEBUG_MAX_ITER=1024 合理性:硬上限是 magic number,需要在 commit message 解释为何选 1024;截断后输出统计会"少部分",是否在 seq_file 末尾打印"truncated"警告值得评估。
  • offline CPU 检查与 racecpu_online(cpu) 与后续 print_cpu() 之间没有持锁,理论上仍可能 CPU 此时 down,是否需要 RCU + cpuhp 引用计数保护待 maintainer 拍板。
  • per-CPU debugfs 与 cpuset hotplug:新增目录基于 possible CPU 全集,hotplug 出去的 CPU 目录会残留,长期看是否要在 CPU 真正消失时清理 dentry。
  • sashiko-bot 自动报告:4 个 bugfix 都带 Reported-by: sashiko-bot,说明自动化静态分析器在持续扫这些 lockless 模式,后续值得观察是否还命中其他 sched 路径。

版本变化

  • v1 → v2:受 Peter Zijlstra 与 Zhan Xusheng 启发,将动机重新框定为"大 SMP 上的 targeted interactive debugging";为 sched_debug_cpu_show()cpu_online() 检查并返回 -ENODEV
  • v2 → v3:在 print_cpu()rcu_read_lock() + READ_ONCE();保护 rq->rd 应对 hot-unplug / cpuset repartition;引入 for_each_leaf_cfs_rq_rcu() 基于 list_for_each_entry_rcu()
  • v3 → v4:Patch 1 从 READ_ONCE() 升级到 rcu_dereference(rq->curr) 保留 __rcu;Patch 2 在写入侧补齐 rcu_assign_pointer();新增 Patch 3(p->mm TOCTOU);Patch 4 新增 SCHED_DEBUG_MAX_ITER=1024 上限。

一句话总结

v4 把 sched debugfs 的 5 处无锁/RCU 隐患一次性清理(rq->rdrq->currp->mmleaf_cfs_rq_list),并落地 per-CPU debugfs 接口 cpu<N>/debugstruct rq::rd__rcu 注解、rcu_dereference_all() 风格统一、max_iter=1024 合理性等细节仍待 v5 跟进。