hammer2_read_file ignores uiomovebp() error: user-triggered unkillable in-kernel infinite loop (read/readlink EFAULT livelock)
| Field | Value |
|---|---|
| ID | DF-2626 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-835 Loop with Unreachable Exit Condition |
| File | sys/vfs/hammer2/hammer2_vnops.c |
| Lines | 983 |
| Area | vfs |
| Confidence | certain |
| Discovered | 2026-08-28 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | hammer2 |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
The read loop in hammer2_read_file (used by both hammer2_vop_read and
hammer2_vop_readlink) discards the return value of uiomovebp() at :983.
When the copyout faults at the first byte (EFAULT), uiomove returns
without updating uio_resid/uio_offset (sys/kern/kern_subr.c:148-156), so
the loop condition at :926 never changes and the loop spins forever at 100%
CPU with the buffer cached. The thread never checks signals, holds
ip->truncate_lock SH and the vnode lock shared indefinitely β unkillable,
permanently blocks ftruncate on that file, and each additional call wedges
another CPU.
Root cause
vnops.c:983: uiomovebp(bp, bp->b_data + loff, n, uio); β result discarded,
unlike the write path (hammer2_write_file:1142 with break at :1145). On a
copyout fault at byte 0, kern_subr.c uiomove breaks out with error and
WITHOUT executing the uio_resid/uio_offset update block at :150-155, so
n, loff, uio_offset, uio_resid are identical on every iteration;
cluster_readx now hits the cached buffer (fast path), and the loop at
:926-985 never exits. Entry points: read(2) on a hammer2 regular file
(hammer2_vop_read:796-824) and readlink(2) on a hammer2 symlink
(hammer2_vop_readlink:779-792). A faulting buffer is any unmapped or
PROT_NONE user address, e.g. (void *)0x10000.
Threat model & preconditions
- Attacker position: any unprivileged local user with read permission on ANY regular file (or any symlink) on a hammer2 mount.
- Privileges gained or impact: one syscall = one permanently unkillable kernel thread at 100% CPU holding truncate_lock SH (blocks truncation of that file β cascading unkillable sleeps in ftruncate) and the vnode lock SH. Repeat in a fork loop to pin all CPUs; no recovery short of reboot.
- Required config or capabilities: none.
- Reachability:
read(fd, (void*)0x10000, n)or readlink equivalent.
Proof of concept
Build & run
cc -o h2readloop h2readloop.c # in findings/poc/DF-2626/
int fd = open("/h2/any_readable_file", O_RDONLY);
read(fd, (void *)0x10000, 4096); /* never returns, spins in kernel */
Expected output
process never returns from read(); top(1) shows a kernel-mode thread at 100% CPU; kill -9 has no effect; concurrent ftruncate of the same file hangs.
Impact
Unprivileged unkillable CPU-consuming kernel threads β full machine exhaustion (DoS); no info leak or corruption.
Recommended fix
Propagate the error like the write path does:
--- a/sys/vfs/hammer2/hammer2_vnops.c
+++ b/sys/vfs/hammer2/hammer2_vnops.c
@@ -980,7 +980,10 @@ hammer2_read_file(hammer2_inode_t *ip, struct uio *uio, int seqcount)
if (n > size - uio->uio_offset)
n = (int)(size - uio->uio_offset);
bp->b_flags |= B_AGE;
- uiomovebp(bp, bp->b_data + loff, n, uio);
+ error = uiomovebp(bp, bp->b_data + loff, n, uio);
bqrelse(bp);
+ if (error)
+ break;
}
hammer2_mtx_unlock(&ip->truncate_lock);
Timeline
- 2026-08-28 Discovered during automated audit (pass 2, GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2626 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| poc.c | β | trigger: read() into mmap(PROT_NONE, MAP_FIXED) page β guaranteed fault at byte 0 | 2.0 KB | view raw |
| readlink_poc.c | β | trigger: readlink() of a hammer2 symlink into PROT_NONE page | 1.1 KB | view raw |
| build.sh | β | exact build commands (guest cc) | 186 B | view raw |
| run.sh | β | exact run commands incl. timeout-based vulnerable/fixed discrimination | 673 B | view raw |
| build.log | β | build transcript with honesty note (in-guest log lost to final reset) | 1.0 KB | view raw |
| run.log | β | DECISIVE: single unprivileged reader β STAT R2, 100.0% CPU, wchan '-', survives two kill -9 | 3.4 KB | view raw |
| wedge1.log | β | same-file multi-reader (100% sys, getblk interleave) + tmpfs control (EFAULT returned, 'tmpfs_read uiomove error 14' on console) | 426 B | view raw |
| wedge2.log | β | 6 readers on 6 distinct hammer2 files β monitoring session truncated to header; vm.sh status down | 55 B | view raw |
| serial_wedge1.log | β | serial console mirror of run.log evidence (survives ssh death) | 3.2 KB | view raw |
| serial_wedge2.log | β | serial console: last line is the wedge-test header, then silence (full starvation, no panic) | 3.2 KB | view raw |
| serial_initial_wedge.log | β | first-ever incident: single reader with unmapped dest wedged box (pre-instrumentation) | 2.2 KB | view raw |
| readlink_stock.log | β | readlink vector on stock kernel: session died mid-script | 56 B | view raw |
| serial_readlink_stock.log | β | serial console of the readlink stock run | 2.3 KB | view raw |
| fix.diff | β | validated fix: propagate uiomovebp() error, bqrelse preserved, break on error (git apply -p1) | 383 B | view raw |
| fix_build.log | β | kernel rebuild transcript (verbatim poll excerpts; full log lost to final reset β flagged) | 2.0 KB | view raw |
| fix_run.log | β | patched kernel: exact same PoC binary returns -1/EFAULT in 0.00 s, exit 0 | 227 B | view raw |
| fix_readlink.log | β | patched kernel: readlink variant returns -1/EFAULT in 0.00 s | 209 B | view raw |
| fix_regress.log | β | patched kernel regression: 50/50 good reads, 50/50 EFAULTs, 6-file pattern leaves 0 spinners, 98.9% idle | 625 B | view raw |
| env.txt | β | guest environment (uname, mounts, users, targets) | 1.2 KB | view raw |
| VERDICT.md | β | full narrative: source trace, empirical runs, fix validation, honest notes | 7.1 KB | β raw |
| manifest.json | β | this catalog | 3.6 KB | view raw |
| verdict.json | β | machine verdict consumed by audit/persist_poc.py and audit/persist_fix.py | 5.7 KB | view raw |
DF-2626 β VERDICT (verify mode)
Status: REPRODUCED (impact: dos, confidence: certain). Fix authored and VALIDATED in-guest (fix_status: fixed).
1. Source analysis (all claims checked line-by-line)
- hammer2_vnops.c:926-985 β the read loop:
while (uio->uio_resid > 0 && uio->uio_offset < size). Inside, the onlybreakis on cluster_readx() failure (:971-974).nis min(lblksize-loff, uio_resid, size-offset) (:977-981) β always > 0 for a non-empty file at offset 0. The result ofuiomovebp(bp, bp->b_data + loff, n, uio)at :983 is DISCARDED; there is no error re-check and no n==0 break.errorat loop entry is whatever cluster_readx left (0 for a cached buffer). - kern_subr.c:117-156 β uiomove(): on
copyout()failure it executesif (error) break;at :148-149 BEFOREuio_resid -= cnt; uio_offset += cnt;at :152-153. A fault at the FIRST byte therefore leaves resid and offset completely unchanged and returns EFAULT. (The "uiomove advances resid before copyout" alternative reading in the claim is wrong β this ordering is exactly what makes byte-0 faults livelock the caller when the caller ignores the return.) - Consequently: fault at byte 0 + discarded EFAULT => loop predicate is unchanged => infinite in-kernel loop. The thread holds the vnode lock SH (kern/vfs_vnops.c:751, vn_lock(vp, LK_SHARED) around VOP_READ) and ip->truncate_lock SH (hammer2_vnops.c:922, released only at :986, i.e. never). It never sleeps and never returns to user mode, so a pending SIGKILL (delivered only at user-mode boundary or PCATCH sleep) can never be acted on: UNKILLABLE, 100% of one CPU per read() call.
- Reachability: any unprivileged user, any readable hammer2 file. The
guest's root filesystem is hammer2 (
vbd0s1d on / (hammer2, local)). readlink() is an identical vector via hammer2_vop_readlink (:779-790) -> hammer2_read_file. Note the WRITE path already checks the error (:1144-1147) β the read loop was simply missed.
2. Empirical reproduction (stock kernel #0, INVARIANTS, 6 vCPU)
Trigger guarantee: destination page mmap'd PROT_NONE at a fixed address β copyout faults deterministically at byte 0.
(a) Single read() [run.log, mirrored on serial console serial_wedge1.log]
pid 988 uid 1001 (unprivileged "maxx"): STAT R2, %CPU 100.0, wchan
-, top: CPU2 99.85% df2626poc; whole-box CPU states 16.6% system =
exactly 1/6 CPUs pinned in kernel mode. kill -9 returned 0 twice;
the process was STILL ALIVE AND SPINNING after both (checked at +3 s
and +7 s). No sleep, no panic β pure spin. This satisfies the
"unkillable in-kernel infinite loop" claim verbatim.
(b) readlink() vector [readlink_stock.log, serial_readlink_stock.log] Single unprivileged readlink() of /home/maxx/df2626link (hammer2 symlink) into the PROT_NONE page: the monitoring ssh session died mid-script and ~60 s later ssh failed with "Connection timed out during banner exchange" while the serial console stayed silent (no panic). The wedge cascades because the spinner holds truncate_lock/vnode lock SH forever while hammer2 flush/buffer-cache reclaim paths need progress.
(c) Whole-box starvation [wedge2.log, serial_wedge2.log]
Six readers on six DIFFERENT hammer2 files: the monitoring script
printed only its header line to the serial console, then nothing β
every CPU pinned by an unkillable spinner, interactive userspace
starved, ssh dead (vm.sh status => down), serial console silent for
30+ s. Same-file readers (7 on /etc/rc) instead interleave in getblk
(buffer-lock sleeps): CPU states 100% system, 0.0% idle but ssh
limps β documented in wedge1.log; distinct files remove the only sleep
point.
(d) Control (other filesystem) [wedge1.log]
Identical PoC against a tmpfs file: immediate read returned -1
errno=14, kernel console prints tmpfs_read uiomove error 14 β tmpfs
checks uiomove's return value and exits its loop. This isolates the
defect to hammer2's read loop, not to the VM fault path.
3. Fix validation (fix.diff)
fix.diff (git-apply-able, -p1): - uiomovebp(bp, bp->b_data + loff, n, uio); + error = uiomovebp(bp, bp->b_data + loff, n, uio); bqrelse(bp); + if (error) + break;
Applied to the guest's /usr/src, kernel rebuilt (make -j6 nativekernel KERNCONF=X86_64_GENERIC && make installkernel, hammer2_vnops.c compiled clean under -Werror), rebooted into "DragonFly 6.5-DEVELOPMENT #1: Fri Aug 28 20:29:47 UTC 2026".
baseline (stock #0): livelock as in (a)-(c). baseline_reproduced=1 patched (#1): same PoC binary (md5 ed5f88f79641d7b5f1e53ec714524016) returns -1/EFAULT in 0.00 s, exit 0 [fix_run.log]; readlink variant same [fix_readlink.log]. patched_reproduced=0 regression on patched: 50/50 good reads of /etc/rc fine, 50/50 bad reads return EFAULT; the exact 6-distinct-file pattern that wedged the stock kernel leaves "spinners left: 0" and the box 98.9% idle [fix_regress.log].
POSIX-semantics note: on a mid-buffer fault the fix returns EFAULT after a partial copy (uio already advanced); this matches tmpfs behavior and FreeBSD/DragonFly conventions for read(2) with a faulting buffer.
4. Honest deviations / notes
- The evidence-pack seed suggested
(void*)0x10000as the bad address; that address can be mapped, so the PoC uses mmap(PROT_NONE, MAP_FIXED) to GUARANTEE fault-at-byte-0. A plain bad pointer works equally well when it faults (first-ever incident log serial_initial_wedge.log used an unmapped address and also wedged the box). - DragonFly guest has no procstat(1) and no setsid(1); kernel-stack capture
was unavailable β the R-state/100%-CPU/wchan
-/unkillable evidence plus the source trace carry the proof. - The in-guest build log was destroyed by the final snapshot reset before it could be fetched; fix_build.log preserves the verbatim poll excerpts (hammer2_vnops.o -Werror clean, module install, BUILD_INSTALL_OK, kernel timestamp) β flagged as excerpt.
- The first-ever run (serial_initial_wedge.log) wedged the box from a single reader via an unmapped (not PROT_NONE-mmap'd) destination β same root cause, before instrumentation existed.
- Guest left CLEAN: final
vm.sh reset with-src; stock kernel #0; no spinners; ssh up (verified).
5. Impact classification
Local unprivileged user -> unkillable per-CPU kernel spin per read()/readlink() call; N distinct files = N CPUs; all CPUs = full system starvation (no panic, requires hard reset / power cycle of the VM or console intervention; even root cannot kill the offenders). This is a straightforward local DoS on the default root filesystem type β Medium severity as filed is appropriate (no memory corruption, no info leak, no privilege boundary crossing).
Fix verification
fixedfix.diff applied to guest /usr/src (patch -p1, hunk at :980), kernel rebuilt with make -j6 nativekernel KERNCONF=X86_64_GENERIC + make installkernel (hammer2_vnops.c compiled clean under -Werror), rebooted into kernel #1 (Fri Aug 28 20:29:47 UTC 2026). Baseline on stock #0: livelock reproduced (unkillable 100%-CPU spin; 6-file pattern wedges the box). Patched #1: the IDENTICAL PoC binary (md5 ed5f88f7...) returns -1/EFAULT in 0.00 s with exit 0 (fix_run.log), readlink variant likewise (fix_readlink.log), and regressions pass β 50/50 good reads of /etc/rc unchanged, 50/50 faulting reads return EFAULT, the 6-distinct-file wedge pattern leaves 0 spinning processes and the box 98.9% idle (fix_regress.log). The previously-observed bad behavior is GONE with no read-path regressions: fix_status=fixed.
fix_build.log (patch applied at :980; hammer2_vnops.o -Werror clean; BUILD_INSTALL_OK; kernel #1 20:29:47); fix_run.log (-1/EFAULT, 0.00 real, POC_EXIT=0); fix_readlink.log; fix_regress.log (good_reads=50 efault_reads=50, spinners left: 0, 98.9% idle)
Confirmed kernel references
Detail
Evidence (decisive lines)
["run.log: pid 988 uid 1001 STAT R2 100.0% CPU wchan '-' β '>>> STILL ALIVE after kill -9 <<<' twice; top 'CPU2 99.85% df2626poc'", "serial_wedge2.log: serial console ends at '=== DF-2626 WEDGE2 ... ncpu=6' then total silence; wedge2.log + vm.sh status => down (all-CPU starvation, no panic)", 'readlink_stock.log + serial_readlink_stock.log: single unprivileged readlink() wedged the stock box within ~60 s (ssh banner timeout, console silent)', "wedge1.log: tmpfs control β same PoC on tmpfs returns -1/EFAULT instantly, kernel console prints 'tmpfs_read uiomove error 14'", "fix_run.log: patched kernel #1 (20:29:47), identical binary md5 β 'read returned -1 errno=14' in 0.00 real, POC_EXIT=0", "fix_regress.log: good_reads=50 efault_reads=50; 'spinners left: 0'; CPU 98.9% idle after the 6-distinct-file pattern"]
PoC changes
Replaced seed trigger with deterministic fault-at-byte-0: mmap(PROT_NONE, MAP_FIXED) at 0x10000000 instead of a raw bad pointer ((void*)0x10000 can be mapped). Added readlink variant (same loop via hammer2_vop_readlink). Fixed the guest harness for DragonFly: no setsid(1)/procstat(1) on DF; su -m must not carry csh redirect syntax; backgrounded jobs need
Verified recommended fix
In hammer2_read_file(): capture the uiomovebp() result and break on error β 'error = uiomovebp(bp, bp->b_data + loff, n, uio); bqrelse(bp); if (error) break;' (see fix.diff).
Verdict
CONFIRMED on the stock guest (root fs hammer2): hammer2_read_file() (hammer2_vnops.c:926-985) discards uiomovebp()'s return at :983; on a user-page fault at byte 0 uiomove() (kern_subr.c:148-153) returns EFAULT without advancing uio_resid/uio_offset, so the loop predicate at :926 can never change and the thread spins in kernel mode forever holding the vnode lock SH (vfs_vnops.c:751) and ip->truncate_lock SH (:922). Empirically: an unprivileged read() of any readable hammer2 file into a PROT_NONE page pins one CPU at 100% system (STAT R, wchan '-') and the process SURVIVES two kill -9 attempts β unkillable. readlink() is an equal vector (:790). Six readers on six distinct hammer2 files starved all 6 CPUs: ssh dead, serial console silent, no panic β full system DoS requiring power cycle. tmpfs control returns EFAULT immediately (console: 'tmpfs_read uiomove error 14'), isolating the defect to hammer2's read loop. fix.diff (propagate the error, bqrelse preserved, break) was rebuilt in-guest (nativekernel) and validated: the identical PoC binary returns -1/EFAULT in 0.00 s on the patched kernel, with 50/50 good/bad read regression and the 6-file wedge pattern leaving 0 spinners and 98.9% idle. fix_status=fixed.
No comments yet.