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

Missing privilege check on BULKFREE_SCAN/ASYNC and DEBUG_DUMP ioctls β€” unprivileged DoS + kernel address leak via msgbuf

Summary

hammer2_ioctl.c:83 error=caps_priv_check(cred,SYSCAP_NOVFS_IOCTL). :144-146 BULKFREE_SCAN error=handler() unconditional NO if(error==0) guard. :147-149 BULKFREE_ASYNC same. :154-156 DEBUG_DUMP same. Every OTHER admin ioctl (DESTROY :151 GROWFS :162 PFS_CREATE/DELETE/SNAPSHOT :118/130/134 EMERG_MODE :158 VOLUME_LIST :166) guards with if(error==0). BULKFREE_SCAN: takes hmp->bflock EXCLUSIVE :1110 global sync every PFS :1125 full-media scan :1164 sustained DoS. DEBUG_DUMP: hammer2_dump_chain kprintf %p chain+parent :1042 flags=0xFFFFFFFF user-controlled recursive 100000 lines kernel pointers. security.unprivileged_read_msgbuf=1 (default subr_prf.c:126) + ptr_restrict=0 (default) sysctl kern.msgbuf reads KASLR bypass. Trigger: unprivileged open(/tmp/x) ioctl BULKFREE_SCAN or DEBUG_DUMP. Fix: if(error==0) error=handler() for all 3 cases.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0815 Β· 18 files
FileTypeDescriptionSize
df0815.c trigger-source unprivileged DEBUG_DUMP + BULKFREE_ASYNC ioctl probes (safe, non-DoS) 4.6 KB view raw
build.sh build-script cc -o df0815 df0815.c 141 B view raw
run.sh run-script ./df0815 /etc (as unprivileged maxx) 116 B view raw
README.md readme claim, reachability, impact, build/run, expected output 2.8 KB ↓ raw
VERDICT.md verdict full narrative + before/after fix validation 5.8 KB ↓ raw
build.log build-log final successful build, full output 64 B view raw
run.log run-log decisive unpatched run (DEBUG_DUMP rc=0) 415 B view raw
run.2.log run-log stress run 2 367 B view raw
run.3.log run-log stress run 3 367 B view raw
leak_sample.txt leak-sample kernel %p pointers harvested from msgbuf by maxx 808 B view raw
env.txt environment uname, cc, root-fs=hammer2, sysctls 398 B view raw
baseline_run.log run-log before: unpatched #0 PoC output (bug present) 525 B view raw
fix.diff suggested-fix if(error==0) guards on the 3 cases (git-apply-able) 885 B view raw
fix_build.log build-log single-fix kernel build, full output (NK_DONE rc=0) 5.6 MB ↓ download
fix_run.log run-log after: patched #1 PoC output (EPERM), +root sanity 769 B view raw
manifest.json manifest this catalog 3.4 KB view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme claim, reachability, impact, build/run, expected output
↓ download raw

DF-0815 β€” Missing privilege check on HAMMER2 BULKFREE_SCAN/ASYNC + DEBUG_DUMP ioctls

Claim (Medium, CWE-862 Missing Authorization)

hammer2_ioctl() (sys/vfs/hammer2/hammer2_ioctl.c:83) computes error = caps_priv_check(cred, SYSCAP_NOVFS_IOCTL); and every admin ioctl case guards its handler with if (error == 0). But three cases do not:

Line Case Code
144-146 HAMMER2IOC_BULKFREE_SCAN error = hammer2_ioctl_bulkfree_scan(ip, data);
147-149 HAMMER2IOC_BULKFREE_ASYNC error = hammer2_ioctl_bulkfree_scan(ip, NULL);
154-156 HAMMER2IOC_DEBUG_DUMP error = hammer2_ioctl_debug_dump(ip, *(u_int*)data);

They assign directly to error, ignoring the privilege check. An unprivileged user holding any fd on a hammer2 mount can invoke them.

Reachability on the audit guest

Root filesystem is hammer2 (vbd0s1d on / (hammer2, local)), so any fd on / (e.g. open("/tmp", O_RDONLY)) routes ioctls through vn_ioctl β†’ hammer2_vop_ioctl (hammer2_vnops.c:2264) β†’ hammer2_ioctl. No special device node, no mount permission β€” just an fd the unprivileged user already has.

Impact

  • DEBUG_DUMP β€” kprintfs kernel %p pointers (chain, parent, chain->data) of hammer2 internal chains via hammer2_dump_chain (hammer2_chain.c:5812-5829). With security.unprivileged_read_msgbuf=1 (default) the unprivileged user reads these via sysctl kern.msgbuf β†’ kernel address leak / KASLR bypass. flags is user-controlled (*(u_int*)data).
  • BULKFREE_SCAN β€” takes hmp->bflock EXCLUSIVE (:1110), syncs every PFS on the media (:1118-1130), then runs a full-media bulkfree scan (:1164). Sustained kernel work / DoS from an unprivileged user.
  • BULKFREE_ASYNC β€” same handler; would also DoS.

Build / Run

cc -o df0815 df0815.c
./df0815                 # as unprivileged user (uid 1001 maxx)

Expected output

  • Bug present (unpatched #0):
  • DEBUG_DUMP rc=0 (priv check bypassed; %p pointers appear in msgbuf)
  • BULKFREE_ASYNC rc=EINVAL (handler NULL-check at :1103, NOT EPERM)
  • Fixed (patched): both return EPERM (errno=1).

Notes on test discipline

  • BULKFREE_SCAN with real data is NOT exercised live β€” it would run the full bulkfree scan (DoS) and wedge the guest before fix-validation could proceed. Its identical missing if (error == 0) guard is proven by code inspection (hammer2_ioctl.c:144-145) and by the DEBUG_DUMP/ASYNC probes.
  • BULKFREE_ASYNC passes NULL internally β†’ handler returns EINVAL at :1103 without running the scan, making it a safe probe (EINVAL β‰  EPERM β‡’ priv check bypassed).
  • DEBUG_DUMP with flags=0 is safe (only top-level inode chains, no recursion).
VERDICT.md verdict full narrative + before/after fix validation
↓ download raw

DF-0815 β€” VERDICT

Verdict: REPRODUCED β†’ FIX VALIDATED

Missing privilege check on HAMMER2 BULKFREE_SCAN / BULKFREE_ASYNC / DEBUG_DUMP ioctls. Unprivileged local user β†’ kernel-address info leak (DEBUG_DUMP) + DoS vector (BULKFREE_SCAN/ASYNC). CWE-862 Missing Authorization. Medium severity.

Mechanism (trigger β†’ primitive β†’ effect)

hammer2_ioctl() computes the privilege gate once at the top:

/* sys/vfs/hammer2/hammer2_ioctl.c:83 */
error = caps_priv_check(cred, SYSCAP_NOVFS_IOCTL);

For an unprivileged caller, error becomes non-zero (would-be EPERM). Every admin ioctl case then guards its handler with if (error == 0) β€” e.g. DESTROY (:151), GROWFS (:162), EMERG_MODE (:158), PFS_CREATE/DELETE/SNAPSHOT (:126/131/134), VOLUME_LIST (:166).

But three cases assign to error directly, discarding the privilege result and running the handler regardless:

/* sys/vfs/hammer2/hammer2_ioctl.c:144-156 */
case HAMMER2IOC_BULKFREE_SCAN:
    error = hammer2_ioctl_bulkfree_scan(ip, data);      /* :145  NO guard */
    break;
case HAMMER2IOC_BULKFREE_ASYNC:
    error = hammer2_ioctl_bulkfree_scan(ip, NULL);      /* :148  NO guard */
    break;
...
case HAMMER2IOC_DEBUG_DUMP:
    error = hammer2_ioctl_debug_dump(ip, *(u_int *)data);/* :155 NO guard */
    break;

Reachability (unprivileged)

Root fs on the audit guest is hammer2 (vbd0s1d on / (hammer2, local)). Any fd on / routes ioctls through vn_ioctl (vfs_vnops.c:983, VDIR/VREG falls through to VOP_IOCTL at :1029) β†’ hammer2_vop_ioctl (hammer2_vnops.c:2264) β†’ hammer2_ioctl. So open("/etc", O_RDONLY) as uid 1001 is sufficient β€” no device node, no mount privilege, no vfs.usermount.

Effect

  • DEBUG_DUMP (flags user-controlled via *(u_int*)data): calls hammer2_dump_chain (hammer2_chain.c:5799) which kprintfs kernel %p pointers β€” chain (:5813), parent (:5827), and (with flags=0xFFFFFFFF) recurses up to 100000 lines. With security.unprivileged_read_msgbuf=1 (default), the unprivileged user harvests these via sysctl kern.msgbuf. Confirmed leak on the unpatched kernel: i-chain 0xfffff80116981080, p=0xfffff80116980f00 (live kernel heap addresses of hammer2_chain_t). β†’ kernel address leak / KASLR bypass.
  • BULKFREE_SCAN (real data): takes hmp->bflock EXCLUSIVE (:1110), syncs every PFS on the media (:1118-1130), then runs a full-media hammer2_bulkfree_pass (:1164). Sustained kernel work β†’ DoS from an unprivileged user. (Not exercised live β€” would wedge the guest; identical missing guard at :144-145 proves the same bypass.)
  • BULKFREE_ASYNC: same handler; with NULL data it returns EINVAL at :1103 without the scan β€” a safe probe that returns EINVAL (not EPERM), proving the priv check was bypassed.

Reproduction evidence (unpatched #0 kernel)

uid=1001(maxx) ...  ./df0815 /etc
[DEBUG_DUMP    ] ioctl rc=0 errno=0 (success)        ← priv check BYPASSED, handler ran
[BULKFREE_ASYNC] ioctl rc=-1 errno=22 (Invalid arg)  ← EINVAL not EPERM => priv check bypassed

msgbuf (read by maxx, unprivileged_read_msgbuf=1):

i-chain 0xfffff80116981080 inode.0  ...   ← leaked kernel pointer
      p=0xfffff80116980f00 [pflags 00046102 prefs 0]   ← leaked parent pointer

Why this is NOT memory corruption β€” no escalation chain

This is a privilege-check / authorization logic bug (CWE-862), not a memory- corruption primitive. There is no slab write/UAF/double-free to convert. The exploit chain deliverable here is the impact ceiling: unprivileged info leak of kernel heap addresses (KASLR defeat) + an unprivileged sustained-DoS vector (bulkfree scan). Demonstrated fully.

Fix (authored, validated)

Add the same if (error == 0) guard the other admin ioctls already use, to all three cases β€” see fix.diff. Minimal, targeted, matches the established in-file pattern. Matches the finding markdown's ## Recommended fix proposal verbatim ("if(error==0) error=handler() for all 3 cases").

Fix validation (Phase 8) β€” VALIDATED

Built single-fix kernel (make -j6 nativekernel, warm obj, ~6 min), installed over bare /boot/kernel/kernel (+ .debug), rebooted:

unpatched #0 (bug) patched #1 (fix)
kern.version #0: Thu Jul 2 06:02:54 UTC 2026 #1: Fri Jul 10 21:30:29 UTC 2026
DEBUG_DUMP (maxx) rc=0 errno=0 (success + leak) rc=-1 errno=1 (EPERM)
BULKFREE_ASYNC (maxx) rc=-1 errno=22 (EINVAL) rc=-1 errno=1 (EPERM)
DEBUG_DUMP (root sanity) n/a rc=0 (still works β€” privileged use preserved)

Clean before/after. Root can still legitimately use the ioctls; only the unprivileged path is now blocked. Fix is determinism-confirmed (2 runs).

PoC changes

The seeded PoC folder did not exist (no markdown/PoC pre-seeded for DF-0815). Authored fresh: df0815.c (trigger), build.sh, run.sh, README.md. Key correctness fix during iteration: the _IOWR macro encodes sizeof(arg-type) into the ioctl number, so BULKFREE_SCAN/ASYNC (which take struct hammer2_ioc_bulkfree, 64 bytes) MUST use a struct of that exact size, not int β€” otherwise the number mismatches and the kernel returns EOPNOTSUPP (default case) instead of reaching the handler. Also: the target path must be on the hammer2 mount (/etc, not /tmp which is tmpfs).

Files

  • df0815.c β€” trigger (DEBUG_DUMP safe probe + BULKFREE_ASYNC NULL probe)
  • build.sh / run.sh β€” exact repro
  • build.log / run.log / run.2.log / run.3.log β€” full untrimmed logs
  • leak_sample.txt β€” harvested kernel %p pointers from msgbuf
  • env.txt β€” guest environment
  • fix.diff β€” git-apply-able fix
  • fix_build.log β€” full single-fix kernel build output
  • fix_run.log / baseline_run.log β€” before/after contrast
  • manifest.json β€” catalog

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: on the unpatched 6.5-DEVELOPMENT #0 baseline, maxx's DEBUG_DUMP ioctl returned rc=0 (priv check bypassed, kernel pointers leaked to msgbuf) and BULKFREE_ASYNC returned EINVAL (not EPERM); on the single-fix #1 kernel (fix.diff applied, make -j6 nativekernel, installed over bare /boot/kernel/kernel + .debug, rebooted), the SAME PoC now returns EPERM (errno=1) for both ioctls as maxx, while root still gets rc=0 on DEBUG_DUMP (privileged use preserved). Confirmed deterministic across 2 runs. fix closes the bug.

BEFORE (unpatched #0, maxx): [DEBUG_DUMP] rc=0 errno=0 (success) | [BULKFREE_ASYNC] rc=-1 errno=22 (Invalid argument). AFTER (patched #1, maxx): [DEBUG_DUMP] rc=-1 errno=1 (Operation not permitted) | [BULKFREE_ASYNC] rc=-1 errno=1 (Operation not permitted). Root sanity (patched #1, uid=0): [DEBUG_DUMP] rc=0 errno=0 (still works).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Fri Jul 10 21:30:29 UTC 2026 (X86_64_GENERIC, amd64) [kernel.stripped sha256 1d5a8388986160a5778bd7f25c8374d1a7db10c1cb8813d81910de706e8c5093]

Confirmed kernel references

Detail

Exploit chain

none (privilege-check/authorization logic bug, CWE-862 -- not a memory-corruption primitive; no slab write/UAF/double-free to convert). Impact ceiling fully characterized: (1) unprivileged kernel-address info leak via DEBUG_DUMP (KASLR-bypass; flags is user-controlled (u_int)data so flags=0xFFFFFFFF recurses up to 100000 lines of %p pointers); on the demonstrated flags=0 run maxx directly harvested 2 live kernel heap pointers (0xfffff80116981080, 0xfffff80116980f00). (2) unprivileged sustained-DoS vector via BULKFREE_SCAN/ASYNC (takes hmp->bflock EXCLUSIVE :1110, syncs every PFS :1118-1130, full-media hammer2_bulkfree_pass :1164) -- proven by identical missing if(error==0) guard at :144-145; not exercised live to avoid wedging the guest before fix-validation.

Evidence (decisive lines)

BASELINE (unpatched #0, maxx): [DEBUG_DUMP] ioctl rc=0 errno=0 (success) -- priv check BYPASSED, handler ran; [BULKFREE_ASYNC] ioctl rc=-1 errno=22 (Invalid argument) -- EINVAL not EPERM => priv bypass. msgbuf (read by maxx): i-chain 0xfffff80116981080 inode.0 ... / p=0xfffff80116980f00 [pflags 00046102 prefs 0]. PATCHED (#1, maxx): [DEBUG_DUMP] ioctl rc=-1 errno=1 (Operation not permitted); [BULKFREE_ASYNC] ioctl rc=-1 errno=1 (Operation not permitted). Root sanity on patched: DEBUG_DUMP rc=0 (privileged use preserved).

PoC changes

The seeded PoC folder did not exist for DF-0815; authored fresh: df0815.c (trigger), build.sh, run.sh, README.md, VERDICT.md, manifest.json. Key correctness fix during iteration: the _IOWR macro encodes sizeof(arg-type) into the ioctl number, so BULKFREE_SCAN/ASYNC (which take struct hammer2_ioc_bulkfree = 64 bytes) must use a struct of that exact size, not int, or the number mismatches and the kernel returns EOPNOTSUPP (default case) instead of reaching the handler. Also target path must be on the hammer2 mount (/etc, not /tmp which is tmpfs). Added fix.diff (if(error==0) guards on the 3 cases).

Verified recommended fix

In sys/vfs/hammer2/hammer2_ioctl.c, wrap the three handler calls with the same if(error==0) guard every other admin ioctl in the function already uses: HAMMER2IOC_BULKFREE_SCAN (:145), HAMMER2IOC_BULKFREE_ASYNC (:148), HAMMER2IOC_DEBUG_DUMP (:155) become 'if(error==0) error=hammer2_ioctl_...(...)'. This restores the caps_priv_check(SYSCAP_NOVFS_IOCTL) gate computed at :83 so unprivileged callers get EPERM while root retains use. Matches the finding markdown's ## Recommended fix proposal verbatim. Full git-apply-able diff in findings/poc/DF-0815/fix.diff.

Verdict

REPRODUCED. hammer2_ioctl() at sys/vfs/hammer2/hammer2_ioctl.c:83 computes error=caps_priv_check(cred,SYSCAP_NOVFS_IOCTL) and EVERY admin ioctl guards with if(error==0), but the three cases HAMMER2IOC_BULKFREE_SCAN (:145), HAMMER2IOC_BULKFREE_ASYNC (:148) and HAMMER2IOC_DEBUG_DUMP (:155) assign to error directly, ignoring the privilege result. Root fs on the audit guest is hammer2 (vbd0s1d on /), so open('/etc',O_RDONLY) as uid 1001 routes ioctl -> vn_ioctl (vfs_vnops.c:1029) -> hammer2_vop_ioctl -> hammer2_ioctl. Confirmed: as maxx DEBUG_DUMP returns rc=0 errno=0 (priv check bypassed, handler ran) and leaks live kernel pointers to the msgbuf (hammer2_dump_chain kprintf %p chain/parent at hammer2_chain.c:5812/5827), readable via sysctl kern.msgbuf because security.unprivileged_read_msgbuf=1 default; BULKFREE_ASYNC returns EINVAL (not EPERM) => same bypass. Harvested i-chain 0xfffff80116981080 and p=0xfffff80116980f00 from the msgbuf as an unprivileged user.