Commit Graph

2 Commits

Author SHA1 Message Date
KaraZajac 5c18b678a5 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
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
2026-07-23 18:45:32 -04:00
KaraZajac e46a32f11e modules: add refluxfs (CVE-2026-64600, "RefluXFS" XFS reflink CoW ILOCK race)
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
Adds the corpus's first XFS module and its first data-oriented kernel bug —
every other kernel entry corrupts memory; this one corrupts file contents.

xfs_direct_write_iomap_begin() reads the data-fork extent map under ILOCK,
then xfs_reflink_fill_cow_hole() drops ILOCK to wait for transaction log
space. On reacquiring it, the code re-queries the refcount btree at the
ORIGINAL imap->br_startblock and never re-reads the data fork. A second
O_DIRECT writer holding only IOLOCK completes a whole CoW cycle in that
window, so the first writer's stale mapping sees refcount 1, treats a
still-shared block as private, and writes to it in place — landing its data
on the reflink source file's on-disk blocks.

The primitive is an arbitrary overwrite of the on-disk contents of any
readable file, which has three consequences that drive the design:
  - No offsets, no ROP, no KASLR/SMEP/SMAP; SELinux, containers and seccomp
    are all irrelevant.
  - The victim's inode is never written, so mtime/ctime/size never change
    and nothing is logged — FIM and `-w /etc/passwd -p wa` cannot see it.
  - The change persists across reboots.

Introduced 4.11 (3c68d44a2b49); fixed 2f4acd0fcd86 (mainline 7.2-rc4,
merged 2026-07-16), stable backports 7.1.4 / 6.18.39 / 6.12.96. Exposure is
distro-shaped: RHEL/CentOS/Rocky/Alma/Oracle/CloudLinux 8-10, Fedora Server
>= 31 and Amazon Linux 2023 ship XFS+reflink by default.

detect() is not a pure version gate — reachability here is safely
observable, so it pairs the backport table with a real storage precondition
(writable XFS via statfs XFS_SUPER_MAGIC, deliberately not via a successful
FICLONE since btrfs implements that too and is unaffected). --active
confirms reflink via FICLONE; SKELETONKEY_XFS_ASSUME_REFLINK=1/0 overrides.
On rpm-family hosts it warns that vendors backport without bumping the
upstream version, so the verdict speaks only to the upstream base.

exploit() forks a child that works only in a private mkdtemp scratch dir on
two files it owns: it establishes a shared extent (FICLONE, corroborated by
FIEMAP_EXTENT_SHARED) plus an O_DIRECT gate, then races a hard-bounded
8 writers / 2 helpers / 16 rounds / 2s and stops, reading the donor back
with O_DIRECT. Deliberately under-driven, and it never clones or targets a
file it does not own — the /etc/passwd overwrite -> su -> root step is
documented but NOT bundled. Always returns EXPLOIT_FAIL.

Safety rank 55, far above bad_epoll (12) and ghostlock (11): a won race
corrupts 4 KiB of our own scratch file and cannot touch kernel memory, so
there is no oops/KASAN/panic path.

Detection inverts the usual advice. auditd/sigma anchor on ioctl request
0x40049409 (FICLONE, matched exactly) and openat O_DIRECT; falco adds the
cross-uid reflink condition; and the yara rule is genuinely the right tool
here, matching the on-disk artifact because FIM is structurally blind.

VM-VERIFIED 2026-07-23 — the corpus's first rpm-family verification, taking
the empirical count to 29 of 41 CVEs. Rocky Linux 9.8 /
5.14.0-687.10.1.el9_8.0.1.x86_64 under qemu/KVM, stock GenericCloud layout
with no provisioner changes (root is XFS with reflink=1 out of the box).
detect() -> VULNERABLE, --active FICLONE witness confirmed reflink, phase A
observed FIEMAP_EXTENT_SHARED on a real shared extent, scratch self-cleaned,
clean build on el9 gcc. The underlying bug was separately confirmed winnable
on that kernel via tools/verify-vm/refluxfs_verify.c at the public PoC's
parameters (32 writers / 8 helpers, 60s): 4/4 runs won, first divergence
after 69/114/170/494 rounds. The shipped under-driven trigger did NOT win in
its 2s budget on that same vulnerable kernel — intended behaviour, and
exactly why a non-win must never be read as "patched".

14 new detect() unit rows (148 tests total, 0 failures). Bumps to v0.9.14.

Credit: Qualys Threat Research Unit (blog by Saeed Abbasi; the technical
advisory credits model-assisted kernel analysis performed with Anthropic),
and the upstream XFS maintainers who fixed it.

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