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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3000 Β· 13 files
FileTypeDescriptionSize
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).

VERDICT.md
↓ download raw

DF-3000 VERDICT β€” NOT REPRODUCED (reachability blocker documented; source-level defect certain)

What was proven

  1. 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).

  2. 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)

    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_testable
per-fix-DF-3000

Confirmed 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.