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)
PoC verification
Evidence pack
findings/poc/DF-0815 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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%ppointers (chain,parent,chain->data) of hammer2 internal chains viahammer2_dump_chain(hammer2_chain.c:5812-5829). Withsecurity.unprivileged_read_msgbuf=1(default) the unprivileged user reads these viasysctl kern.msgbufβ kernel address leak / KASLR bypass.flagsis user-controlled (*(u_int*)data). - BULKFREE_SCAN β takes
hmp->bflockEXCLUSIVE (: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;%ppointers appear in msgbuf)BULKFREE_ASYNC rc=EINVAL(handler NULL-check at:1103, NOTEPERM)- Fixed (patched): both return
EPERM(errno=1).
Notes on test discipline
BULKFREE_SCANwith 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 missingif (error == 0)guard is proven by code inspection (hammer2_ioctl.c:144-145) and by the DEBUG_DUMP/ASYNC probes.BULKFREE_ASYNCpasses NULL internally β handler returns EINVAL at:1103without running the scan, making it a safe probe (EINVAL β EPERM β priv check bypassed).DEBUG_DUMPwithflags=0is safe (only top-level inode chains, no recursion).
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 (
flagsuser-controlled via*(u_int*)data): callshammer2_dump_chain(hammer2_chain.c:5799) whichkprintfs kernel%ppointers β chain (:5813), parent (:5827), and (withflags=0xFFFFFFFF) recurses up to 100000 lines. Withsecurity.unprivileged_read_msgbuf=1(default), the unprivileged user harvests these viasysctl kern.msgbuf. Confirmed leak on the unpatched kernel:i-chain 0xfffff80116981080,p=0xfffff80116980f00(live kernel heap addresses ofhammer2_chain_t). β kernel address leak / KASLR bypass. - BULKFREE_SCAN (real
data): takeshmp->bflockEXCLUSIVE (:1110), syncs every PFS on the media (:1118-1130), then runs a full-mediahammer2_bulkfree_pass(:1164). Sustained kernel work β DoS from an unprivileged user. (Not exercised live β would wedge the guest; identical missing guard at:144-145proves the same bypass.) - BULKFREE_ASYNC: same handler; with
NULLdata it returnsEINVALat:1103without the scan β a safe probe that returnsEINVAL(notEPERM), 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 reprobuild.log/run.log/run.2.log/run.3.logβ full untrimmed logsleak_sample.txtβ harvested kernel%ppointers from msgbufenv.txtβ guest environmentfix.diffβ git-apply-able fixfix_build.logβ full single-fix kernel build outputfix_run.log/baseline_run.logβ before/after contrastmanifest.jsonβ catalog
Fix verification
fixedVALIDATED 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).
Confirmed kernel references
- sys/vfs/hammer2/hammer2_ioctl.c:83
- sys/vfs/hammer2/hammer2_ioctl.c:144
- sys/vfs/hammer2/hammer2_ioctl.c:145
- sys/vfs/hammer2/hammer2_ioctl.c:147
- sys/vfs/hammer2/hammer2_ioctl.c:148
- sys/vfs/hammer2/hammer2_ioctl.c:154
- sys/vfs/hammer2/hammer2_ioctl.c:155
- sys/vfs/hammer2/hammer2_chain.c:5812
- sys/vfs/hammer2/hammer2_chain.c:5827
- sys/kern/vfs_vnops.c:1029
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.
No comments yet.