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

Unbounded disk-index loop in SPAN/JBOD I/O strategy causes OOB read / hang

Summary

JBOD/SPAN branch of ata_raid_strategy walks per-disk sector table with drv++ no bound against MAX_DISKS or total_disks. If bio destination block past sum of configured per-disk sectors (trivially with crafted metadata) drv climbs past rdp->disks[16] reads/writes rdp->disks[N].sectors for N up to whatever it takes to fault leaking kernel heap and panicking. If garbage value read is 0 loop never terminates hang.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2497 Β· 15 files
FileTypeDescriptionSize
README.md β€” 3.3 KB ↓ raw
VERDICT.md β€” 3.5 KB ↓ raw
dfrig3_ata.c β€” 36.4 KB view raw
Makefile β€” 445 B ↓ download
atastat.c β€” 927 B view raw
build.sh β€” 138 B view raw
run.sh β€” 236 B view raw
run_nonfatal.log β€” 4.4 KB view raw
run_test13.log β€” 1.1 KB view raw
env.txt β€” 652 B view raw
fix.diff β€” 6.4 KB view raw
fix_build.log β€” 206 B view raw
fix_validation.log β€” 46.5 KB view raw
manifest.json β€” 1.4 KB view raw
verdict.json β€” 5.4 KB view raw

DF-2497 β€” unbounded SPAN/JBOD disk-index walk in ata_raid_strategy (OOB read / hang)

What was verified

sys/dev/disk/nata/ata-raid.c:352-353:

case AR_T_JBOD:
case AR_T_SPAN:
    drv = 0;
    lba = blkno;
    while (lba >= rdp->disks[drv].sectors)
        lba -= rdp->disks[drv++].sectors;      /* NO BOUND */

The walk has no bound against rdp->total_disks, MAX_DISKS (16), or the size of the struct ar_softc allocation (2424 bytes; disks[] at +112, 48 bytes/entry, so disks[49] is the first entry past the object). A bio whose block number is beyond the sum of the configured per-disk sectors β€” trivially reachable with crafted metadata β€” makes drv climb through the whole kernel heap at a 48-byte stride.

Both proven through 100% production code (rig: production ata-raid.c compiled into a KLD, HPTv2 SPAN metadata crafted exactly as a crafted disk would present it, only ata_queue_request interposed):

  • test 11 (deterministic) β€” production ata_raid_hptv2_read_meta() installs type=AR_T_SPAN, total_disks=2, disks[0].sectors=0, disks[1].sectors=1000 from crafted metadata. Production ata_raid_strategy() with blkno=5000: the walk read rdp->disks[drv].sectors for drv = 0,1,…,29 (all past total_disks=2, indices β‰₯16 past MAX_DISKS), stopped on a single harness stopper planted at the disks[30].sectors stride, and dispatched I/O with request->this(drv)=30 and request->dev = rdp->disks[30].dev β€” both OOB reads (run_nonfatal.log, run_test13.log).
  • test 13 (past the allocation, natural heap) β€” same setup, no planted stop within the object: the production walk continued past index 48 and reached disk index 51, reading disks[51].sectors/.flags/.dev 168+ bytes past the end of the 2424-byte heap object, stopping on natural heap contents and dispatching with this(drv)=51 (run_test13.log + serial STRATEGY DISPATCH lines).

Hang aspect (as filed): while climbing, any OOB region that reads as zero never satisfies the loop condition with a smaller lba, so the loop keeps advancing (lba -= 0) until it reaches a value β‰₯ lba (kernel pointers usually stop it) or unmapped memory (page fault). On this guest the walk stopped on mapped heap both times; a panic is heap-layout-dependent, and the "hang" variant is the same walk with an all-zero region. Impact: kernel heap OOB read at attacker-chosen stride (blkno is attacker-controlled), plus I/O dispatch to an OOB-read device pointer.

Build / run (guest, root; requires vm.sh reset with-src)

cd /root/dfrig3 && ./build.sh
kldload ./dfrig3_ata.ko
sysctl kern.dfrig3_ata_test=11    # SPAN walk, deterministic (drv=30)
sysctl -n kern.dfrig3_ata_status
sysctl kern.dfrig3_ata_test=13    # walk past the allocation (drv>=49)
sysctl -n kern.dfrig3_ata_status

Expected (baseline): status shows PROOF: production strategy dispatched to disk index drv=this=NN (>= MAX_DISKS / past the allocation).

Fix validation

fix.diff bounds the walk (while (drv < (int)rdp->total_disks && …)) and fails the bio with EIO when blkno is past the array end. On the single-fix kernel (fix_validation.log): both tests show dispatches=0, ar0: I/O beyond end of SPAN/JBOD array, rig_bio_done b_error=5 (EIO) β€” no OOB read, no dispatch.

VERDICT.md
↓ download raw

DF-2497 VERDICT β€” reproduced (OOB read walk past total_disks, past MAX_DISKS, past the allocation)

Bottom line

CONFIRMED. The JBOD/SPAN branch of the production ata_raid_strategy() (sys/dev/disk/nata/ata-raid.c:352-353) walks rdp->disks[drv].sectors with drv++ and no bound of any kind. Driven with a bio whose block number is past the array end (the per-disk sector table comes verbatim from crafted HPTv2 SPAN metadata: total_disks=2, disks[1].sectors=1000):

  1. Deterministic in-object proof (test 11): the walk consumed the configured disks, then kept reading rdp->disks[2..30].sectors β€” 29 entries past total_disks=2, 15 past MAX_DISKS=16 β€” and the production code then dispatched I/O using the OOB-read rdp->disks[30].dev as the target device (shim captured this(drv)=30, dev=0xffffffff8260b3c0). The only harness interposition besides the I/O shim is one planted valid stopper at the disks[30].sectors stride so the walk terminates at a mapped, valid address instead of depending on heap luck; every index the production loop read beyond the configured disks is the bug.
  2. Past-the-allocation proof (test 13): without an in-object stopper, the production walk continued past index 48 (first index past the 2424-byte struct ar_softc allocation; disks[] at +112, 48 bytes/entry) and reached drv=51, reading .sectors/.flags/.dev from unrelated kernel heap 168+ bytes past the object, then dispatched with that OOB-read .dev pointer.

The walk extent is attacker-influenced (blkno comes from the bio; bigger blkno β†’ farther walk). Termination depends on heap contents: kernel pointers (0xffff…) stop it; zero regions make the loop climb forever (lba -= 0) β€” the filed "hang" variant; unmapped pages would fault. On this guest both runs stopped on mapped heap, so no panic was forced; the primitive (unbounded heap OOB read + dispatch to garbage device pointer) is fully demonstrated.

Threat model

Crafted on-disk SPAN/JBOD metadata (e.g., HPTv2 HPTV2_T_SPAN with small total_sectors) makes the array's usable sector sum far smaller than any bio offset an unprivileged filesystem user can request once the array is mounted; every read past the sum walks kernel heap OOB.

Fix validation (single-fix kernel)

fix.diff bounds the walk with drv < (int)rdp->total_disks and fails out-of-range I/O (biofinish(bp, NULL, EIO) + "ar%d: I/O beyond end of SPAN/JBOD array"). On the fixed kernel (fix_validation.log): test 11 and test 13 both show dispatches=0, the beyond-end message, and rig_bio_done b_error=5 β€” the walk never leaves the configured disks. In-range SPAN I/O still works (the shim dispatched normally in DF-2496 test20's RAID0 path; the SPAN chunk computation below the new bound is unchanged).

Kernel references

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Single-fix kernel (walk bound + EIO in fix.diff) validates clean: both test 11 and test 13 show dispatches=0, the production 'ar0: I/O beyond end of SPAN/JBOD array' message, and the bio completed with b_error=5 (EIO) -- the walk never leaves the configured disks; no OOB read, no dispatch to garbage dev.

fix_validation.log: '===== FIXED-KERNEL TEST 11 ===== ... strategy returned 0; dispatches=0', '===== FIXED-KERNEL TEST 13 ===== ... dispatches=0', dmesg section: 'ar0: I/O beyond end of SPAN/JBOD array' + 'dfrig3: rig_bio_done #1 b_error=5'
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Wed Oct 14 20:11:51 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

Detail

Exploit chain

crafted HPTv2 SPAN metadata (type=HPTV2_T_SPAN, small total_sectors) on an nata disk -> array attached with per-disk sector sum far below any bio offset -> unprivileged read/write past the array end through the ar device -> while-loop reads rdp->disks[N].sectors for unbounded N (48-byte stride over kernel heap, extent attacker-influenced via blkno) -> either stops on a heap pointer >= lba and dispatches I/O to the OOB-read .dev pointer (wild device deref in ata_raid_send_request -> panic) or climbs an all-zero region forever (hang) or hits an unmapped page (panic). Demonstrated: drv=30 (deterministic) and drv=51 (past the allocation); guest survived both because the rig's stopper/dev target was a valid fake device.

Evidence (decisive lines)

run_nonfatal.log test11 ('PROOF: production strategy dispatched to disk index drv=this=30 ... the walk read 29 entries past the configured disks and 15 past disks[] bounds', parse rc=1); run_test13.log ('walk reached disk index 51 ... disks[51].sectors is 168 bytes past the object', serial 'STRATEGY DISPATCH #1: this(drv)=51' lines); fix_validation.log (fixed kernel: dispatches=0, 'ar0: I/O beyond end of SPAN/JBOD array', rig_bio_done b_error=5 for both tests)

PoC changes

No pass-1 pack existed (empty dir); the SPAN array state comes from a full production HPTv2 parse (no hand-crafted softc): the rig crafts the HPTv2 superblock (magic/order/type/disk_number/total_sectors; no checksum in that format) and lets production ata_raid_hptv2_read_meta() install it. rdp->status=AR_S_READY is set by the rig to mirror what production attach (ata_raid_config_changed) would do before I/O flows. The bio carries a rig bio_done callback so the fixed kernel's biofinish(EIO) path terminates safely in the harness instead of running buf completion on a fake buffer.

Verified recommended fix

Bound the walk: while (drv < (int)rdp->total_disks && lba >= rdp->disks[drv].sectors) ...; and fail the bio with biofinish(bp, NULL, EIO) when drv reaches total_disks (block number past the array end). See fix.diff.

Verdict

CONFIRMED. The JBOD/SPAN branch of production ata_raid_strategy() (ata-raid.c:352-353) walks rdp->disks[drv].sectors with drv++ and no bound against total_disks, MAX_DISKS (16) or the 2424-byte allocation. Driven with crafted HPTv2 SPAN metadata (production parse installs total_disks=2, disks[1].sectors=1000) and a bio at blkno=5000: (1) deterministic in-object run (test 11): the production walk read disks[2..30] -- 29 entries past the configured disks, 15 past MAX_DISKS -- then dispatched I/O with request->this(drv)=30 and request->dev = the OOB-read rdp->disks[30].dev (a single valid stopper was planted at the disks[30] stride so the walk terminates at a mapped address; every read past total_disks is production behavior); (2) past-the-allocation run (test 13): without an in-object stopper the walk continued past index 48 and reached drv=51, reading .sectors/.flags/.dev 168+ bytes past the end of the heap object out of natural kernel heap, then dispatched with that garbage dev pointer. Termination is heap-content-dependent (kernel pointers stop the walk; zero regions make it climb forever -- the filed hang variant; unmapped pages would fault), so a panic is not deterministic on this guest, but the unbounded OOB-read walk and the dispatch to an OOB-read device pointer are fully demonstrated.