β¬’ 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) 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
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 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.