Published
- 32 min read
CVE-2026-64600 (RefluXFS): An XFS Reflink Race That Defeats SELinux, seccomp, and Container Isolation
Stay Safe Online Without Making It Your Second Job
The Digital Fortress (Second Edition)
A warm, plain-English guide for people with real lives and finite patience. Learn the handful of habits that genuinely protect your money, accounts, and family, and get honest permission to ignore the rest.
For People Who Cannot Afford to Get Privacy Wrong
The Anonymity Playbook (Second Edition)
A practitioner’s field manual for journalists protecting sources, whistleblowers, and activists. It explains how the surveillance actually works, what each technique costs you, and exactly where it fails.
Write, Ship, and Maintain Code Without Shipping Vulnerabilities
Secure Software Development
A hands-on security guide for developers and IT professionals who ship real software. Build, deploy, and maintain secure systems without slowing down or drowning in theory.
Use AI Coding Agents Without Losing Control of Your Codebase
The Secure Harness
A calm, practical guide to letting agents do useful work inside boundaries you set, enforce, and audit. Ships with 15 copy-pasteable artifacts: hook scripts, permission configs, release gates, and MCP templates.
Stop Shipping Demos. Start Shipping Systems.
The AI Native Engineer
Sixteen hands-on chapters, one real product. Grow it from a single model call into a retrieved, tool-using, observable, production-grade system, with evaluation treated as a habit from the first feature.
Why this one is worth reading carefully
CVE-2026-64600, published in late July 2026 under the name RefluXFS, is a race condition in the copy-on-write path of the Linux XFS filesystem. Concurrent O_DIRECT writes against reflinked files can be raced so that an unprivileged local user overwrites the contents of any file they can read.
Three properties make it more serious than the average local privilege escalation. The write lands at the block layer, beneath the filesystem’s allocation bookkeeping, so the usual mediation points never see it. The kernel produces no log record of the anomalous write. And the result persists across reboots, because the modification is on disk rather than in memory.
Affected kernels run from v4.11, released in 2017, wherever XFS reflink is enabled. That covers default configurations on RHEL, Oracle Linux, Amazon Linux, and Fedora, which vendors have sized at roughly 16.4 million systems.
The mechanism
Reflink is XFS’s implementation of copy-on-write file cloning. Two files share the same physical extents until one is written to, at which point the kernel allocates new blocks for the modified region and updates the writing file’s extent map. The shared data stays intact for the other file. It is the mechanism behind cp --reflink, and it is how container image layers and snapshot workflows avoid duplicating data.
The correctness requirement is that the decision to break sharing and the write itself must be atomic with respect to other operations on the same extents. The remapping is bracketed by locks intended to hold that invariant.
O_DIRECT bypasses the page cache and issues I/O straight to the block layer, which takes a different path through the code with different locking. Issuing direct writes concurrently against a reflinked region opens a window where one thread has resolved a block address for a shared extent while another thread’s copy-on-write break is still in flight. The first write then lands on the extent that the second thread has already remapped to a different file’s data. Physical block, wrong file.
The exploitation requirement is a reflinked file whose target extents you want to overwrite, plus enough race attempts to hit a window measured in microseconds. Both are cheap. Local attackers get unlimited attempts and a failed attempt is not noisy.
Inside the extent map and the CoW remap sequence
The account above stays at the level of threads and windows. One layer down, in the on-disk structures XFS keeps for every file, the race has a precise anatomy worth walking through.
XFS records each inode’s block layout in structures called extent forks. Every inode has a data fork that maps the file’s logical offsets to physical blocks on the device. A reflinked inode also carries a copy-on-write fork, which holds blocks reserved for the moment a shared region gets modified. Alongside the forks, a per-allocation-group reference-count btree tracks how many inodes point at each physical extent, so the allocator can tell that an extent is shared and must be copied rather than written in place.
A write to a shared region triggers a remap with a fixed shape. The kernel reads the data-fork mapping to locate the shared physical block, allocates a fresh extent recorded in the CoW fork, writes the new data into it, then swaps that extent into the data fork and decrements the reference count on the old block. Every step of that exchange has to look atomic to any other thread reading or writing the same offsets.
Guarding the extent forks is the inode’s internal lock, the ILOCK, held exclusively for a modification. The defect hides in how long that lock stays held. Allocating the XFS transaction that journals the remap can block waiting for log space, and sleeping on log space while holding the ILOCK risks deadlock. The helper therefore drops the ILOCK, allocates the transaction, and takes the ILOCK again. That drop-and-retake is the seam the exploit pries open.
When the lock comes back, the vulnerable code refreshes its view of the CoW fork but does not resample the data-fork mapping it read before the gap. A second thread that remapped the same range during the gap has already moved the physical block to another file. The stale mapping still names the old address, so the reference-count lookup and the write that follows both operate on a block that no longer belongs to this file.
The upstream fix, described as resampling the data-fork mapping after cycling the ILOCK, re-reads that mapping once the lock is regained, so a remap that slipped in during the gap is detected before anything is written. Everything downstream of that stale read is how a block lands in the wrong file.
Where direct I/O diverges from the buffered path
The remap seam exists on any write path, yet the reproduction recipe insists on O_DIRECT. The reason is in how differently the two write paths treat the mapping they just read.
A buffered write copies data into the page cache, marks the folios dirty, and lets writeback flush them later. On its way to disk, the iomap layer revalidates each folio against a sequence cookie recorded when the mapping was resolved; if the extent map changed underneath, the cookie no longer matches and the folio is remapped before it is written. That revalidation is a second chance to notice a shifted block.
A direct write skips the page cache and hands the I/O straight to the block layer. The path is shorter and issues the transfer against the block address resolved for the range, with a narrower recheck than the folio-by-folio cookie comparison on the buffered side. Three locks frame the surrounding work: the inode’s i_rwsem (historically the IOLOCK) serialises reads and writes against each other, the invalidate_lock (historically the MMAPLOCK) serialises page faults and cache invalidation, and after the transfer the kernel invalidates any lingering page-cache pages over the range through kiocb_invalidate_post_direct_write so a later buffered read cannot return stale bytes.
| Property | Buffered write | Direct (O_DIRECT) write |
|---|---|---|
| Page cache | Data staged in cache, flushed by writeback | Bypassed, transfer goes to block layer |
| Mapping revalidation | Per-folio sequence cookie before writeback | Resolved once, narrower recheck |
| Serialisation during transfer | Cache and writeback locks | i_rwsem plus block-layer submission |
| Post-write cache handling | Already coherent through the cache | Explicit invalidation of stale pages |
| Exposure to the remap seam | Usually caught or serialised | Window reachable with ordinary syscalls |
Put the two together and the reason for the recipe is plain. A buffered writer racing the remap tends to be serialised or corrected by the cookie check before the stale address reaches disk. Two direct writers against the same reflinked range have enough of that margin removed that the window in the ILOCK cycle becomes hittable with open, pwrite, and a modest number of attempts.
What makes the race practical to win
A window measured in microseconds sounds unwinnable until you count how cheaply an attacker manufactures attempts and how much the gap can be stretched. The economics point one way, and it is toward the attacker on every axis that matters.
Consider each factor a defender would hope narrows the odds, and why it does not.
- Unlimited retries. A losing attempt corrupts nothing and raises no alert, so the exploit simply loops. Millions of attempts cost seconds of CPU and leave no forensic residue to trip over.
- A widenable gap. The ILOCK is released precisely while the remap waits for log space. An attacker who first pushes the journal toward contention, with a burst of small metadata operations against a nearly full log, lengthens that wait and stretches the window along with it.
- True parallelism. Pinning the two racing threads to separate cores removes scheduler serialisation and lets them overlap for real, and raising their priority sharpens the timing of the collision.
- Ordinary primitives.
open,pwritewithO_DIRECT, and the clone ioctl are all unprivileged calls available in any useful sandbox. Nothing in the recipe needs a capability, a special mount, or a kernel address. - A self-checking loop. After each attempt the attacker reads the target file back and compares it, so the loop knows the moment it has won and stops there.
Put those together and the timing works against the defender. Treating the narrow window as a mitigation is a mistake, because the attacker sets both the number of tries and, in part, the width of the window. The correct mental model is that the race resolves in the attacker’s favour on a long enough run, and every host that meets the three preconditions is on that long run by default.
The one genuine friction is workload noise. On a busy database or virtualisation host the same direct-I/O and copy-on-write traffic that produces the false positives from the detection section also perturbs the attacker’s timing, so a quiet host is actually the easier target. That inversion, where the machine with the least legitimate reflink activity is the most reliable to attack, is worth keeping in mind when triaging which hosts to reach first.
Why the usual controls do not engage
The list of mitigations that fail is the part worth internalising, because it says something about where each control actually sits.
KASLR randomises kernel address layout to frustrate exploits that need to know where kernel structures live. This exploit does not corrupt kernel memory and never needs a kernel address.
SMEP and SMAP prevent the kernel from executing or accessing userspace pages, defeating a large family of exploitation techniques. Again, no kernel code execution is involved. The kernel is doing exactly what it was asked to do, with a block address that is wrong.
SELinux and AppArmor mediate at the VFS layer. They evaluate whether this subject may open this object with these permissions, and the answer during the attack is yes, legitimately: the attacker opens files they are permitted to open. The mislabelled write happens after that decision, at a layer where no label exists.
seccomp filters syscalls. The syscalls used are open, pwrite, and ioctl with ordinary arguments, all of which any useful sandbox must permit.
Container isolation partitions namespaces and cgroups. Both are constructs above the block layer. A container writing through this bug reaches blocks belonging to the host filesystem or to a sibling container, because the shared storage underneath was never namespaced in the first place.
The general principle: every control in that list operates on the abstraction of files and processes, and the exploit operates on the abstraction of blocks. A control cannot mediate a layer it does not observe.
Mapping the control stack against the block layer
The walk through failed mitigations reads as a clean sweep for a reason that a diagram makes obvious. Every control named there sits at or above the VFS boundary, and the write that does the damage happens below it.
The controls cluster in the top half of that stack; the mislabelled write commits in the bottom half. A control cannot mediate a block it never sees as a block. Laid out as a table, each row answers the same question from a different angle.
| Control | What it checks | Layer it sits at | Why it misses RefluXFS |
|---|---|---|---|
| SELinux / AppArmor | Subject may open object with these rights | VFS | Open is legitimate; the bad write happens after the check |
| seccomp | Which syscalls a process may issue | Syscall boundary | open, pwrite, ioctl are all permitted |
| KASLR | Hides kernel address layout | Kernel memory | Exploit needs no kernel address |
| SMEP / SMAP | Blocks kernel use of user pages | CPU and kernel memory | No kernel code execution occurs |
| Namespaces / cgroups | Partition process view and resources | Above block layer | Shared backing store was never namespaced |
| inotify FIM | File change notifications | VFS | Modification arrives beneath the hook |
| auditd | Syscall audit records | Syscall boundary | Sees ordinary writes to owned files |
Counting controls by product name gives seven layers of defence. Counting them by the assumption they rest on gives one, that enforcement above the block layer is enough, and this bug falsifies that single assumption for all seven at once.
Detection is close to hopeless
Do not build your response plan around catching this in telemetry.
No kernel log entry is generated, because from the kernel’s perspective nothing exceptional happened. Auditd records the syscalls, which look like a process writing to files it owns. File integrity monitoring based on inotify does not fire, since inotify hooks the VFS layer and the modification arrives beneath it. Even a tool computing periodic hashes only tells you a file changed between two scans, with no attribution and no timestamp.
The one signal with any value is behavioural rather than forensic. A process making a large volume of O_DIRECT writes against reflinked files is unusual outside database and virtualisation workloads, and on a general-purpose host it is worth a look. That is a weak detection and it is what is available.
# Which filesystems have reflink enabled. "reflink=1" is the exposure.
for dev in $(lsblk -nro NAME,FSTYPE | awk '$2=="xfs"{print "/dev/"$1}'); do
printf '%s: ' "$dev"
xfs_info "$dev" 2>/dev/null | grep -o 'reflink=[01]' || echo 'unknown'
done
# Files currently sharing extents, which is the precondition for the race.
# Non-empty output on a host that does no snapshotting deserves a question.
filefrag -v /path/to/suspect/file 2>/dev/null | grep -i shared
Observing the I/O path with eBPF and audit
Forensic detection after the fact is a losing game, as the previous section argued. Live tracing does a little better, because the one behavioural tell, direct writes landing on reflinked files, can be watched as it happens. None of the probes below prove exploitation; they narrow the set of processes worth a second look.
bpftrace can attach to the XFS functions that sit on exactly these paths. Counting direct writes per process, and correlating them with active copy-on-write breaks, turns the abstract signal into something you can rank.
# Count direct-I/O writes to XFS, grouped by process
bpftrace -e 'kprobe:xfs_file_dio_write { @dio[comm, pid] = count(); }'
# Correlate CoW allocation with direct writes over 10-second windows.
# A process high in both maps is the profile of interest.
bpftrace -e '
kprobe:xfs_reflink_allocate_cow { @cow[comm] = count(); }
kprobe:xfs_file_dio_write { @dio[comm] = count(); }
interval:s:10 { print(@cow); print(@dio); clear(@cow); clear(@dio); }'
Auditd cannot see the block-level write, but it can flag the clone ioctls that create the shared extents the race depends on. Expect volume on any host that snapshots.
# Flag FICLONE ioctls (request 0x40049409). Pair with a baseline of known cloners.
-a always,exit -F arch=b64 -S ioctl -F a1=0x40049409 -k reflink_clone
Ranking the options by what they actually catch keeps expectations honest.
| Signal | Mechanism | Catches the bad write? | False-positive load |
|---|---|---|---|
| inotify FIM | VFS change notifications | No, write is below VFS | Low |
| Periodic hashing | Compare digests between scans | Change only, no attribution or time | Low |
| auditd syscalls | Audit open, pwrite, ioctl | No, looks like owned writes | Medium |
bpftrace dio+cow | Kprobe correlation | Behavioural hint only | High on DB and VM hosts |
| dm-verity / IMA | Block or file integrity at read | Detects tamper on read-only volumes | Low |
Treat every row as a tripwire around workloads you already have reason to watch, not a net you can throw across the whole fleet and trust.
Turning an arbitrary write into root
The primitive is an arbitrary overwrite of any file the attacker can read. Several routes lead from there to root, and knowing them helps with impact assessment when someone asks whether this is really exploitable in your environment.
Overwriting a SUID binary is the most direct. Any file with the setuid bit that the attacker can read, and there are several on a default installation, becomes a program that executes attacker-controlled code as its owner. Replacing the contents of a root-owned SUID binary yields root on the next execution.
Overwriting /etc/passwd works where the file is world-readable, which is the norm. Adding an entry with a known password hash and UID 0 creates a root account. This route is noisy in the sense that the file is easy to check afterwards, and completely silent at the moment it happens.
Configuration files that root parses are the subtler path. A cron file, a systemd unit, a shell profile sourced by a root login, or a sudoers.d fragment all cause code to execute with elevated privilege at a predictable time. This route is favoured when the attacker wants the compromise to survive scrutiny, since the modified file is plausible-looking configuration rather than a corrupted binary.
In a container context the target set changes but the outcome does not. Overwriting a binary in a base image layer shared with other containers, or a file in a volume mounted by a more privileged workload, reaches outside the container without ever touching the namespace machinery.
The relevant point for impact assessment is that no single file needs protecting, because the primitive works against any readable file and a modern system has dozens of viable targets. Mitigations that harden one path do not reduce exploitability.
Threat model by deployment shape
Impact from a single primitive is far from uniform. The same arbitrary overwrite is a minor local nuisance on a one-person laptop and a full cross-tenant breakout on a shared node, and the deployment shape decides which end of that range you land on. Working the common shapes shows who belongs at the front of the patch queue.
- Multi-tenant Kubernetes. Pods on a node share that node’s kernel, and if the storage driver sits on reflinked XFS (overlay2 on an XFS root with
reflink=1, or a CSI volume on reflinked storage) they share the backing filesystem too. A pod with any code execution reaches host files or a co-scheduled pod’s files without touching a namespace. Worst case, and common. - CI runners. Running untrusted code is the whole job. A pull request from anyone can execute in the runner, and fleets that mount a warm cache volume on XFS hand that code a reflinked filesystem to race. Local code execution is not a hurdle here; the runner exists to provide it.
- Shared build and multi-user hosts. Several engineers hold shell accounts on one machine. A single compromised account escalates to root, and from root to every other account’s work and keys.
- Hosting providers and shared web hosts. Tenants share a filesystem by design. The primitive crosses account boundaries the same way it crosses container boundaries, quietly and beneath the isolation that was sold.
- Developer workstations. One user, so no tenant line to cross, yet untrusted code still arrives through package post-install scripts and build steps, and a developer box holds cloud credentials, signing keys, and source.
The table condenses the ordering.
| Deployment shape | Who runs untrusted code | Shared-FS boundary crossed | Blast radius | Priority |
|---|---|---|---|---|
| Multi-tenant Kubernetes | Any tenant pod | Yes, host and siblings | Node-wide | Highest |
| CI runners | Every submitted job | Sometimes, cache volumes | Runner and its secrets | Highest |
| Shared build / dev hosts | Any shell user | Yes, all local users | Whole host | High |
| Hosting / shared web | Any tenant | Yes, all accounts | All hosted sites | High |
| Developer workstation | Dependencies, build scripts | No | Single user, valuable creds | Medium |
The pattern across the table is that severity tracks how many mutually untrusting parties share one filesystem, so the hosts that were built to pack many tenants densely are the same hosts that turn this bug into a breakout.
Assessing whether you are affected
Three questions determine exposure, and they are all answerable in a few minutes per host.
Is the kernel within the affected range and unpatched. Vendors have published fixed versions; the range starts at v4.11 and the practical question is whether your running kernel predates your distribution’s fix.
Is XFS in use with reflink enabled. Reflink became the default for new XFS filesystems on most distributions some years ago, so the answer on a recent installation is usually yes, and older filesystems created before that default may not have it.
Can untrusted code execute locally. This is the question that converts a theoretical exposure into a real one, and the honest answer is broader than most people first assume. Any multi-tenant workload, any CI runner, any service with a code execution path, any host where users have shell access, and any developer machine running dependencies from public registries.
# Running kernel against the distribution's fixed version.
uname -r
# Debian/Ubuntu
apt list --installed 2>/dev/null | grep -E '^linux-image'
# RHEL family
rpm -q kernel --last | head -3
# Reflink status per XFS filesystem, and whether any files share extents.
findmnt -t xfs -no TARGET,SOURCE | while read -r mnt src; do
printf '%-20s %-16s ' "$mnt" "$src"
xfs_info "$mnt" 2>/dev/null | grep -o 'reflink=[01]' || echo 'n/a'
done
Hosts answering yes to all three go at the front of the patch queue. Hosts answering yes to the first two and no to the third are still worth patching, on the grounds that “no local code execution” is a property that tends to change without anyone announcing it.
Fleet-wide assessment at scale
The three questions above answer exposure one host at a time. A fleet needs the same three facts, kernel build, reflink status, and local-code-execution exposure, gathered into a single list you can sort by risk. Ansible collects the raw facts cheaply.
# assess-refluxfs.yml: collect the facts, one row per host
- hosts: all
gather_facts: true
tasks:
- name: Running kernel
command: uname -r
register: kver
changed_when: false
- name: XFS mounts and reflink status
shell: |
findmnt -t xfs -no TARGET | while read -r m; do
r=$(xfs_info "$m" 2>/dev/null | grep -o 'reflink=[01]')
echo "$m ${r:-reflink=?}"
done
register: xfs_reflink
changed_when: false
- name: Emit a sortable row
debug:
msg: "{{ inventory_hostname }} | {{ kver.stdout }} | {{ xfs_reflink.stdout_lines | join(',') }}"
Facts on their own do not rank hosts. A small scorer applied per host turns them into a number you can queue on. The rubric below keeps the scoring explicit rather than buried in code.
| Signal | Points | Rationale |
|---|---|---|
Any mounted XFS with reflink=1 | +1 | The precondition for the race |
| Running kernel predates the fixed build | +1 | The bug is live, not merely present |
| Untrusted local code can run (tenant, CI, shell users) | +1 | Converts theory into a usable primitive |
#!/usr/bin/env bash
# score-host.sh: prints hostname,score,reasons
score=0; reasons=()
if findmnt -t xfs -no TARGET | while read -r m; do
xfs_info "$m" 2>/dev/null | grep -q 'reflink=1' && exit 0
done; then
score=$((score+1)); reasons+=("reflink")
fi
# Compare uname -r against a distro map of fixed builds you maintain.
if is_kernel_vulnerable "$(uname -r)"; then
score=$((score+1)); reasons+=("kernel")
fi
# Read an inventory tag you already keep for exposure.
if [ "${LOCAL_EXEC_EXPOSURE:-no}" = "yes" ]; then
score=$((score+1)); reasons+=("local-exec")
fi
printf '%s,%d,%s\n' "$(hostname)" "$score" "$(IFS=';'; echo "${reasons[*]}")"
A host scoring 3 is a cross-tenant escalation waiting to be used, and it belongs at the top of the first wave. A host scoring 2 with no local execution path still earns a patch, because that third property has a habit of becoming true without warning. Feed the resulting CSV into whatever tool already sequences your patch waves; the goal is one ranked list, not a spreadsheet per team.
Ephemeral fleets complicate the count. Autoscaling groups and Kubernetes node pools recreate hosts from a golden image, so a snapshot taken at noon can be stale by one o’clock, and a machine you scored as fixed may be replaced by a fresh instance booting the old kernel. The durable fix for that class of host lives in the image pipeline rather than the running fleet: rebuild the base image on a patched kernel, bake reflink=0 into the filesystem template where policy calls for it, and let the next scale-up roll the change out. Until the image is rebuilt, the assessment has to run on boot rather than on a schedule, tagging any instance that comes up exposed so it is drained before it takes tenant workloads. Treat the golden image as the real inventory item and the running hosts as disposable copies of it.
Response, in priority order
Patching and rebooting is the fix, and the ordering of the rollout is where judgement is required. A live kernel patch does not help here for most vendors, since the change touches locking in the I/O path, so plan for a reboot.
Multi-tenant systems come first. Any host where mutually untrusting workloads share a filesystem, whether that is a container platform, a CI runner fleet, a shared build server, or a hosting environment, converts this bug into a tenant-to-tenant compromise. That is the highest-severity configuration and it is common.
Internet-facing systems with any local code execution path come next. The bug needs local access, and a web application with a file upload feature, a template engine, or a dependency with a known RCE provides exactly that. Chaining a medium-severity application bug into root via a local escalation is the standard pattern, and this escalation is unusually reliable.
Developer workstations and build infrastructure follow, because they hold credentials and are exposed to untrusted code through dependencies. Everything else is ordinary patching.
Where a reboot genuinely cannot happen quickly, the interim measure is disabling reflink on filesystems that do not need it. That is not a live change: reflink is set at mkfs time and cannot be toggled on a mounted filesystem, so in practice the option is limited to new filesystems. For containers specifically, moving the container storage driver away from a reflink-enabled XFS backing store closes the tenant-to-tenant path, at the cost of the space savings that motivated the configuration.
Orchestrating reboots without a maintenance window
Priority ordering decides which hosts go first. Rebooting a fleet without an outage is a separate discipline, and it is where the limits of live patching start to matter.
Live patching is built on dynamic ftrace, which redirects execution at the entry of a traced function and swaps in a new body. That model handles a self-contained logic fix well. A change to locking discipline is harder, because tasks already executing inside the affected functions may hold locks under the old rules while the new rules take effect, and the consistency model that governs when a patch may activate cannot always prove that transition is safe. A fix that adds a resampling step around a lock cycle sits in exactly that awkward category, so for most vendors this one needs a reboot rather than a hot patch.
Rebooting at scale then becomes a scheduling problem with a few standard patterns.
- Kubernetes. Cordon, drain, reboot, uncordon, one node at a time, honouring PodDisruptionBudgets so quorum-based workloads keep quorum. A daemon such as
kuredwatches for a reboot-required flag and serialises the dance across the cluster. - Stateless fleets. Reboot in waves behind the load balancer, sized so capacity never drops below headroom.
- Faster individual reboots.
kexecboots the new kernel without the firmware self-test, cutting per-host downtime, at the cost of leaving firmware-level state in place.
A guarded reboot avoids cycling a host that has no staged fix yet.
# Reboot only when a newer kernel is installed than the one running
NEW=$(rpm -q --qf '%{VERSION}-%{RELEASE}.%{ARCH}\n' kernel | sort -V | tail -1)
if [ "$NEW" != "$(uname -r)" ]; then
systemctl reboot
fi
# Kubernetes: drain, reboot, then bring the node back
kubectl cordon "$NODE"
kubectl drain "$NODE" --ignore-daemonsets --delete-emptydir-data --timeout=300s
ssh "$NODE" 'sudo systemctl reboot' # kured can automate this cluster-wide
The decision tree keeps wave assignment consistent across the teams doing the reboots.
Where reboots have to wait, the interim options carry costs of their own, which the next section takes apart.
The cost of disabling reflink
Turning reflink off is the interim lever when a reboot has to wait, and it comes with a bill worth reading before you pull it. Reflink is fixed at mkfs time; you cannot toggle it on a mounted filesystem. In practice, disabling it means building a new filesystem with reflink=0 and migrating data onto it, or moving container storage to a different backing store entirely.
The saving that reflink bought is the saving you give back. cp --reflink falls back to full byte-for-byte copies, so clone operations get slower and consume real space. Container image layers that shared physical blocks through a reflinked overlay stop sharing, and disk usage grows with every layer that used to be free. Snapshot-heavy and virtual-machine-image workloads, golden images and qcow files cloned dozens of times, pay the steepest part of the bill.
Storage driver choice is the practical knob for container hosts, and each option trades exposure against space differently.
| Backing store | Uses XFS reflink | Exposure to this race | Space cost if you drop reflink |
|---|---|---|---|
overlay2 on XFS (reflink=1) | Yes | Exposed | Loses layer block sharing |
overlay2 on XFS (reflink=0) | No | Not exposed | Already paid, no reflink to lose |
| overlay2 on ext4 | No | Not exposed | Not applicable, ext4 has no reflink |
| devicemapper (thin) | No | Not exposed | Not applicable, block-level thin provisioning |
| btrfs | No, own CoW | Not this race | Not applicable, different CoW design |
| ZFS | No, own CoW | Not this race | Not applicable, different CoW design |
The row that matters is the first one, because it is usually the default that made the host fast and cheap in the first place. Moving off it closes the tenant-to-tenant path at a measurable storage cost, which is the trade to weigh against how long a reboot will really take.
Compensating controls while you wait to reboot
The controls that failed earlier all shared one blind spot at the block layer. A few structural choices do cut exposure, precisely because they change what is shared rather than what is checked. None of them substitutes for the patch; each buys time until the reboot lands.
Start with the sharing itself. The bug crosses a boundary only where two trust domains share one XFS filesystem, so the strongest compensations remove that shared substrate.
- Give each tenant its own kernel and filesystem. Sandboxed runtimes such as Kata Containers, backed by a lightweight virtual machine, or Firecracker microVMs, hand every workload a private kernel and a private block device. A co-tenant then has no host XFS to race against. gVisor takes a different route, intercepting syscalls in userspace, and does not expose the host reflink path to the guest, which closes the vector at a compatibility and performance cost.
- Do not co-locate workloads that distrust each other. Scheduling mutually untrusting jobs onto separate nodes, or at least onto separate filesystems on the same node, keeps a successful race inside one trust domain instead of letting it reach across tenants.
- Shrink the local-execution surface. The primitive needs local code execution, so every path you remove shrinks the attacker set: no interactive shell for service accounts, no upload-and-run features, and dependency pinning with provenance checks on CI so a poisoned package cannot execute in the first place.
- Mount shared base layers read-only. A read-only mount does not stop a block-level write, yet it removes the legitimate write path that lets an anomalous write blend in, which sharpens the one behavioural signal detection actually has.
The table weighs each option by how much it genuinely reduces exposure against what it costs to run.
| Compensating control | What it changes | How much it helps | Cost |
|---|---|---|---|
| microVM / Kata isolation | Removes shared kernel and filesystem | High | Density and performance |
| gVisor | Removes host reflink path from guest | High | Compatibility, some syscalls slower |
| Tenant separation by node or FS | Contains the blast radius | Medium | Capacity and scheduling flexibility |
| Reduce local execution | Fewer attackers reach the primitive | Medium | Workflow friction |
| Read-only shared mounts | Aids the weak detection signal | Low, indirect | Minimal |
Each of these is a choice you would defend on other grounds anyway, hardening tenancy, tightening CI, reducing standing shell access, which is what makes them reasonable to reach for in the days before every affected host has been cycled through a reboot.
Validating that the remediation held
A patch is finished when you can show it is finished, across the fleet and against the mechanism rather than a version string read in isolation. Four checks close the loop.
First, confirm the running kernel, not merely the installed one, matches the fixed build on every host. A machine that upgraded the package but never rebooted stays exposed, and that gap is the most common one after a patch wave.
# verify-fixed.sh: exit 1 if the RUNNING kernel predates the fix
FIXED="REPLACE-with-your-distro-fixed-build" # from the vendor advisory
RUN=$(uname -r)
if [ "$(printf '%s\n%s\n' "$FIXED" "$RUN" | sort -V | head -1)" != "$FIXED" ]; then
echo "EXPOSED: running $RUN, fixed build is $FIXED"; exit 1
fi
echo "OK: running $RUN"
Second, confirm reflink posture wherever you changed it. Volumes you migrated should report reflink=0, and container hosts you moved should show the new storage driver in effect.
findmnt -t xfs -no TARGET | while read -r m; do
xfs_info "$m" 2>/dev/null | grep -o 'reflink=[01]' | sed "s#^#$m #"
done
docker info 2>/dev/null | grep -i 'storage driver'
Third, if you keep a lab, run a controlled reproducer against the patched kernel and confirm the race no longer resolves in the attacker’s favour after a long run. A reproducer that won reliably on the old kernel and wins zero times across millions of attempts on the fixed one is the strongest evidence short of a formal proof.
Fourth, guard against drift. New hosts boot, old images get reused, and fresh filesystems get created with defaults that may reintroduce the precondition. A scheduled re-run of the assessment, alerting on any host that boots an unpatched kernel or mounts a reflinked volume against policy, keeps the fix from quietly eroding.
# /etc/systemd/system/refluxfs-drift.timer
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
Remediation that is verified and monitored stays fixed. The closing section is about why a bug at this depth earned that much care.
Confirming a suspected compromise after the fact
Real-time detection may be a losing game, yet a post-incident sweep still has teeth, because the arbitrary-write primitive has to land somewhere and most useful targets can be checked against a known-good baseline. If a host that met all three preconditions ran untrusted code before it was patched, treat the checks below as due diligence rather than paranoia.
- Verify package-owned files against the package database.
rpm -Va, ordebsumson Debian, flags any file whose on-disk content no longer matches what the package manager installed, which catches a tampered SUID binary or system library even though the write left no log. That single command is the highest-value check on the list. - Enumerate UID 0 accounts and recent credential changes. An extra root entry in
/etc/passwd, or an unexpected hash in/etc/shadow, is the crudest route the primitive enables, and the easiest to spot after the fact. - Audit the configuration that root parses.
sudoers.dfragments, cron files, systemd units, and shell profiles sourced by root are the quiet path, so diff each against version control or the golden image rather than reading them by eye. - Compare the tree against the golden image. A block-level modification shows up as a content difference even when the attacker preserved timestamps, so a full content diff beats any scan that trusts
mtime.
A handful of commands covers most of the ground.
# Files whose content diverges from what the package manager installed
rpm -Va 2>/dev/null | awk '$1 ~ /5/ {print}' # a "5" flag means content mismatch
debsums -c 2>/dev/null # Debian equivalent
# Unexpected UID 0 accounts
awk -F: '$3==0 {print $1}' /etc/passwd
# Root-parsed configuration changed in the last fortnight
find /etc/sudoers.d /etc/cron.d /etc/systemd/system -type f -newermt '-14 days' -print
Read the results with the right amount of caution. Finding nothing does not clear the host, because a careful attacker overwrites a plausible configuration file that runs once and then reverts itself, leaving the tree clean by the time you look. A positive rpm -Va result on a root-owned binary, on a host with no legitimate reason for package drift, is about as close to conclusive as this class of evidence gets.
Where this sits among filesystem and kernel races
RefluXFS is a new instance of an old category, and naming its relatives helps calibrate how much weight a storage-layer race deserves in a review.
Two recent bugs share its shape almost exactly. Dirty COW (CVE-2016-5195) was a race in the memory manager’s copy-on-write handling of private mappings that let an unprivileged user write to files they could only read. Dirty Pipe (CVE-2022-0847) came from an uninitialised page-cache flag that let writes into a pipe overwrite pages of read-only files. Different subsystems, same result: a write primitive against files the attacker holds only read access to, reached by racing or confusing a copy-on-write boundary. RefluXFS extends the pattern from memory-backed copy-on-write to on-disk copy-on-write, which is what buys it persistence across reboots.
For the mechanics, the kernel’s own documentation is the reference worth reading rather than any secondary summary:
- The iomap operations documentation (
Documentation/filesystems/iomap/) describes the buffered and direct paths and the sequence-cookie revalidation that the direct path partly forgoes. - The livepatch documentation (
Documentation/livepatch/) sets out the consistency model that explains why a locking fix resists hot patching. - The XFS design notes on the reference-count btree and reflink describe the on-disk structures the race manipulates.
On the defensive side, the frameworks a security review usually cites, the CIS Benchmarks, the Kernel Self-Protection Project, and MITRE ATT&CK’s privilege-escalation techniques, all concentrate on the layers above the block device. That focus is reasonable, and it is also the exact gap this bug drives through, which is worth stating plainly the next time one of those frameworks is offered as coverage. A race at the storage layer is rare enough to feel like an outlier and common enough, across a decade of examples, to belong on the list of things a serious threat model accounts for.
What to take from it structurally
Two lessons generalise past this CVE.
Defence in depth assumes the layers are independent. Here they were not: KASLR, SMEP, SMAP, SELinux, seccomp, and namespaces all share the assumption that enforcement above the block layer is sufficient, so a single bug below that layer bypasses all of them at once. When you count your controls, count how many distinct assumptions they rest on rather than how many products you have deployed.
Filesystem features that deduplicate or share physical storage between logically separate files create a channel between those files by construction. Reflink, block-level deduplication, and snapshot sharing all trade isolation for space, and the trade is usually correct. It is worth knowing you made it, particularly on hosts where the logically separate files belong to different tenants.
The vulnerability class here is not new. What is notable is how completely a bug at the wrong layer walks past a stack of controls that a security review would have counted as five independent mitigations.