0/1 已展开

LLM 分析

sched/debug:拒绝 numa_balancing scan_size_mb 的非法写入

系列概况

  • 标题[PATCH, Resend, v2] sched/debug: Reject invalid writes to numa_balancing scan_size_mb
  • 作者:Li RongQing <lirongqing@baidu.com>
  • 版本:v2(Resend,基于 v1 rebase 后重写 commit message 并调整 API 选择)
  • 规模:1 file changed, 22 insertions(+), 1 deletion(-)
  • 修改文件kernel/sched/debug.c
  • 代码统计:+22 / -1
  • Message-ID20260822023313.1721-1-lirongqing@baidu.com
  • 完整性:单 patch,自带 commit message(含 Fixes:Signed-off-by:)、diff、v1→v2 的 changelog,无邮件回复线程

补丁目的

NUMA balancing 的 scan_size_mb 参数以前是通过 debugfs_create_u32() 注册的,这条"原厂接口"对写入值是「来者不拒」的——任何 u32 都接受,包括 0,也包括超过 UINT_MAX 之后会被静默截断的"伪大数"。这两类输入都会让后续的 NUMA 扫描逻辑出问题:

  • 写入 0 会在 task_scan_min()task_nr_scan_windows() 中触发除零。
  • 写入超过 UINT_MAX 的值,则在到达本 handler 之前就被 C 的隐式转换静默截断,可能写入与用户预期不符的 u32

本补丁要做的事是在 debugfs 那道闸门处加校验:自定义 file_operations,在 setter 里明确拒绝 0val > UINT_MAX,统一返回 -ERANGE,让攻击面/误用面在边界就被挡掉。

旧流程的问题

user --write--> /sys/kernel/debug/sched/numa_balancing/scan_size_mb
                       |
                       v
              debugfs_create_u32()
                       |
                       v
            *(u32*) &sysctl_numa_balancing_scan_size = val
                       |
                       v
            task_scan_min()  | task_nr_scan_windows()
                       |
              windows = MAX_SCAN_WINDOW / 0  -->  division-by-zero
              rss / nr_scan_pages         (with nr_scan_pages from scan_size_mb = 0)

问题点:

  • 没有任何上限/下限校验。
  • 写入 0 后真正调用除法时崩。
  • 写入 > UINT_MAX 时被截断,听不到任何反馈。
  • 来自 commit 8a99b6833c88("sched: Move SCHED_DEBUG sysctl to debugfs"),把 sysctl 搬迁到 debugfs 时无意中放大了输入风险。

新流程

user --write val--> /sys/kernel/debug/sched/numa_balancing/scan_size_mb
                              |
                              v
                  debugfs_create_file_unsafe()
                              |
                              v
                    numa_scan_size_set(data, val)
                              |
                  +-----------+-----------+
                  | val == 0 ? val > UINT_MAX ?
                  |   yes         yes
                  +-------+-------+
                          |
                          v
                     return -ERANGE   <-- 写入被拒绝

                          |
                          no
                          v
          *(u32*)data = (u32)val; return 0;

关键点:用 DEFINE_DEBUGFS_ATTRIBUTE(numa_scan_size_fops, ...) 一次性把 getter/setter 包成 fops;注册时用 debugfs_create_file_unsafe() 而不是 debugfs_create_file(),避免再多套一层 full_proxy——因为这个 ops 内部已经借助 DEFINE_DEBUGFS_ATTRIBUTE 提供了基于 debugfs_file_get()/put() 的引用计数保护,无需重复。

关键实现

#ifdef CONFIG_NUMA_BALANCING
static int numa_scan_size_get(void *data, u64 *val)
{
	*val = *(u32 *)data;
	return 0;
}

static int numa_scan_size_set(void *data, u64 val)
{
	if (val == 0 || val > UINT_MAX)
		return -ERANGE;

	*(u32 *)data = (u32)val;
	return 0;
}

DEFINE_DEBUGFS_ATTRIBUTE(numa_scan_size_fops, numa_scan_size_get,
			 numa_scan_size_set, "%llu\n");
#endif /* CONFIG_NUMA_BALANCING */

注册处的替换:

- debugfs_create_u32("scan_size_mb", 0644, numa, &sysctl_numa_balancing_scan_size);
+ debugfs_create_file_unsafe("scan_size_mb", 0644, numa,
+		&sysctl_numa_balancing_scan_size, &numa_scan_size_fops);

函数路径里两处除法所在的上下文(这里只引用函数签名说明):

/* task_scan_min(): windows = MAX_SCAN_WINDOW / scan_size; */
/* task_nr_scan_windows(): return rss / nr_scan_pages; */

也就是说扫描窗口数与每次扫描的 page 数都来自 sysctl_numa_balancing_scan_size,对这个字段做 0 校验即可同时保护这两条除法分支。

类比

把它想成一个自助餐厅的取餐窗口:以前发餐员不验餐具,来人就把菜往盘里倒——有人端空盘子(写 0)走,结果打饭机 task_scan_min 直接卡死;有人端比出口还大的铁盘(写 > UINT_MAX),机器硬塞,出来一个被压变形的盘子(静默截断)。现在窗口前加了一道红外线扫描:set 就是闸机,「空盘」和「巨型盘」一律响警报、放行失败、扣回 -ERANGE;只有尺寸正常的盘子才让过、记录秤重。

或者更贴软件一点:原来的 debugfs_create_u32 像一个「无脑的快递柜」,用户塞多大的箱子它都吞,等到下游拆箱的工人(task_scan_min)才发现箱子是空的或者超规格,直接宕机。现在快递柜升级成「带 X 光扫描的智能柜」,扫描异常当场拒收,让 echo 0 > scan_size_mb 在源头就拿到 -ERANGE,而非等到除法时才崩。

Highlight:风险与注意点

  • Fixes tag 必须保留:这个 bug 的本质是 commit 8a99b6833c88 把 sysctl 迁移到 debugfs 时引入的,写 Fixes: 是为了让 stable 自动回溯到受影响的版本,合并时务必保留。
  • debugfs_create_file_unsafe 的取舍full_proxy 层不仅管引用计数,还管 module unload 时的"安全移除"。本 ops 既然使用 DEFINE_DEBUGFS_ATTRIBUTE,已经具备同等机制;但维护者仍会盯这个选择,需要解释为什么这里不需要 full_proxy
  • setter 校验放在边界即可,但下游别放松警惕:除零保护在 set 上做了,但 task_scan_min / task_nr_scan_windows 自己仍可能因为别的修改把 scan_size_mb 拉成 0(比如某些 debug 接口绕过 debugfs 直接写 sysctl 变量)。后续 review 可以追问:要不要在函数入口也加一道 WARN_ON_ONCE
  • val > UINT_MAX 这一点其实早被 C 静默截断:因为 setter 收到的 u64 val 在到达本代码之前已经被截成 u32 了吗?答案是不——debugfs 走的是 string parsing,传进来的是 u64,所以这次校验是有意义的。但这也意味着历史上"用户写大数但被静默截断"的旧行为,本质是别的路径,需要单独 review。
  • 重复发送 + v2:邮件标题带 [Resend]v2,需要确认 v1 在 ml 上是否有人提过 review 意见,v2 的两个改动(rebase 与改用 unsafe)应对应回答 v1 的具体诘问。

版本变化

  • v1 → v2
    1. 基于最新基线 rebase。
    2. 把注册调用从 debugfs_create_file 换成 debugfs_create_file_unsafe——因为 DEFINE_DEBUGFS_ATTRIBUTE 已自带引用计数保护,避免重复的 full_proxy
    3. 重写 commit message,更明确地解释两类风险(除零 + 静默截断)和 -ERANGE 的语义。

一句话总结

sched/debug 里 numa_balancing scan_size_mb 的 debugfs 写入路径换成自定义 setter:拒绝 0val > UINT_MAX、返回 -ERANGE,把可能的除零与隐式截断都挡在闸门之外。