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

Unbounded dive depth in hpfs_hpbmap allows infinite kernel loop via cyclic alsec pointers

Summary

hpfs_alsubr.c:78 dive: label. :80 for(i..busycnt) finds anp. :82 bn<anp->an_nextoff. :89 bread(anp->an_lsn). :106 abp=&asp->as_ab. :110 goto dive. No depth counter no visited-set no cycle detection. Craft alsec A busycnt=1 AB_NODES an_nextoff=0xFFFFFFFF an_lsn=LSN(B) alsec B symmetric back to A. For any file offset dive never terminates. After first pass both buffers B_CACHE bread returns immediately = tight kernel CPU spin. Unprivileged user cat/stat triggers. Process unkillable SIGKILL cannot deliver in kernel mode. Fix: depth cap 20.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0858 Β· 16 files
FileTypeDescriptionSize
README.md readme finding summary, build/run, expected vs fixed behaviour 2.2 KB ↓ raw
VERDICT.md verdict full narrative: mechanism, trigger, impact ceiling, fix validation 10.3 KB ↓ raw
harness.c trigger-source deterministic dive-loop harness: faithful transcription + depth-counter escape + 3 cases (cyclic-bug, cyclic-fix, control) 13.2 KB view raw
craft_img.py trigger-source HPFS image crafter: cyclic AlSec A<->B (or control A->leaf AlSec) 11.9 KB view raw
df858.img trigger-source crafted 80 KB HPFS image with cyclic AlSec A<->B (binary, for live mount test) 80.0 KB ↓ download
df858-control.img trigger-source control image: AlSec A points at a leaf AlSec (loop terminates normally β€” proves the cycle is the cause) 80.0 KB ↓ download
build.sh build-script exact cc command to build the harness 169 B view raw
run.sh run-script exact harness invocation 135 B view raw
build.log build-log full compiler output of the final successful harness build 66 B view raw
run.log run-log decisive harness run (full output): cyclic-bug dives 1000x, fix trips at depth 20, control terminates at depth 2 1.9 KB view raw
env.txt environment uname, cc version, sysctls (vm.randomize_mmap=0, vfs.usermount=0), hpfs.ko module status 741 B view raw
fix.diff suggested-fix git-apply-able fix: add HPFS_DIVE_MAX=20 depth cap to hpfs_hpbmap's dive loop (one logical change) 1.4 KB view raw
fix_build.log build-log full build output of the single-fix hpfs.ko module (Phase 8) 9.8 KB view raw
fix_run.log run-log patched-module re-run of cat /mnt/FILE (3x): EINVAL in <1ms each, dmesg shows 'dive depth 22 exceeds 20 (cyclic or corrupt AlSec tree)', guest stays up 688 B 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 finding summary, build/run, expected vs fixed behaviour
↓ download raw

DF-0858 β€” Unbounded dive depth in hpfs_hpbmap (cyclic AlSec infinite loop)

Bug: hpfs_hpbmap() at sys/vfs/hpfs/hpfs_alsubr.c:78,110 walks the on-disk AlNode/AlSec allocation tree via a dive: label and goto dive with no depth counter, no visited-set, and no cycle detection. A crafted HPFS image with two AlSecs that point at each other (A→B→A) drives the loop into an infinite kernel CPU spin that is unkillable (SIGKILL cannot be delivered while in kernel mode).

Trigger (unprivileged): an admin mounts a crafted HPFS image (realistic precondition); an unprivileged user then does cat /mnt/FILE or even stat /mnt/FILE β†’ VOP_READ/VOP_GETATTR β†’ hpfs_hpbmap β†’ infinite dive. After the first pass both AlSec buffers are B_CACHE, so bread() returns immediately = tight CPU spin.

Impact: DoS β€” unkillable kernel CPU spin. No corruption β†’ no escalation.

Files

file purpose
craft_img.py HPFS image crafter: cyclic AlSec A↔B (or control Aβ†’leaf)
df858.img crafted 80 KB HPFS image with cyclic A↔B (binary)
df858-control.img control image: A→leaf AlSec (loop terminates normally)
harness.c deterministic dive-loop harness with depth-counter escape
build.sh exact cc command
run.sh exact harness invocation
VERDICT.md full narrative + fix validation
fix.diff git apply-able fix: dive-depth cap (HPFS_DIVE_MAX=20)

Build

./build.sh        # cc -O2 -Wall -o harness harness.c

Run (deterministic harness β€” safe)

./run.sh          # ./harness

Expected (BUG PRESENT): harness prints

DF_0858_BUG_CYCLIC_DIVE_LOOPS_FOREVER=1
DF_0858_FIX_DEPTH_CAP_BREAKS_LOOP=1
DF_0858_CONTROL_TERMINATES_NORMALLY=1
DF_0858_BUG_CONFIRMED=1

Run (live β€” destructive; will hang the guest)

# as root:
mdconfig/vnconfig the image, mount_hpfs -o ro /dev/vnX /mnt
# as unprivileged user:
timeout 10 cat /mnt/FILE &
# watch from a second ssh β€” top/systat will show one CPU pegged at 100%,
# the cat process is in pfork/wait and CANNOT be killed (kill -9 has no
# effect β€” it's spinning in kernel mode).  The guest becomes sluggish.
# Recover only with:  ./dfbsd-qemu/vm.sh reset with-src
VERDICT.md verdict full narrative: mechanism, trigger, impact ceiling, fix validation
↓ download raw

DF-0858 β€” VERDICT

Verdict: REPRODUCED (unbounded dive depth / no cycle detection β†’ unkillable kernel CPU spin; root-only mount + unpriv read/stat trigger; no corruption β†’ no escalation; valid hard blocker applies)

Status: reproduced (live kernel hang on the unpatched kernel + deterministic harness + live fix validation) Impact: dos (unkillable kernel CPU spin from a crafted HPFS image; the spinning process cannot be killed because SIGKILL cannot be delivered while in kernel mode β€” only vm.sh reset recovers) Confidence: certain


Mechanism (root cause, traced path:line)

hpfs_hpbmap() (sys/vfs/hpfs/hpfs_alsubr.c:62) translates a file logical block number bn to a disk offset by walking the on-disk allocation tree of AlNodes (interior) and AlLeaves (extents). The walk is driven by a dive: label and a goto dive with no depth counter, no visited-set, and no cycle detection:

line code comment
sys/vfs/hpfs/hpfs_alsubr.c:78 dive: label β€” entry and re-entry
sys/vfs/hpfs/hpfs_alsubr.c:79 if (abp->ab_flag & AB_NODES) interior-node branch
sys/vfs/hpfs/hpfs_alsubr.c:80 for (i=0; i<abp->ab_busycnt; i++, anp++) iterate AlNodes
sys/vfs/hpfs/hpfs_alsubr.c:82 if (bn < anp->an_nextoff) select the child AlSec
sys/vfs/hpfs/hpfs_alsubr.c:89 bread(hp->h_devvp, dbtodoff(anp->an_lsn), DEV_BSIZE, &bp) read child AlSec
sys/vfs/hpfs/hpfs_alsubr.c:99 if (asp->as_magic != AS_MAGIC) ... EINVAL magic check (passes for forged image)
sys/vfs/hpfs/hpfs_alsubr.c:106 abp = &asp->as_ab; descend into child AlBlk
sys/vfs/hpfs/hpfs_alsubr.c:110 goto dive; NO depth counter, NO visited-set, NO cycle detection

There is no count of how many times dive: is entered. A crafted HPFS image with two AlSecs that point at each other (A→B→A) drives the loop into an infinite dive:

  file fnode.fn_ab (AB_NODES, 1 alnode)  ->  AlSec A
  AlSec A.as_ab (AB_NODES, 1 alnode)     ->  AlSec B
  AlSec B.as_ab (AB_NODES, 1 alnode)     ->  AlSec A   *** CYCLE ***

For any file offset bn (with an_nextoff = 0xFFFFFFFF on every alnode), each iteration: 1. finds the single alnode (bn < 0xFFFFFFFF is always true), 2. brelse()s the previous buffer, 3. bread()s the child AlSec β€” after the first pass both AlSec buffers are B_CACHE, so bread() returns immediately = tight kernel CPU spin, 4. descends into the child AlBlk and goto dives.

The kernel never returns to userspace, so the process is unkillable (SIGKILL cannot be delivered while in kernel mode) and one CPU pegs at 100 %. The guest becomes unresponsive and only a hard vm.sh reset recovers.

Trigger path (realistic)

vfs.usermount = 0 on this guest, so mounting requires root. This is a realistic precondition: an admin mounts an attacker-supplied HPFS image (USB stick, downloaded image, removable device). Once mounted, the trigger is fully unprivileged:

ls /mnt                # directory readdir β€” does NOT hit the bug
cat /mnt/FILE          # regular file read β†’ VOP_READ β†’ hpfs_read
                       # β†’ hpfs_hpbmap β†’ infinite dive β†’ CPU peg, unkillable
stat /mnt/FILE         # VOP_GETATTR β†’ hpfs_getattr β†’ hpfs_hpbmap β†’ same

Even a stat(2) triggers the dive because hpfs_getattr consults the allocation tree for the file size.

Reproduction evidence

Deterministic harness (harness.c)

A faithful userspace transcription of the exact dive: loop at hpfs_alsubr.c:78-110, against the exact on-disk struct layouts from hpfs.h (alblk_t 8 B, alnode_t 8 B, fnode.fn_abd[0x60], alsec.as_abd[0x1E0]). The harness has a depth-counter escape hatch (DEPTH_CAP) so it can OBSERVE the loop running forever instead of actually hanging. Three cases:

  • Case 1 β€” cyclic image (Aβ†’Bβ†’A), UNPATCHED (DEPTH_CAP=1000): harness dives 1000 times A↔B before the cap trips β†’ "WOULD LOOP FOREVER". This proves the kernel code has no cap and would loop forever.
  • Case 2 β€” cyclic image (Aβ†’Bβ†’A), FIXED (DEPTH_CAP=HPFS_DIVE_MAX=20): harness trips at depth 20 β†’ "EINVAL β€” dive cap tripped, loop broken". This proves the proposed fix closes the bug.
  • Case 3 β€” control image (Aβ†’leaf AlSec), UNPATCHED: harness dives exactly 2 levels (fnodeβ†’Aβ†’leaf) and resolves bn=0 normally. This proves the CYCLE is the cause, not the image structure.

Output (run.log):

--- Case 1: cyclic image (A->B->A), UNPATCHED (no depth cap) ---
  [hpbmap] dive depth=0  node[0]: an_nextoff=0xffffffff an_lsn=0x60  (bn=0x0)
  [hpbmap] dive depth=1  node[0]: an_nextoff=0xffffffff an_lsn=0x80  (bn=0x0)
  [hpbmap] dive depth=2  node[0]: an_nextoff=0xffffffff an_lsn=0x60  (bn=0x0)
  ...A↔B bounce repeats...
  [hpbmap] dive depth=1000 node[0]: an_nextoff=0xffffffff an_lsn=0x60 (bn=0x0)
  [hpbmap] WOULD LOOP FOREVER: hit depth cap 1000 at bn=0x0
  result: DEPTH-CAP TRIPPED (kernel: would loop forever)

--- Case 2: cyclic image (A->B->A), FIXED (cap = HPFS_DIVE_MAX) ---
  result: EINVAL β€” dive cap tripped, loop broken (FIX WORKS)

--- Case 3: control image (A->leaf), UNPATCHED (proves cycle is cause) ---
  [hpbmap] dive depth=0  node[0]: an_nextoff=0xffffffff an_lsn=0x60  (bn=0x0)
  [hpbmap] dive depth=1  node[0]: an_nextoff=0xffffffff an_lsn=0x90  (bn=0x0)
  [hpbmap] dive depth=2  leaf[0]: al_off=0x0 al_len=0x80 al_lsn=0x91 (bn=0x0)
  [hpbmap] FOUND: bn=0x0 -> disk lsn 0x91 (depth=2)
  result: FOUND normally (loop terminated at leaf)

=== SUMMARY ===
DF_0858_BUG_CYCLIC_DIVE_LOOPS_FOREVER=1
DF_0858_FIX_DEPTH_CAP_BREAKS_LOOP=1
DF_0858_CONTROL_TERMINATES_NORMALLY=1
DF_0858_BUG_CONFIRMED=1

Live (DragonFly 6.5-DEVELOPMENT #0, X86_64_GENERIC, INVARIANTS ON)

craft_img.py builds an 80 KB minimal HPFS image (SuperBlock + SpareBlock + bitmap dir + bitmap band + root dir fnode + dirblk with one "FILE" dirent + a regular-file fnode whose fn_ab is AB_NODES with one AlNode pointing to AlSec A, plus AlSec A→B and AlSec B→A). Root mounts it; unprivileged user does cat /mnt/FILE:

mount_hpfs -o ro /dev/vn0 /mnt  β†’ MOUNT_RC=0
(12-second timeout on `cat /mnt/FILE` as maxx β†’ times out)
guest becomes unresponsive; vm.sh status β‡’ down

The guest wedged with NO panic in boot.log β€” exactly the signature of an unkillable kernel CPU spin (vs a panic, which would leave a fatal trap or panic: in boot.log). Only vm.sh reset with-src recovered. This is the unambiguous live DoS.

Exploit chain / impact ceiling

This bug produces a control-flow primitive (infinite kernel loop) but not a memory-corruption primitive. The dive loop only dereferences AlSec buffers and reads an_nextoff/an_lsn/al_off/al_len/al_lsn fields β€” it does not write attacker-controlled bytes anywhere. Therefore the Phase-6 escalation chain does not apply: the valid hard blocker "primitive is genuinely read-only / no write capability" applies (Phase 6 hard blocker #1).

Realistic impact ceiling:

  1. Unkillable kernel CPU spin / hard DoS β€” a single cat or stat on the mounted cyclic image pegs one CPU at 100 % forever and the process cannot be killed (SIGKILL cannot deliver in kernel mode). The guest becomes unresponsive. Deterministic on the default GENERIC kernel.
  2. No memory corruption β‡’ no info leak, no privilege escalation.

Fix (fix.diff)

Add a depth counter to hpfs_hpbmap() that is incremented at every dive: iteration. When the counter exceeds HPFS_DIVE_MAX (= 20 β€” far above any legitimate allocation-tree depth; each AlSec fan-out holds up to 60 AlNodes, so even a maximally-fragmented volume reaches only a handful of levels), log a kprintf and return EINVAL. This rejects cyclic or corrupt AlSec trees at the dive point instead of looping forever.

The fix is minimal (one logical change: depth counter + cap check at the dive label) and targeted at the confirmed root cause. It does not conflict with the DF-0857 fix (which validates ab_busycnt against the container max β€” an orthogonal issue); the two fixes are complementary.

Fix validation (Phase 8)

Applied fix.diff to in-guest /usr/src (patch -p1), rebuilt only the hpfs.ko KLD module (make in /usr/src/sys/vfs/hpfs), installed to /boot/kernel/hpfs.ko (sha256 d23a53566d6ddd0f8c80a1d261ddba7f554444a4f36748ef4b0153a243db6498), re-mounted the same cyclic image, re-ran cat /mnt/FILE (3Γ— for determinism):

Kernel / module cat /mnt/FILE dmesg guest
#0 + unpatched hpfs.ko HANG β†’ guest wedged (no panic) (none β€” infinite spin, no panic signature) DOWN
#0 + PATCHED hpfs.ko EINVAL: Invalid argument (0.00 real, Γ—3) hpfs_hpbmap: dive depth 22 exceeds 20 (cyclic or corrupt AlSec tree) UP

Fix closes the bug: the cyclic dive is now broken by the depth cap at depth 22 (2 levels past HPFS_DIVE_MAX=20 β€” fnode dive + AlSec A dive before the cap is checked at the second entry to dive:), hpfs_hpbmap returns EINVAL, cat returns immediately, the guest stays up, and the dmesg message clearly identifies the cyclic-or-corrupt tree. The fix is deterministic across 3 runs.

PoC changes

The runner created the entire evidence pack from scratch (the finding folder did not exist). Files authored: - README.md β€” finding summary, build/run, expected vs fixed behaviour - harness.c β€” deterministic dive-loop harness (faithful transcription + depth-counter escape + 3 cases: cyclic-bug, cyclic-fix, control) - craft_img.py β€” HPFS image crafter (cyclic AlSec A↔B and control Aβ†’leaf) - df858.img β€” crafted 80 KB HPFS image with cyclic A↔B (binary, for live test) - df858-control.img β€” control image: Aβ†’leaf AlSec (loop terminates normally) - build.sh / run.sh β€” exact repro scripts - fix.diff β€” git-apply-able fix: dive-depth cap (HPFS_DIVE_MAX=20) - build.log, run.log, fix_build.log, fix_run.log, env.txt β€” full logs - manifest.json β€” artifact catalog - VERDICT.md β€” this file

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: cat /mnt/FILE on the unpatched #0 + unpatched hpfs.ko wedged the guest with no panic (unkillable CPU spin, vm.sh status => down, only reset recovered); cat /mnt/FILE on #0 + PATCHED hpfs.ko returns 'Invalid argument' (EINVAL) in 0.00s (deterministic across 3 runs) and dmesg shows 'hpfs_hpbmap: dive depth 22 exceeds 20 (cyclic or corrupt AlSec tree)' => the depth cap breaks the cyclic dive and the guest stays up. fix.diff applies cleanly (patch -p1; git apply --check), compiles cleanly (hpfs.ko build rc=0), and closes the bug end-to-end. Phase 8g: guest reset to with-src baseline.

baseline (unpatched): timeout 12 cat /mnt/FILE as maxx -> RC=124 (timeout fired), guest wedged, vm.sh status => down, no panic signature in boot.log (signature of unkillable kernel CPU spin). patched (single-fix hpfs.ko): CAT_EXIT=1, 'cat: /mnt/FILE: Invalid argument', 0.00 real 0.00 user 0.01 sys (x3 runs identical); dmesg: 'hpfs_hpbmap: dive depth 22 exceeds 20 (cyclic or corrupt AlSec tree)'; guest stays up.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (kernel unchanged; single-fix hpfs.ko module rebuilt and installed -- sha256 d23a53566d6ddd0f8c80a1d261ddba7f554444a4f36748ef4b0153a243db6498)

Confirmed kernel references

Detail

Exploit chain

none (genuinely non-corruption class). The dive loop only dereferences AlSec buffers and READS an_nextoff/an_lsn/al_off/al_len/al_lsn fields -- it writes nothing attacker-controlled. This is a pure control-flow / infinite-loop DoS, not memory corruption, so Phase 6 escalation does not apply. The valid hard blocker 'primitive is genuinely read-only / no write capability' (Phase 6 #1) applies. Realistic impact ceiling = unkillable kernel CPU spin / hard DoS from a crafted HPFS image mount + read/stat by an unprivileged user; no info leak, no privilege escalation.

Evidence (decisive lines)

Harness (run.log): Case 1 cyclic unpatched dives depth 0..1000 A<->B then 'WOULD LOOP FOREVER: hit depth cap 1000 at bn=0x0 (this is the bug -- kernel has NO cap)'; Case 2 cyclic fixed 'EINVAL -- dive cap tripped, loop broken (FIX WORKS)'; Case 3 control 'FOUND: bn=0x0 -> disk lsn 0x91 (depth=2)'. SUMMARY: DF_0858_BUG_CYCLIC_DIVE_LOOPS_FOREVER=1, DF_0858_FIX_DEPTH_CAP_BREAKS_LOOP=1, DF_0858_CONTROL_TERMINATES_NORMALLY=1, DF_0858_BUG_CONFIRMED=1. Live: cat /mnt/FILE on unpatched kernel -> 12s timeout fires, guest wedged (vm.sh status => down), no panic in boot.log; on patched hpfs.ko cat returns 'Invalid argument' in 0.00s (3x deterministic) and dmesg shows 'hpfs_hpbmap: dive depth 22 exceeds 20 (cyclic or corrupt AlSec tree)'.

PoC changes

Created the entire evidence pack from scratch (folder did not exist). Files authored: harness.c (faithful dive-loop transcription with depth-counter escape + 3 cases: cyclic-bug, cyclic-fix, control-A->leaf); craft_img.py (HPFS image crafter for cyclic A<->B and control A->leaf, reusing DF-0857 scaffolding for SuperBlock/SpareBlock/bitmap/dirblk/fnode layout); df858.img + df858-control.img (binary images); build.sh / run.sh; fix.diff (git-apply-able dive-depth cap HPFS_DIVE_MAX=20); VERDICT.md; manifest.json; env.txt. The harness verbose mode was tuned (condensed: first 5 + last 5 iterations) so run.log is concise but the infinite A<->B bounce is unambiguously visible.

Verified recommended fix

Add a depth counter to hpfs_hpbmap() at sys/vfs/hpfs/hpfs_alsubr.c:62. Initialize depth=0 before the dive: label (line 78); at the top of dive: increment depth and if it exceeds HPFS_DIVE_MAX (20 -- far above any legitimate allocation-tree depth; each AlSec fan-out holds up to 60 AlNodes per sys/vfs/hpfs/hpfs.h:267), kprintf a diagnostic and return EINVAL. This breaks cyclic or corrupt AlSec trees at the dive point instead of looping forever. The full git-apply-able diff lives in findings/poc/DF-0858/fix.diff; git apply --check passes. Supersedes the finding proposal (the finding suggested 'e.g. 20'; this fix implements exactly that with a kprintf diagnostic).

Verdict

REPRODUCED. The bug is real: hpfs_hpbmap() walks the on-disk AlSec allocation tree via a dive: label (sys/vfs/hpfs/hpfs_alsubr.c:78) and a goto dive (sys/vfs/hpfs/hpfs_alsubr.c:110) with NO depth counter, NO visited-set, and NO cycle detection anywhere on the path. A crafted HPFS image with two AlSecs that point at each other (A->B->A, each with one AlNode {an_nextoff=0xFFFFFFFF, an_lsn=other}) drives the dive loop into an infinite kernel CPU spin. After the first pass both AlSec buffers are B_CACHE so bread() (line 89) returns immediately = tight spin; the process is unkillable (SIGKILL cannot deliver in kernel mode). Confirmed three ways: (1) deterministic harness transcribing hpfs_alsubr.c:78-110 dives 1000x A<->B before its escape cap trips (DF_0858_BUG_CONFIRMED=1); (2) live: root mounts the crafted df858.img, unpriv maxx does cat /mnt/FILE -> 12s timeout fires, guest wedged with NO panic in boot.log (signature of unkillable CPU spin, not a crash), only vm.sh reset recovered; (3) control image (A->leaf AlSec) dives exactly 2 levels and resolves bn=0 normally -- proves the cycle is the cause.