Files
SKELETONKEY/modules/refluxfs_cve_2026_64600/MODULE.md
T
KaraZajac c55adc1840
build / build (clang / debug) (push) Waiting to run
build / build (clang / default) (push) Waiting to run
build / build (gcc / debug) (push) Waiting to run
build / build (gcc / default) (push) Waiting to run
build / sanitizers (ASan + UBSan) (push) Waiting to run
build / clang-tidy (push) Waiting to run
build / drift-check (CISA KEV + Debian tracker) (push) Waiting to run
build / static-build (push) Waiting to run
refluxfs: drop the redundant standalone verify harness
The own-files reachability harness tools/verify-vm/refluxfs_verify.c is
superseded by the module itself: the safe default --exploit does the same
own-files reachability confirmation, and --full-chain provides the stronger
end-to-end proof (actual root). Remove the standalone and repoint the three
doc references (MODULE.md, RELEASE_NOTES.md, targets.yaml) at the module's
full-chain verification. The 4/4 own-files measurement it produced is kept
as a historical data point, just without the now-deleted reproducer.

No code depended on it; 148-test unit harness still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118iUgHY44hdRtANgyCmu7y
2026-07-23 18:50:58 -04:00

288 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# refluxfs — CVE-2026-64600
"RefluXFS" — a time-of-check/time-of-use race in the XFS **reflink
copy-on-write** path that lets **any unprivileged local user overwrite the
on-disk contents of any file they can read**, on any XFS volume mounted with
`reflink=1` that they can write to. No user namespace, no capability, no crafted
filesystem image, no kernel offsets. It has been present since reflink direct-I/O
CoW landed in **4.11 (2017)** — a nine-year window.
This is the corpus's first XFS module, and its first **data-oriented** kernel
bug: the primitive is an arbitrary *file content* overwrite, not memory
corruption.
> **🟢 Full chain (`--full-chain`), VM-verified end-to-end.**
> `skeletonkey --exploit refluxfs --i-know --full-chain` lands root: it
> reflink-clones `/etc/passwd`, races the CoW window, strips root's password
> field on disk (`root:x:` → `root::`), evicts the stale page cache, and
> returns `EXPLOIT_OK`; `su root` (empty password) then gives uid 0. **Without**
> `--full-chain` the module runs a safe reachability trigger only, confined to
> files the caller owns. See "Full-chain verification" below.
## The bug
`xfs_direct_write_iomap_begin()` (`fs/xfs/xfs_iomap.c`) reads the data-fork
extent map under `ILOCK`. To allocate a transaction it must wait for log space,
so `xfs_reflink_fill_cow_hole()` (`fs/xfs/xfs_reflink.c`) **drops `ILOCK`**. On
re-acquiring it, the code re-queries the refcount btree at the **original**
physical block number (`imap->br_startblock`) — and **never re-reads the data
fork**.
A second `O_DIRECT` writer, holding only the coarser `IOLOCK`, can complete an
entire CoW cycle inside that window: allocate block Y, write it, and remap via
`xfs_reflink_end_cow()`. The first writer's `imap` now points at a block owned
solely by the reflink **source**. Its stale refcount lookup returns `1`, it
concludes the block is private, and writes to it in place — landing attacker
data on the source file's on-disk blocks.
Three consequences follow, and they drive the whole module design:
1. **No offsets, no ROP, no KASLR/SMEP/SMAP.** There is nothing to port per
kernel build. Qualys is explicit that SELinux enforcing, container
boundaries and seccomp are equally irrelevant.
2. **The victim's inode is never written.** The data is applied to the shared
physical block *underneath* it, so `mtime`/`ctime`/size do not change and
there is no kernel log output. **File-integrity monitoring does not fire.**
3. **It persists across reboots**, because the change is on disk.
The public demonstration (RHEL 10.2) reflink-clones `/etc/passwd` into
`/var/tmp`, races concurrent direct-I/O writes against the clone, thereby
rewriting `/etc/passwd` itself to strip root's password, and runs `su`.
## Affected range
| | |
|---|---|
| Introduced | **4.11** (2017-02, commit `3c68d44a2b49`, "xfs: allocate direct I/O COW blocks in iomap_begin") |
| Fixed upstream | commit `2f4acd0fcd86` ("xfs: resample the data fork mapping after cycling ILOCK") — merged **2026-07-16**, released **7.2-rc4** |
| Stable backports | **7.1.4** (`e705d81a7193`) · **6.18.39** (`206c09b04dc5`) · **6.12.96** (`44f891bc0889`) |
| Affected, no upstream fix | 6.6 / 6.1 / 5.15 / 5.14 / 5.10 / 4.19 / 4.18 LTS lines (per the CNA record at time of writing) |
| Not affected | < 4.11 — includes RHEL/CentOS **7** (3.10 predates reflink) |
| NVD class | CWE-362 (race) → CWE-367 (TOCTOU). NVD had published **no CWE and no CVSS vector** at time of writing |
| CISA KEV | no (disclosed 2026-07-22) |
**The exposure is distro-shaped, not kernel-shaped.** What matters is whether
XFS+reflink is the installer default:
| Exploitable out of the box | Not reachable by default |
|---|---|
| RHEL 8/9/10 · CentOS Stream 8/9/10 · Rocky/AlmaLinux 8/9/10 · Oracle Linux 8/9/10 (RHCK + UEK R6/R7/8) · CloudLinux 8/9/10 · Fedora Server ≥ 31 · Amazon Linux 2023 (and AL2 AMIs from 2022-12) | Debian · Ubuntu · Fedora Workstation · SLES · openSUSE · Arch (ext4/btrfs defaults — unless an XFS volume was added deliberately) |
### ⚠️ The version gate has a real blind spot here
The affected population is overwhelmingly **RHEL-family**, and those vendors
backport fixes **without bumping the upstream base version** — a patched RHEL 8
kernel still reports `4.18.0-xxx.el8`. An upstream-version gate cannot see that.
So on rpm-family hosts, a `VULNERABLE` verdict is a statement about the
**upstream base version only**. `detect()` prints that warning explicitly rather
than implying it checked the erratum. Confirm against the vendor advisory
(RHSA / ELSA / ALSA / RLSA) before acting on it.
## Trigger / detection
Unlike a pure kernel race, this bug's reachability **can** be established safely
and deterministically, so `detect()` is a version gate **plus a real
precondition probe**:
- **Passive** — is there a writable directory on a mounted XFS filesystem?
Identified via `statfs(2)` `f_type == XFS_SUPER_MAGIC`, **not** by a
successful `FICLONE`, because btrfs implements `FICLONE` too and is
unaffected. No such directory → `PRECOND_FAIL`, the correct verdict on a
stock Debian/Ubuntu host.
- **Active** (`--active` / `--auto`) — confirms `reflink=1` empirically by
cloning and removing two 4 KiB files, rather than assuming the `mkfs.xfs`
default. `reflink=0` → no shared extents can exist → `PRECOND_FAIL`.
- **Override** — `SKELETONKEY_XFS_ASSUME_REFLINK=1` (force reachable) / `0`
(force unreachable), for when you know the fleet's storage layout better than
a local probe can. This also drives the unit tests.
### `--full-chain` — the real `/etc/passwd` root pop
With `--full-chain`, `exploit()` performs the actual privilege escalation:
1. **Pre-flight, before touching anything.** Confirms the target
(`/etc/passwd`, or `$SKELETONKEY_REFLUXFS_TARGET`) is root-owned and fits in
one block, and **crafts the payload first** — the original file with root's
password field emptied (`root:x:``root::`), **every other line preserved
byte-for-byte**, padded with newlines to the exact original size. If it
cannot produce a payload that keeps both root and the invoking user's line,
it refuses and touches nothing. (A naive port that truncates the tail drops
`sshd`/`nobody`/the caller and bricks login — this is the single most
important safety property of the implementation.)
2. **Backup.** Copies the target aside so failure or `cleanup()` can restore it.
3. **Race.** 32 writers push the crafted block at a reflink-clone of the target
while 8 helpers churn `ftruncate`/`fdatasync`, up to a 90 s budget. A won
race lands the crafted block on the target's still-shared physical block.
4. **Cache eviction.** The overwrite bypasses the target inode, so its clean
page-cache pages are never invalidated — a `su` immediately after would read
the *stale* old passwd. The module issues `POSIX_FADV_DONTNEED` (needs only
an `O_RDONLY` fd) so subsequent buffered readers see the new bytes.
5. **Verify (via `O_DIRECT`, not the cache) and report.** Confirms the on-disk
root line is now `root::`; if the write was torn, it restores from backup and
fails. On success returns `EXPLOIT_OK` and prints `su root` (empty password).
`cleanup()` (run as root after the pop) restores `/etc/passwd` from the backup.
The overwrite is persistent and survives reboot, so restoring matters.
#### The private-extent precondition (not in the public writeup)
The race only fires when the target's extent refcount is **exactly** the
attacker-clone pair — i.e. the target's extent must be **private** going in. The
mechanism: the block starts at refcount 2 (target + attacker clone), the
concurrent CoW drops it to 1, and the stale writer then reads "1 → private". If
the target is *already* reflink-shared with a third file, the post-CoW refcount
stays > 1, the writer correctly does CoW, and nothing corrupts.
This was found during verification: the stock Rocky 9 cloud image ships
`/etc/passwd` **pre-shared** (its block had refcount > 1 in the base image), and
the attack failed against it across ~41 000 rounds. Rewriting the file so its
extent became private — with byte-identical content, exactly what any
`useradd`/`passwd`/`vipw` does — made it fall in ~2 000 rounds. So the
exploitable state is the *normal* administered state; the cloud image was
accidentally protected by how it was built. `detect() --active` reports the
target's extent state (`filefrag -v /etc/passwd | grep shared` checks it by
hand), and the full chain warns when the target is pre-shared.
### `--full-chain` verification (2026-07-23, Rocky 9.8)
On `5.14.0-687.10.1.el9_8.0.1.x86_64`, unprivileged `uid=1000`, SELinux
**Enforcing**, against a private-extent `/etc/passwd`:
| | |
|---|---|
| `--exploit refluxfs --i-know --full-chain` | **`EXPLOIT_OK`**, 3/3 wins (1244 / 3716 / 7913 rounds, 430 s) |
| `su root` (empty password) afterwards | **`uid=0(root)`** |
| Accounts preserved | all 25 lines; `root`/`sk`/`sshd`/`nobody` intact |
| `/etc/passwd` metadata after overwrite | size/inode/**mtime/ctime unchanged**, only content — FIM-invisible |
| `cleanup` (as root) | restored `/etc/passwd` from backup, removed backup |
| Plain `--exploit` (no `--full-chain`) | safe trigger, `EXPLOIT_FAIL`, target untouched |
| Pre-shared `/etc/passwd` | not attackable (~41 000 rounds, no win) — as predicted |
### The safe default trigger
Without `--full-chain`, `exploit()` forks an isolated child that creates a
private `mkdtemp` scratch directory on the XFS mount and works **only on two
files it owns**:
- **(A) deterministic + safe** — writes a donor file, `FICLONE`-clones it, and
confirms via **`FIEMAP_EXTENT_SHARED`** that the clone's extent really is
shared (refcount > 1), plus that `O_DIRECT` opens succeed. That is a
read-only observation that the exact filesystem state the bug misjudges
exists here. Reflink cloning is an ordinary supported operation, so this
phase is safe on any kernel.
- **(B) hard-bounded window exercise** — races **8** concurrent `O_DIRECT`
4 KiB writes against the clone while **2** helper threads cycle
`ftruncate`/`fdatasync` to keep the transaction allocator dropping `ILOCK` to
wait for log space, for at most **16 rounds / 2 s**. Then it stops and reads
the donor back **with `O_DIRECT`** — a buffered read would be served from the
page cache that the corruption bypasses, and would hide a win.
This default path is **deliberately under-driven** (the public PoC and the
`--full-chain` path use 32 writers and 8 helpers) and **never clones or targets
a file it does not own** — the destructive `/etc/passwd` overwrite lives only
behind `--full-chain` (above). The default `exploit()` always returns
`EXPLOIT_FAIL`.
If the race *is* won on the safe path, the module says so loudly: that is
CVE-2026-64600 confirmed present, empirically, with the damage contained to
4 KiB of the operator's own scratch file.
## VM verification (2026-07-23)
Confirmed on **Rocky Linux 9.8 / `5.14.0-687.10.1.el9_8.0.1.x86_64`** under
qemu/KVM with 6 vCPUs — the stock GenericCloud installer layout, root on
`/dev/vda4` XFS with `reflink=1`, no provisioner changes:
| Check | Result |
|---|---|
| `detect()` on real XFS | **VULNERABLE** (found writable XFS at `/var/tmp`) |
| rpm-family backport caveat | fired correctly |
| `--active` FICLONE witness | **reflink CONFIRMED** |
| Phase A shared extent | **`FIEMAP_EXTENT_SHARED` set** (btrfs never reported it; XFS does) |
| Phase A `O_DIRECT` gate | available |
| Shipped trigger (8 writers / 2 helpers / 2 s) | ran 16 rounds, **did not win***by design* |
| Scratch cleanup | no artifacts left |
| Build on el9 gcc | clean |
**The underlying bug was separately confirmed winnable on that kernel.** The
`--full-chain` run above is the definitive proof — the same reflink-CoW race
rewrote `/etc/passwd` and landed root **3/3** (1244 / 3716 / 7913 rounds). An
earlier *non-destructive* measurement, driven at the public PoC's parameters
(32 writers / 8 helpers, 60 s) but confined to two files the test user owned,
won **4/4** (first divergence after **69, 114, 170 and 494 rounds**): a racing
`O_DIRECT` write landing on a still-shared block and rewriting the donor's
on-disk bytes — the arbitrary-overwrite primitive, observed directly, contained
entirely to attacker-owned files.
Note carefully what this does and does not say. The shipped trigger **not**
winning in 2 s on a kernel that is provably vulnerable is exactly the designed
behaviour, and is the concrete reason a non-win must **never** be read as
"patched" — trust the version gate and the vendor erratum instead.
### Why this ranks *above* the other reconstructed race triggers
`bad_epoll` (12) and `ghostlock` (11) sit at the bottom of the `--auto` safety
ranking because a won race frees a live `struct file` or corrupts the kernel
**stack** — silent destabilisation or near-certain panic. Neither applies here.
RefluXFS corrupts **file data, not kernel memory**: there is no oops, no KASAN
report, no panic risk, and the blast radius of a win is one 4 KiB scratch file
we created and delete. That is why `refluxfs` carries safety rank **55** — it is
genuinely safe to run, and the ranking should say so. The VM run above bears
this out: the bug was won 4/4 times on a vulnerable kernel with no oops, no
dmesg output and no instability.
## Detection — the obvious rule does not work
**Do not rely on `-w /etc/passwd -p wa`, AIDE, or Tripwire for this CVE.** The
attacker never issues a `write(2)` against the victim inode; XFS applies their
data to the shared physical block beneath it. Size, `mtime` and `ctime` are
unchanged and nothing is logged. Anyone relying on FIM to catch a `passwd`
modification is blind to this bug *by construction*.
What does work, in descending order of fidelity:
1. **The reflink itself**`ioctl(fd, FICLONE, srcfd)` where `FICLONE` is
`0x40049409`. auditd can match the request number **exactly**, so it does not
flood, and the attack cannot avoid it. Tune out `cp --reflink=auto`, podman
and `systemd-nspawn` image work.
2. **`O_DIRECT` opens** — `openat` flags `& 0x4000`. Also on the critical path,
and rare outside databases and backup agents.
3. **Content-vs-metadata drift** — because the bytes change while `mtime` does
not, hashing `/etc/passwd`, `/etc/shadow` and the setuid binaries on a
schedule and alerting when the *content* hash moves **without** a
corresponding `mtime` change is a near-zero-false-positive detector for this
whole bug class.
The shipped rules cover all three: auditd/sigma anchor on the `FICLONE` request
number and `O_DIRECT` opens (correlated per-pid, plus the post-exploitation
euid-0 transition), falco adds the high-fidelity "reflinked a file owned by
another user" condition, and — unusually for a kernel bug — the **yara** rule is
genuinely the right tool, matching the on-disk artifact (`/etc/passwd` with a
password-less root entry or an added uid-0 account) precisely because there is
no metadata trace for FIM to find.
## Fix / mitigation
Upgrade the kernel (≥ 7.1.4 / 6.18.39 / 6.12.96 on-branch, or 7.2+; on
RHEL-family, the vendor erratum) **and reboot**.
There is **no partial mitigation**, which is why `mitigate()` is `NULL`:
`reflink` is a superblock feature that cannot be disabled on a live filesystem,
`O_DIRECT` cannot be turned off, and — because this is a data-oriented bug —
SELinux enforcing, container boundaries, KASLR, SMEP, SMAP and seccomp are all
irrelevant. Qualys puts it plainly: *"This isn't a vulnerability you can harden
around, isolate, or live-patch."*
`cleanup()` restores `/etc/passwd` from the `--full-chain` backup (run it as
root after the pop: `su root`, then `skeletonkey --cleanup refluxfs`), then
sweeps any `skeletonkey-refluxfs-*` scratch directories left behind if a run was
killed mid-round; normal runs remove their own.
## Credit
Discovery and research: **Qualys Threat Research Unit (TRU)**; the blog post is
authored by **Saeed Abbasi**, and the technical advisory credits model-assisted
kernel analysis performed with **Anthropic**. Upstream fix `2f4acd0fcd86`. See
`NOTICE.md`.