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

Kernel stack/heap OOB write via untrusted total_disks in ata_raid_create and ata_raid_status ioctls

Summary

Two ioctl handlers iterate up to user/corpus-controlled disk count and index fixed 16-element arrays without bounds check. ata_raid_create IOCATARAIDCREATE loops disk<config->total_disks over config->disks[16] and rdp->disks[16]. ata_raid_status IOCATARAIDSTATUS loops i<rdp->total_disks over status->disks[16]. Both total_disks accepted verbatim. config->total_disks raw int from user; rdp->total_disks attacker-inflatable via metadata. IOCATARAIDCREATE with total_disks=200 overwrites ~184 bytes kernel stack past 84-byte struct. Reachable by operator-group /dev/ata. Stack-smashing privilege escalation.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2495 Β· 17 files
FileTypeDescriptionSize
README.md β€” 1.8 KB ↓ raw
VERDICT.md β€” 5.3 KB ↓ raw
dfrig_ata.c β€” 23.7 KB view raw
df2495_status.c β€” 1.2 KB view raw
Makefile β€” 442 B ↓ download
build.sh β€” 176 B view raw
run.sh β€” 426 B view raw
build.log β€” 955 B view raw
run.log β€” 3.9 KB view raw
env.txt β€” 701 B view raw
fix.diff β€” 1.5 KB view raw
fix_build.log β€” 513 B view raw
fix_run.log β€” 1.4 KB view raw
fix_tests_123.log β€” 1.3 KB view raw
fix_tests_67.log β€” 1.1 KB view raw
manifest.json β€” 1.7 KB view raw
verdict.json β€” 6.9 KB view raw

DF-2495 β€” inflated rdp->total_disks (LSI v2 disk_count) drives a 1912-byte kernel-heap OOB write out of ata_raid_status() (IOCATARAIDSTATUS); ata_raid_create() likewise loops config->total_disks unchecked

File: sys/dev/disk/nata/ata-raid.c:1013-1025, 1051-1151, 2856 Β· Severity: High (filed) Β· Bucket: memcorrupt Β· Confidence: certain Status: reproduced on the QEMU guest (production parse + production ata_raid_status() through the rig; the real-kernel /dev/ata ioctl arm also executed β€” see run.log and VERDICT.md for the honest impact ceiling: /dev/ata is 0600 root-only on this tree, so the ioctl trigger is root-gated).

Build (guest, root)

cd /root/dfrig && ./build.sh      # dfrig_ata.ko (production ata-raid.c compiled in)
cc -O2 -Wall -o df2495_status df2495_status.c

Run

sh run.sh                         # rig test 3 + real-kernel arm (test 7 + ioctl)

Expected (see run.log)

  • rig test 3: production parse sets rdp->total_disks=255 from crafted configs[0].raid.disk_count; production ata_raid_status() then writes status->disks[0..254] β€” 2064 bytes into/through a 152-byte buffer (the exact size the generic ioctl layer kmallocs for IOCATARAIDSTATUS): 1912 bytes past the allocation, smashing 12/24 marker guard chunks with the production {state=0, lun=-1} pattern.
  • real-kernel arm: rig test 7 installs the inflated array into the REAL ata_raid_arrays[0]; ./df2495_status 0 (root, real syscall) returns total_disks=255 β€” the real kernel wrote the same 1912 OOB bytes.

Fix

fix.diff: reject disk_count > MAX_DISKS at the LSI v2 parse, bound rdp->total_disks in ata_raid_status(), and reject out-of-range config->total_disks in ata_raid_create(). Validated on a rebuilt kernel (fix_build.log / fix_run.log).

VERDICT.md
↓ download raw

DF-2495 VERDICT β€” reproduced (1912-byte kernel-heap OOB write out of production ata_raid_status()), with an honest impact ceiling

Bottom line

The pass-1 claim is real at the code level and reproduced through production code twice; its practical trigger surface on this tree is narrower than filed (details below).

The write. ata_raid_lsiv2_read_meta() sets raid->total_disks = meta->configs[raid_entry].raid.disk_count (ata-raid.c:2856) β€” a raw u_int8_t from the crafted $XIDE$ superblock, no bound against MAX_DISKS=16. Every later loop trusts it. The one that writes is ata_raid_status() (ata-raid.c:1013-1025):

for (i = 0; i < rdp->total_disks; i++ ) {
    status->disks[i].state = 0;          /* unconditional OOB store */
    ... status->disks[i].lun = -1; ...
}

status is the generic ioctl layer's copyin buffer: 152 bytes, and because 152 > STK_PARAMS (128) kern_ioctl kmallocs it (sys/kern/sys_generic.c:664-680). With total_disks=255 the loop writes 2064 bytes β€” 1912 bytes past the allocation β€” with the production pattern {state=0, lun=-1} per entry.

Reproduced: - rig test 3 (guard-buffer proof): production parse β†’ rdp->total_disks=255 installed in the module's real ata_raid_arrays[0]; production ata_raid_status() called with a 152-byte kmalloc'd buffer surrounded by 0xAA guard chunks β†’ 12/24 guards smashed, victim tail overwritten with ffffffff00000000 words (run.log). - real-kernel arm: rig test 7 ran the same production parse with raidp = the REAL kernel ata_raid_arrays (0xffffffff8114dec0), then the REAL /dev/ata IOCATARAIDSTATUS ioctl (df2495_status, ordinary ioctl(2) through ata-all.c:443 β†’ ata_raid_ioctl:249 β†’ ata_raid_status) returned 0 with total_disks=255 β€” i.e. the real kernel executed all 255 iterations and wrote the 1912 OOB bytes into its own heap.

No panic observed: across the single-shot run plus a 3x700-iteration hammer with concurrent heap churn, the smashed zone neighbors were free chunks every time. The write primitive is proven; crash/escalation was not achieved on this guest.

Honest impact ceiling (differs from the filing)

  1. /dev/ata is crw------- root operator (mode 0600, ata-all.c:1025 make_dev(&ata_ops, 0, UID_ROOT, GID_OPERATOR, 0600, ...)) β€” the filing's "reachable by operator-group" does not hold on this tree: the ioctl arms (STATUS and CREATE) are root-only. As a privilege escalation this is therefore inert here; as a memory-corruption defect it remains real (root actions on attacker-inflated state, plus the fsck-style "admin runs atacontrol status" confused-deputy pattern).
  2. The unprivileged half of the chain is the crafted-disk metadata: disk_count=255 inflates total_disks at boot without any ioctl. The boot-time writers over the inflated count (ata_raid_config_changed ata-raid.c:924-926 clears AR_DF_ONLINE bits up to 255 entries past the disks[16] table β€” an OOB heap read-modify-write at attach; the attach print loop at 194-232 OOB-reads the same range) could not be armed on the standard guest for the same infrastructure reason as DF-2494: any IDE hard disk hangs SeaBIOS on the vm.sh invocation (verified with a benign image too).
  3. The CREATE arm (ata-raid.c:1051-1151) does read config->disks[disk] past the 88-byte struct once disk >= 16 and can write -1 at config->disks[disk] (1148) β€” but the loop only advances past disks whose devclass_get_device() lookup succeeds, so with ≀16 subdisk units in the system the OOB iterations hit stale-stack garbage, fail the lookup, and the single -1 store lands inside the 128-byte STK_PARAMS stkbuf. Missing-bound defect confirmed at code level; not weaponized further.

Fix validation

fix.diff (1) rejects disk_count > MAX_DISKS at the LSI v2 parse before the array is accepted, (2) bounds rdp->total_disks in ata_raid_status() (defense in depth for arrays inflated by any other parser), and (3) rejects config->total_disks outside [1, MAX_DISKS] in ata_raid_create(). Validated on a single-fix rebuilt kernel (fix_build.log / fix_run.log): the parse now rejects the crafted disk_count=255 (rc=0), no array is installed in the real ata_raid_arrays, and the STATUS ioctl returns ENXIO.

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 (fix.diff: disk_count>MAX_DISKS rejected at the LSI v2 parse; ata_raid_status bounds rdp->total_disks; ata_raid_create validates config->total_disks) built with nativekernel, installed, rebooted; rig + df2495_status rebuilt on the fixed tree. Baseline behaviors are gone: rig test3's parse now rejects the crafted disk_count=255 (rc=0, 'parse failed, cannot run status') so the production status OOB write never executes; the real-kernel test7 installs nothing (total_disks=0) and the REAL /dev/ata IOCATARAIDSTATUS returns ENXIO (errno=6) instead of 0-with-1912-OOB-bytes.

fix_run.log: 'parse rc=0: raidp[0]=0 rdp->total_disks=0 ... parse failed, cannot run status' and 'ioctl returned -1 (errno=6) total_disks=0' vs run.log baseline 'guard chunks hit by the overflow: 12/24' and 'ioctl returned 0 (errno=0) total_disks=255'.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Mon Oct 12 11:14:44 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

Detail

Exploit chain

crafted LSI v2 superblock with configs[0].raid.disk_count=255 on an ATA disk behind nata -> subdisk attach -> ata_raid_lsiv2_read_meta -> raid->total_disks=255 (ata-raid.c:2856, no bound vs MAX_DISKS=16) -> [boot-time, unprivileged] ata_raid_config_changed (924-926) read-modify-writes disks[16..254] flags (heap OOB bit-clears past the ar_softc) and the attach print loop OOB-reads the same range; [root-gated ioctl half] IOCATARAIDSTATUS lun=0 through /dev/ata (0600 root-only) -> kern_ioctl kmallocs 152 bytes -> production ata_raid_status writes status->disks[0..254] = 2064 bytes = 1912 bytes past the buffer with {0,-1} pairs -> kernel heap corruption of adjacent kmem-pool chunks (silent on this guest; panic potential on denser heaps). CREATE arm: config->total_disks up to INT_MAX loops config->disks[disk] past the 88-byte struct (OOB reads from the 128-byte stkbuf; -1 stores land within stkbuf) -- root-gated, not weaponized.

Evidence (decisive lines)

run.log: 'parse rc=1 ... rdp->total_disks=255 (MAX_DISKS=16, ata-raid.c:2856 takes disk_count verbatim)' / 'writing status->disks[0..254] -> last byte at +2064 (1912 bytes PAST the buffer)' / 'guard chunks hit by the overflow: 12/24 (0xffffffff = lun -1 from production status->disks[i].lun = -1)' / real-kernel arm: 'ioctl returned 0 (errno=0) total_disks=255' with /dev/ata shown crw------- root operator; fix_run.log: 'parse rc=0 ... cannot run status' + 'ioctl returned -1 (errno=6)'.

PoC changes

No pass-1 pack existed (empty dirs); authored fresh: the DF-2494 rig module drives the production parse (disk_count=255, raid_number=0, disk_number=2 to avoid the configs[0] raid/disk union overlay) and calls the production ata_raid_status() with a 152-byte kmalloc'd victim surrounded by 0xAA guard chunks, replicating the exact kern_ioctl buffer; df2495_status.c is a plain ioctl(2) program for the real /dev/ata arm (pass-1 sketch had wrong struct source -- the real copyin buffer is kernel-side, sys/kern/sys_generic.c:664-680, which is why the smash is kernel heap, not user memory).

Verified recommended fix

Reject disk_count > MAX_DISKS in ata_raid_lsiv2_read_meta(); defensively bound rdp->total_disks in ata_raid_status(); reject config->total_disks outside [1,MAX_DISKS] in ata_raid_create().

Verdict

CONFIRMED at the code level with the OOB write executed by production code twice; impact ceiling on this tree is narrower than filed. The LSI v2 parse takes configs[raid_entry].raid.disk_count (raw u_int8_t) verbatim into rdp->total_disks (ata-raid.c:2856, MAX_DISKS=16). Production ata_raid_status() (ata-raid.c:1013-1025) then runs for (i=0; itotal_disks; i++) writing status->disks[i].state/lun unconditionally into the generic ioctl layer's kmalloc'd 152-byte copyin buffer (sys/kern/sys_generic.c:664-680: 152 > STK_PARAMS 128 -> kmalloc): with disk_count=255 that is 2064 bytes written -- 1912 bytes past the allocation. Rig test3 proved it deterministically (production parse + production ata_raid_status with guard chunks: 12/24 guards smashed with the production {state=0,lun=-1}=ffffffff00000000 pattern, victim tail overwritten). The real-kernel arm then installed the inflated array into the REAL kernel ata_raid_arrays[0] and the REAL /dev/ata IOCATARAIDSTATUS ioctl (df2495_status, ordinary ioctl(2)) returned 0 with total_disks=255 delivered -- the real kernel executed all 255 iterations and wrote the 1912 OOB bytes into its own heap; no panic across a 3x700 hammer (smashed neighbors were free chunks every time), so impact=none beyond the proven write. Honest downgrade vs the filing: /dev/ata is crw------- root operator (0600, ata-all.c:1025) so the ioctl arms are ROOT-ONLY on this tree (no operator-group reach, no stack smash from CREATE -- its OOB -1 store lands inside the 128-byte stkbuf and the loop stalls on stale-garbage devclass lookups); the unprivileged half of the chain is the crafted-disk metadata inflation itself, whose boot-time writers (ata_raid_config_changed 924-926 OOB bit-clear RMW over disks[16..254]) could not be armed because ANY IDE hard disk hangs SeaBIOS on the standard vm.sh guest (same infrastructural limitation as DF-2494, verified with a benign image). fix.diff (reject disk_count>MAX_DISKS at parse + bound status + validate create's total_disks) validated on a single-fix rebuilt kernel: parse rejects (rc=0), no array installed, ioctl returns ENXIO.