hammer_vop_write UIO_NOCOPY (putpages/msync) path: bqrelse(bp) followed by bread(&bp) makes breadnx reuse the RELEASED buffer; write-out then runs on an unlocked free-queued buffer (guaranteed panic('buffer is not busy') + buffer-recycle race window); identical pattern in hammer2
| Field | Value |
|---|---|
| ID | DF-3000 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-672 |
| File | sys/vfs/hammer/hammer_vnops.c |
| Lines | 690-696 (sinks :760-761, :824, :844; twin: hammer2_vnops.c:1103-1108) |
| Area | vfs/hammer |
| Confidence | likely |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
The UIO_NOCOPY branch of hammer_vop_write (used by vnode_pager_generic_putpages for msync/pageout of mmap'd files) does getblk(); on !B_CACHE it calls bqrelse(bp) (which unlocks and free-queues the buffer) and then bread(&bp); DragonFly's breadnx reuses a non-NULL *bpp instead of getblk()ing a fresh locked buffer, so hammer proceeds to uiomovebp + bwrite/bawrite/cluster_write/bdwrite on an unlocked, free-queued buffer. Every write-out sink begins with an always-on panic('buffer is not busy') check, and the bqrelseβvn_strategy window allows another CPU to recycle the buffer for a different vnode/offset. Same latent pattern at hammer2_vnops.c:1103-1108. Unpriv local user via mmap+msync/pageout on a HAMMER1 (and HAMMER2) file: if the covering 16K block buffer is not B_CACHE (requires the buffer to have been recycled and clean sibling pages reclaimed under memory pressure while a page stays dirty), the kernel panics deterministically or corrupts buffer-cache ownership in the recycle race. Raced five scenarios (sparse/fill dirty patterns, 256MB-1.2GB buffer churn, MADV_DONTNEED, 3GB anon pressure, concurrent children) β instrumented kernel logged ZERO branch firings (hammer's fault path pre-populates each 16K block B_CACHE with wired pages) β filed latent: not_reproduced with documented reachability blocker. Fix is a one-line bqrelse deletion.
Timeline
- 2026-09-02 Discovered during pass-2 audit of hammer_vnops.c (GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3000 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| op3000.c | β | 2.0 KB | view raw | |
| op3000b.c | β | 2.9 KB | view raw | |
| op3000c.c | β | 2.5 KB | view raw | |
| op3000d.c | β | 2.8 KB | view raw | |
| op3000e.c | β | 3.3 KB | view raw | |
| run_all.sh | β | 869 B | view raw | |
| probe.diff | β | 2.0 KB | view raw | |
| run.log | β | 930 B | view raw | |
| build.log | β | 615 B | view raw | |
| README.md | β | 3.3 KB | β raw | |
| VERDICT.md | β | 3.4 KB | β raw | |
| verdict.json | β | 3.2 KB | view raw | |
| manifest.json | β | 1.0 KB | view raw |
DF-3000 β hammer_vop_write() UIO_NOCOPY path: bqrelse(bp) followed by
bread(&bp) β breadnx() reuses the released buffer pointer; the
write-out then operates on an unlocked, free-queued buffer
(panic("bdwrite|bawrite|bwrite: buffer is not busy") if the branch is
ever reached)
Source chain (all always-on behavior, no INVARIANTS needed)
sys/vfs/hammer/hammer_vnops.c:682-696 (UIO_NOCOPY branch of hammer_vop_write, used by vnode_pager_generic_putpages() for msync/pageout of mmap'd files): bp = getblk(vp, base, 16K, GETBLK_BHEAVY, 0); /* locked */ if ((bp->b_flags & B_CACHE) == 0) { bqrelse(bp); /* UNLOCKS + free-queues */ error = bread(vp, base, 16K, &bp); } sys/kern/vfs_bio.c:908-911 (breadnx): if (*bpp) bp = *bpp; /* reuses stale pointer: does NOT getblk() a fresh locked buffer */ sys/kern/vfs_bio.c:1075-1091 (bqrelse tail): BUF_UNLOCK(bp) sys/kern/vfs_bio.c:986 (bdwrite): panic("bdwrite: buffer is not busy") sys/kern/vfs_bio.c (bawrite): panic("bawrite: buffer is not busy???") sys/kern/vfs_bio.c (bwrite): panic("bwrite: buffer is not busy???") hammer_vnops.c:824-844: after the branch, hammer unconditionally runs bwrite/bawrite/cluster_write/bdwrite on bp.
If the !B_CACHE branch is taken, the buffer hammer holds is unlocked and sits on a bufpcpu free queue; any of the write-out sinks panics (always-on), and the window between bqrelse() and the strategy call in breadnx() allows another CPU to recycle the buffer for a different vnode/offset (buffer-ownership corruption / cross-vnode data race). The identical pattern exists in sys/vfs/hammer2/hammer2_vnops.c:1103-1108 (bqrelse + bread_kvabio(&bp)) β same latent defect in HAMMER2.
Why the branch is hard to reach (verification result)
The putpages caller requires a dirty mmap'd page; the fault that dirtied the page went through hammer_vop_read()->getblk(16K)+bread, which leaves the full 16K block cached with B_CACHE and all pages wired. The branch therefore requires the covering buffer to have been recycled by getnewbuf() (clean-queue LRU) and the clean sibling pages (not pmap-mapped β read-ahead population) to have been reclaimed by the page daemon, while the dirty page stays. On the test guest (4GB RAM + swap) six increasingly aggressive scenarios never satisfied the conjunction; an instrumented kernel (probe kprintf in the branch, verified present in kernel.stripped) logged ZERO firings.
Files
op3000.c scenario A: sparse ftruncate + sparse dirty + MS_SYNC op3000b.c scenario B: fill + sparse dirty + 256MB churn + MADV_DONTNEED + MS_SYNC op3000c.c scenario C: all-pages dirty + 3GB anon pressure + MS_SYNC op3000d.c scenario D: 8 rounds re-dirty + hammer1 churn + anon + MS_SYNC op3000e.c scenario E: concurrent 1.2GB unique-write buffer demand + 84% RAM anon pressure + MS_SYNC run_all.sh runs A-E in order probe.diff the non-semantic probe patch used for the instrumented kernel run.log full output of A-E on the instrumented kernel (0 probe hits)
Build: cc -O -o op3000X op3000X.c β no warnings. Run as unprivileged user with a HAMMER1 mount at /mnt/h1 (vnconfig + newfs_hammer -f + mount -t hammer).
DF-3000 VERDICT β NOT REPRODUCED (reachability blocker documented; source-level defect certain)
What was proven
-
Source-level: the ownership violation is unambiguous. - bqrelse() (sys/kern/vfs_bio.c) ends with BUF_UNLOCK(bp) and leaves the buffer on a free queue. - breadnx() (vfs_bio.c:908-911) reuses a non-NULL *bpp instead of getblk()ing β so hammer's bread(&bp) after bqrelse(bp) performs I/O on, and hands back, an unlocked free-queued buffer. - Every write-out sink hammer then calls (bdwrite/bawrite/bwrite, hammer_vnops.c:824-844) starts with an ALWAYS-ON panic("... buffer is not busy...") when BUF_LOCKINUSE(bp)==0 β i.e. if the !B_CACHE branch is ever taken, the deterministic outcome is a kernel panic (plus a recycle race window for silent corruption).
-
Empirically: the branch was NOT reachable under six scenarios on the test guest, including with an instrumented kernel. Probe build (kernel #1/#2, kprintf in the branch β verified present via
strings kernel.stripped | grep DF3000) logged ZERO firings across:
A sparse ftruncate + sparse dirty + msync(MS_SYNC) B fill + sparse dirty + 256MB read churn + MADV_DONTNEED + msync C all-pages dirty + 3.05GB anon pressure + msync D 8 rounds of re-dirty + hammer1 read churn + growing anon + msync E concurrent 1.2GB unique pwrite buffer-demand + 84%-of-RAM anon pressure + msync
(full outputs in run.log; dmesg grep DF3000 = 0)
Why it does not fire (analysis)
Any page that putpages sees dirty was dirtied through a write fault; the fault ran hammer_vop_read()->getblk(base,16K)+bread which populated and B_CACHE'd the entire 16K block and wired its pages. The branch therefore needs, simultaneously: (a) the covering buffer recycled off the clean queue by getnewbuf() (only happens under new-buffer allocation pressure), (b) the clean sibling pages (populated by read-ahead, NOT pmap-mapped) reclaimed by the page daemon, and (c) the dirty page to still be pending putpages. On the 4GB guest with swap, (b) never materialized in the observation windows (swap absorbs anon pressure before cache pages of the target file are reclaimed; buffer-demand workloads didn't recycle the target's buffers in time). On a long-lived, memory-pressured production HAMMER1 system the conjunction is plausible β the code path exists precisely because its author expected putpages to encounter !B_CACHE blocks ("read-in any missing bits") β so this stays a real latent defect, but the honest verdict on this guest is not_reproduced.
Classification
status: not_reproduced | reproduced: 0 | impact: none (demonstrated) confidence: likely (defect certain from source; trigger conditional) severity of the filed finding: Medium (conditional local panic/DoS + buffer-ownership race window on HAMMER1; same pattern in sys/vfs/hammer2/hammer2_vnops.c:1103-1108)
Recommended fix (trivial, restores breadnx's ownership contract)
bp = getblk(ap->a_vp, base_offset, blksize, GETBLK_BHEAVY, 0);
if ((bp->b_flags & B_CACHE) == 0) {
- bqrelse(bp); error = bread(ap->a_vp, base_offset, blksize, &bp); }
bread()/breadnx() with a pre-acquired locked *bpp already handles the not-cached case by issuing the read on the caller's buffer β exactly what hammer wants β without releasing it. (Same one-line deletion fixes the HAMMER2 copy at hammer2_vnops.c:1106.)
Fix verification
not_testableConfirmed kernel references
Detail
Evidence (decisive lines)
README.md (source chain with vfs_bio.c line cites) | run.log (all six scenario outputs; dmesg DF3000-PROBE count = 0) | probe.diff (exact instrumented-kernel patch) | VERDICT.md (reachability analysis + one-line fix)
PoC changes
wrote five scenario programs (op3000.c..op3000e.c) attacking the !B_CACHE precondition from different angles (fill/sparse dirty, churn, madvise, anon pressure, concurrent buffer-demand children); plus a non-semantic kernel probe to observe branch reachability
Verified recommended fix
Delete the bqrelse(bp) before bread() in hammer_vnops.c:693 (bread/breadnx with a pre-acquired locked *bpp already issues the read on the caller's buffer); same deletion in hammer2_vnops.c:1106.
Verdict
Source-certain ownership violation, empirically unreachable in the test window. hammer_vop_write's UIO_NOCOPY branch (sys/vfs/hammer/hammer_vnops.c:682-696) does bqrelse(bp) (unlocks + free-queues the getblk'd buffer) and then bread(&bp); DragonFly's breadnx (vfs_bio.c:908-911) reuses a non-NULL *bpp instead of getblk()ing a fresh locked buffer, so hammer proceeds to uiomovebp/bwrite/bawrite/bdwrite (hammer_vnops.c:824-844) on an UNLOCKED, free-queued buffer - every one of those sinks begins with an always-on panic('buffer is not busy') check, so a single firing = guaranteed kernel panic, plus a real recycle race window (another CPU's getnewbuf can reassign the buffer between bqrelse and breadnx's vn_strategy). Six scenarios (sparse/fill dirty patterns, 256MB-1.2GB buffer churn, 3GB anon pressure, concurrent children, MADV_DONTNEED, 8 rounds) on an instrumented kernel (kprintf probe verified in kernel.stripped) produced ZERO branch firings: hammer's fault path pre-populates each 16K block as B_CACHE with wired pages, so the branch requires the conjunction of buffer-recycle AND clean-sibling page-reclaim AND a still-dirty page, which did not materialize on a 4GB guest with swap. Filed as a latent defect: the same pattern sits in sys/vfs/hammer2/hammer2_vnops.c:1103-1108.
No comments yet.