β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2626

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2626 Β· 22 files
FileTypeDescriptionSize
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
VERDICT.md full narrative: source trace, empirical runs, fix validation, honest notes
↓ download 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 only break is on cluster_readx() failure (:971-974). n is min(lblksize-loff, uio_resid, size-offset) (:977-981) β€” always > 0 for a non-empty file at offset 0. The result of uiomovebp(bp, bp->b_data + loff, n, uio) at :983 is DISCARDED; there is no error re-check and no n==0 break. error at loop entry is whatever cluster_readx left (0 for a cached buffer).
  • kern_subr.c:117-156 β€” uiomove(): on copyout() failure it executes if (error) break; at :148-149 BEFORE uio_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*)0x10000 as 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Aug 28 20:29:47 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.