sched discussion
[PATCH v2] sched/cache: Fix use-after-free of the mm replaced by exec
LLM 分析
sched/cache:修复 exec 替换 mm 后的 use-after-free
系列概况
- 标题: [PATCH v2] sched/cache: Fix use-after-free of the mm replaced by exec
- 作者: Hyunwoo Kim imv4bel@gmail.com
- 版本: v2,单封 patch
- 规模: 3 files changed, 17 insertions(+)
- 修改文件:
fs/exec.c、include/linux/sched.h、kernel/sched/fair.c - 代码统计: 17 行新增,0 行删除
- Message-ID:
apXzDjnvOm9GfTLK@v4bel - 完整性: 完整(包含 commit message、Fixes/Suggested-by/Cc/Signed-off-by、Changes in v2、嵌入的 KASAN 堆栈、ASCII 序列图)
补丁目的
修复 sched/cache 子系统在 execve() 路径上对 mm_struct 的 use-after-free:
当一个 task 在 CPU1 上执行 execve() 切换 mm 并释放 old_mm 时,CPU0 上 waker 触发的 try_to_wake_up() → ttwu_queue() → enqueue_task_fair() → update_curr() → account_mm_sched() 会以 rq->curr->mm 拿到旧 mm 并继续读写 mm->sc_stat,而该路径持 rq lock 且 preempt_disable(),恰好构成 RCU 读侧临界区,但释放路径上没有任何同步点等待这些 reader 退出,导致已释放的 mm_struct 与其 sc_stat.pcpu_sched 被并发读写。
旧流程的问题
之前的修复 9f23469401b0 ("sched/cache: Fix potential NULL mm pointer access") 把一处 p->mm 改成了用本地变量,并假设 active_mm 引用能撑住 mm_struct 的生命周期。这个假设在其它把 mm 分离的路径上成立,因为它们走 mmgrab_lazy_tlb() 取了一个引用;但 execve() 同时把 tsk->mm 和 tsk->active_mm 都指到 new mm,最后只剩 bprm->old_mm 的 mm_users 引用,所以 mmput(old_mm) 一调,引用就归零,mm_destroy_sched() → free_percpu(sc_stat.pcpu_sched) → free_mm() 跟着执行。
更糟的是 rq lock 和 rq->cpu_epoch_lock 都不约束 mm 寿命,所以哪怕 reader 还在临界区里释放也照样发生。一旦 reader 在 account_mm_sched() 内读/写 mm->sc_stat,就会命中 KASAN 报告里的 slab-use-after-free,写出 sc_stat.cpu,从已释放的 percpu 区累加 runtime。
新流程
fs/exec.c::exec_mm_put_old() 末尾、mm_update_next_owner()/mmput(old_mm) 之前插入 sched_cache_exec_done():
static void exec_mm_put_old(struct mm_struct *old_mm)
{
setmax_mm_hiwater_rss(¤t->signal->maxrss, old_mm);
mm_update_next_owner(old_mm);
sched_cache_exec_done(); // 新增:等待 RCU 宽限期
mmput(old_mm); // 之后才允许释放旧 mm
}
synchronize_rcu() 走完一轮 grace period 之前,没有任何 reader 会跨过临界区;又因为 tsk->mm = new mm 的 store 在调用前就已发生,新进入的 reader 拿到的就是 new mm,不会再触及 old mm。
关键实现
/* kernel/sched/fair.c */
/* exec() has switched to the new mm and is about to drop the old one. */
void sched_cache_exec_done(void)
{
/*
* account_mm_sched() dereferences rq->curr->mm with the rq lock held,
* so a remote CPU can still be using the old mm. That section runs with
* preemption disabled and is therefore an RCU read-side critical
* section, so wait for a grace period before the mm goes away.
*/
synchronize_rcu();
}
include/linux/sched.h 加声明与 #else 分支的 static inline void sched_cache_exec_done(void) { };!CONFIG_SCHED_CACHE 构建下零开销。
类比
把 mm_struct 想象成一间公共阅读室的资料册:
- 读者进入时出示借阅证(
preempt_disable()+ rq lock = 隐式 RCU read-side CS)。 - 管理员(
exec_mm_put_old→mmput)原以为只要active_mm这张借阅证还没还,资料册就不会被清空;但execve顺手把这张证也转给了"新读者",于是只剩bprm->old_mm这张临时证撑门面。 - 修正动作就是管理员在封册前先广播一次:"所有借阅中的读者请把手上的笔记抄完"——这正是
synchronize_rcu()。宽限期过后,要么读者已抄完(安全),要么他们回头看到的是新册(也安全)。
对应的"借阅证"是 RCU 读侧 CS,"广播"是 grace period。
Patch 概览
完整 diff 已包含在邮件正文里,三个文件的 hunk 都贴在 body 中:hunk #1 是 fs/exec.c 单行新增调用;hunk #2 是 include/linux/sched.h 的声明 + stub;hunk #3 是 kernel/sched/fair.c 的 synchronize_rcu() 实现。邮件 body 还附了 CPU0/CPU1 时序 ASCII 图和 KASAN 释放/分配双向堆栈。
Highlight:风险与注意点
- v1 → v2 的核心改动是放弃"刷 rq lock"而改用
synchronize_rcu():前者只能等到锁内的 reader 退出,覆盖不全;后者一次覆盖所有preempt-disabled的 reader,是真正的修复。 synchronize_rcu()是阻塞型;在 exec 路径上执行尚可接受,但任何后续若在热路径引入同等释放点,要评估延迟。- 兜住的只是
exec_mm_put_old()这一条路径;其他让 mm 寿命缩短的路径(如migrate、未来某次 stash 改造)若复用同一 API,需要各自评估是否需要再插入等价的同步点。 account_mm_sched()在 rq lock 内做的 mm 隐式约束,是当代 runtime accounting 的副作用;如果未来把该函数挪出 rq lock,必须重新审视"是否仍位于 RCU CS"。- 上游
9f23469401b0那个把p->mm改成局部变量的修复只是把崩溃从 NULL deref 翻译成 UAF,并未根本解决;本 patch 是补全。
版本变化
v1 → v2 的 Changelog (来自邮件正文 Changes in v2):
- 删掉 v1 里意外混入的不相关 hunk。
- 把"绕一圈 rq lock 的同步方式"换成
synchronize_rcu(),覆盖所有preemption disabled路径上的 reader。
一句话总结
execve() 替换 tsk->mm 同时也搬走了 active_mm,让另一颗 CPU 持 rq lock、preempt-disabled 的 account_mm_sched() 对旧 mm 形成 UAF;在 exec_mm_put_old() 释放 old_mm 之前 synchronize_rcu() 等一个 RCU 宽限期,根治这个回归。
CPU0 (waker) CPU1 (execve target)
----------- --------------------
try_to_wake_up() execve()
ttwu_queue() exec_mmap()
lock rq0 tsk->mm = new mm
enqueue_task_fair() tsk->active_mm = new mm
update_curr() setup_new_exec()
update_se() exec_mm_put_old()
account_mm_sched() |
mm = rq->curr->mm // old |
// preempt-disabled, |
// RCU read-side CS |
sched_cache_exec_done()
synchronize_rcu()
--------------------> wait
mmput(old)
mm_destroy_sched()
free_percpu(sc_stat.pcpu_sched)
free_mm()
// old mm fully gone
Grace period end:
- readers already in CS finished against live old mm
- new readers see the new mm (store happened before wait)