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)
PoC verification
Evidence pack
findings/poc/DF-0858 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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
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:
- Unkillable kernel CPU spin / hard DoS β a single
catorstaton the mounted cyclic image pegs one CPU at 100 % forever and the process cannot be killed (SIGKILLcannot deliver in kernel mode). The guest becomes unresponsive. Deterministic on the default GENERIC kernel. - 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
fixedVALIDATED 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.
Confirmed kernel references
- sys/vfs/hpfs/hpfs_alsubr.c:62
- sys/vfs/hpfs/hpfs_alsubr.c:78
- sys/vfs/hpfs/hpfs_alsubr.c:80
- sys/vfs/hpfs/hpfs_alsubr.c:82
- sys/vfs/hpfs/hpfs_alsubr.c:89
- sys/vfs/hpfs/hpfs_alsubr.c:99
- sys/vfs/hpfs/hpfs_alsubr.c:106
- sys/vfs/hpfs/hpfs_alsubr.c:110
- sys/vfs/hpfs/hpfs.h:233
- sys/vfs/hpfs/hpfs.h:261
- sys/vfs/hpfs/hpfs.h:262
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.
No comments yet.