0/1 已展开

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.cinclude/linux/sched.hkernel/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->mmtsk->active_mm 都指到 new mm,最后只剩 bprm->old_mmmm_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(&current->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_oldmmput)原以为只要 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.csynchronize_rcu() 实现。邮件 body 还附了 CPU0/CPU1 时序 ASCII 图和 KASAN 释放/分配双向堆栈。

Highlight:风险与注意点

  1. v1 → v2 的核心改动是放弃"刷 rq lock"而改用 synchronize_rcu():前者只能等到锁内的 reader 退出,覆盖不全;后者一次覆盖所有 preempt-disabled 的 reader,是真正的修复。
  2. synchronize_rcu() 是阻塞型;在 exec 路径上执行尚可接受,但任何后续若在热路径引入同等释放点,要评估延迟。
  3. 兜住的只是 exec_mm_put_old() 这一条路径;其他让 mm 寿命缩短的路径(如 migrate、未来某次 stash 改造)若复用同一 API,需要各自评估是否需要再插入等价的同步点。
  4. account_mm_sched() 在 rq lock 内做的 mm 隐式约束,是当代 runtime accounting 的副作用;如果未来把该函数挪出 rq lock,必须重新审视"是否仍位于 RCU CS"。
  5. 上游 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-disabledaccount_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)