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) | 149 B | view raw |
| run.sh | β | exact run commands incl. timeout-based vulnerable/fixed discrimination | 162 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.5 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 status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
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.