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

Divide-by-zero panic in CHS-mode ata_tf_write from device-controlled geometry

Summary

In CHS sub-path of ata_tf_write cylinder/head/sector math does modulo and division by sectors and sectors*heads. Both values taken straight from device IDENTIFY data with no zero check. ata-disk.c guards with && current_heads && current_sectors but ata_tf_write does NOT. Malicious ATA device forcing CHS mode (invalid version_major or zero lba_size) reporting sectors=0 triggers integer divide-by-zero on first I/O panicking kernel. ATA_D_USE_CHS set when !ad_version(version_major)||!lbasize both device-controlled. CHS path reached via every non-ATAPI command including geom partition-table probe. [VERIFIED pass2: reproduced 6 ways incl. the FULL PRODUCTION FUNNEL - kernels own ata_identify getparam ad_probe ad_attach SURVIVES the crafted IDENTIFY thanks to the ata-disk guard divergence, sets ATA_D_USE_CHS, then first I/O Fatal trap 18 at ata_tf_write+0x1f0 and +0x218 both division sites disassembly-pinned; word 53 valid with current_sectors 0 passes DF-2489s guarded sites making this an independent bypass; guard-plus-clamp fix-validated, controls byte-exact]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2491 Β· 23 files
FileTypeDescriptionSize
README.md β€” 4.6 KB ↓ raw
VERDICT.md β€” 9.6 KB ↓ raw
dfrig2491.c β€” 36.9 KB view raw
Makefile β€” 549 B ↓ download
build.sh β€” 227 B view raw
run.sh β€” 245 B view raw
cycle.sh β€” 1.7 KB view raw
build.log β€” 12.9 KB view raw
build.fixed-source.log β€” 6.2 KB view raw
run_control.log β€” 3.6 KB view raw
panic.go2_kernel_sectors0.txt β€” 2.6 KB view raw
panic.go3_kernel_heads0.txt β€” 2.2 KB view raw
panic.go5_verbatim_sectors0.txt β€” 2.2 KB view raw
panic.go7_funnel_sectors0.txt β€” 2.2 KB view raw
panic.go9_funnel_heads0.txt β€” 2.3 KB view raw
panic.go10_kernel_raw_sectors0.txt β€” 2.2 KB view raw
ata_tf_write_trap_disasm.txt β€” 2.2 KB view raw
fix_build.log β€” 5.7 MB ↓ download
fix_run.log β€” 4.2 KB view raw
fix.diff β€” 889 B view raw
env.txt β€” 255 B view raw
manifest.json β€” 2.9 KB view raw
verdict.json β€” 5.9 KB view raw

DF-2491 β€” Divide-by-zero panic in CHS-mode ata_tf_write from device-controlled geometry

sys/dev/disk/nata/ata-lowlevel.c:771-790 β€” verified on the audit QEMU guest.

What the finding is

The CHS sub-path of ata_tf_write() takes heads/sectors straight from the device-supplied ATA IDENTIFY words with no zero check and then executes

ata-lowlevel.c:783   ATA_IDX_OUTB(ch, ATA_SECTOR, (request->u.ata.lba % sectors)+1);
ata-lowlevel.c:785   ATA_IDX_OUTB(ch, ATA_CYL_LSB, lba / (sectors * heads));
ata-lowlevel.c:790   DRIVE head field = ((lba % (sectors*heads)) / sectors) & 0xf

sectors==0 β†’ x86 #DE at :783 (lba % sectors); heads==0 β†’ #DE at :785. The sibling consumer of the same words, ad_get_geometry() (ata-disk.c:469-470), guards with && current_heads && current_sectors; the taskfile writer does not. A device that forces CHS mode β€” ad_get_geometry sets ATA_D_USE_CHS whenever !ad_version(version_major) or !lbasize (ata-disk.c:484-486; ad_version(0)==0) β€” and reports a zero geometry word panics the kernel on the first I/O, including the geom partition-table probe.

Threat model: malicious/compromised ATA device (or any attacker who can answer IDENTIFY β€” e.g. USB-UAS bridge firmware, fabricated channel behind a hypervisor, malicious drive firmware) at first disk I/O. Local-denial / malicious-device class (AV:P), same class as DF-2489.

Files in this pack

file what it is
dfrig2491.c the verification rig (KLD): verbatim #include of production ata-lowlevel.c (sha256-gated, 5 globals renamed) plus dispatch of the running kernel's OWN static ata_tf_write through the kernel's own ata_controlcmd β†’ ata_queue_request β†’ ata_begin_transaction β†’ ata_generic_command β†’ hw.tf_write chain, plus a full production funnel (kernel's own ata_identify/ad_probe/ad_attach with a crafted IDENTIFY, then first I/O)
Makefile, build.sh, run.sh, cycle.sh exact build/run machinery
build.log stock-tree rig build (checker sha equality + full compiler output)
build.fixed-source.log rig build against the FIXED /usr/src (checker notes the sha change)
run_control.log stock-kernel controls: go1 (CHS sane), go4 (LBA), go6 (verbatim copy), go8 (full funnel) β€” all pass, taskfile registers verified
panic.go2_kernel_sectors0.txt FATAL trap 18, kernel's own ata_tf_write+0x1f0, divq %r10,%eax β€” IDENTIFY current_sectors=0 (:783)
panic.go3_kernel_heads0.txt FATAL trap 18, ata_tf_write+0x218, divq %rdi β€” current_heads=0 (:785)
panic.go5_verbatim_sectors0.txt FATAL trap 18 in the module's verbatim #included copy (IP inside dfrig2491.ko) β€” byte-identity
panic.go7_funnel_sectors0.txt FATAL trap 18 β€” FULL production funnel: crafted IDENTIFY β†’ kernel's own ad_attach SURVIVES (ad0: 0MB <DF2491 ZEROGEOM DISK>) β†’ first I/O β†’ trap at :783
panic.go9_funnel_heads0.txt FATAL trap 18 β€” full funnel, current_heads=0 β†’ :785
panic.go10_kernel_raw_sectors0.txt FATAL trap 18 β€” raw-words path (no word-53 bit), raw sectors=0 β†’ :783
ata_tf_write_trap_disasm.txt guest objdump of the stock kernel around both trap IPs; pins +0x1f0 to the (lba%sectors)+1 div (:783) and +0x218 to the lba/(sectors*heads) div (:785)
fix_build.log full fixed-kernel rebuild log (make nativekernel after fix.diff in guest /usr/src)
fix_run.log A/B validation on the fixed kernel: every previously-fatal scenario SURVIVES with clamped/raw-fallback CHS registers; controls bit-identical
fix.diff the verified fix (git-apply-able, never applied to the read-only sys/)
env.txt guest uname / kernel sha256 / rig details
manifest.json, verdict.json machine-readable verdict

Reproduce (host, from repo root)

# one fatal scenario per boot (2, 3, 5, 7, 9, 10); controls (1, 4, 6, 8) anytime
sh findings/poc/DF-2491/cycle.sh 7      # full-funnel :783 panic on stock kernel
dfbsd-qemu/vm.sh log 60                 # Fatal trap 18 ... ata_tf_write+0x1f0

Fix validation (already performed, logs in pack): apply fix.diff in the guest /usr/src, make nativekernel && make installkernel, reboot, rerun run.sh 2 3 5 7 9 10 β€” all return rc=0, no panic, clamped CHS taskfile.

Success criterion

Stock kernel + crafted device geometry in CHS mode β‡’ Fatal trap 18: integer divide fault at ata_tf_write+0x1f0/+0x218 (= ata-lowlevel.c:783/:785) on first I/O. Fixed kernel β‡’ I/O proceeds with sane-clamped CHS taskfile; sane-CHS and LBA devices bit-identical before/after the fix.

VERDICT.md
↓ download raw

DF-2491 β€” VERDICT

status: reproduced β€” impact: panic (malicious-device / local-DoS class, AV:P; no memory-corruption primitive, no privesc β€” pure #DE from device-controlled divisor, TEAM DIRECTIVE: panic β€” close).

Bottom line

The CHS sub-path of the kernel's own static ata_tf_write() (sys/dev/disk/nata/ata-lowlevel.c:771-790) divides by IDENTIFY-supplied sectors and sectors*heads with no zero check. A crafted ATA device that forces CHS mode (version_major==0 or lba_size==0 β†’ the kernel's own ad_get_geometry sets ATA_D_USE_CHS, ata-disk.c:484-486) and reports a zero geometry word panics the stock INVARIANTS kernel with Fatal trap 18: integer divide fault on the first I/O issued to the disk. Verified six ways (all on the running kernel's own code, plus a sha256-gated verbatim source copy), with the exact trap IPs disassembled and pinned to ata-lowlevel.c:783 and :785, and the fix A/B-validated on a rebuilt kernel: every fatal scenario survives with clamped geometry, controls bit-identical.

Why this is a distinct site from DF-2489 (and why the full funnel matters)

DF-2489 (verified previously) is the division total_secs/(heads*sectors) in ad_attach/ad_describe (ata-disk.c:147/:540). A zero-geometry device trips DF-2489 at attach, before any I/O. DF-2491 is the division in the taskfile writer, reached on I/O β€” and there is a device population that passes DF-2489's guarded site yet still trips DF-2491:

  • IDENTIFY word 53 reports words 54-58 valid (ATA_FLAG_54_58), current_heads=16, current_sectors=0, raw words sane (16/63);
  • ad_get_geometry:469-470 requires the flag and non-zero current_heads && current_sectors β†’ guard FAILS β†’ falls to raw 16/63 β†’ ad_attach:147 divides by 1008 β†’ attach SURVIVES (ad0: 0MB <DF2491 ZEROGEOM DISK> on the serial console β€” go7/go9 logs);
  • ad_get_geometry:485-486 still sets ATA_D_USE_CHS (version_major=0);
  • ata_tf_write:774 tests only the flag β†’ takes the current_* branch β†’ sectors = current_sectors = 0 β†’ first I/O β†’ #DE at :783.

That is precisely the missing-guard divergence the finding cites ("unlike ata-disk.c which guards with && current_heads && current_sectors").

What ran (stock kernel, INVARIANTS, #0: Thu Jul 2 06:02:54 UTC 2026)

The rig KLD (loaded at boot via loader.conf) dispatches production code only between the crafted device data and the trap:

  • Kernel-path tests (go1..go4, go10): the RUNNING KERNEL's own ata_controlcmd() β†’ ata_queue_request() (ATA_STALL_QUEUE + ATA_R_CONTROL β†’ direct HW call, ata-queue.c:98-107) β†’ the kernel's own global ata_generic_hw() wiring β†’ kernel's own ata_begin_transaction() β†’ kernel's own ata_generic_command() (ata-lowlevel.c:648) β†’ kernel's own static ata_tf_write() (ata-lowlevel.c:750). The channel's I/O registers are a kernel-memory register file behind a fabricated struct resource (r_bustag = X86_64_BUS_SPACE_MEM), so the pre-division taskfile writes (:769-770) are satisfied and observable; the division faults before the CHS register writes. Non-fatal variants complete through the kernel's own ata_interrupt() β†’ ata_end_transaction() β†’ tf_read() β†’ ata_finish() β†’ ata_completed() chain (rig "device" thread plays the drive raising READY).
  • Verbatim-copy test (go5/go6): production ata-lowlevel.c #included unmodified (build.sh sha256-gates it against the audited tree; 089a2ac25b1000d7ee4f030359884c54bde214e906af684ff1a670d30755d557; only the five global entry points renamed) β€” same dispatch, trap inside the module copy.
  • Full production funnel (go7/go8/go9): the kernel's own ata_identify() on a fabricated channel; IDENTIFY served through the DF-2489-class hw.begin_transaction stub seam (the x86_64 bus_space_read_multi_2 MEM path reads a fixed address per the inline asm β€” a data FIFO cannot be emulated in kernel memory, so IDENTIFY delivery is the one stubbed seam); everything after β€” ata_getparam() parse, newbus child, ad_probe(), ad_attach(), ATA_D_USE_CHS gate β€” is the running kernel's own code on the crafted bytes. The seam is then swapped to the kernel's REAL hw chain and the first I/O is fired with ata_controlcmd() on the REAL kernel-created ad child.

Results matrix

scenario input stock kernel fixed kernel
go1 control CHS sane 16/63 rc=0; taskfile SECTOR=0x3e CYL=0x0004 DRIVE=0xa9 CORRECT identical (bit-for-bit)
go2 current_sectors=0 (w56, w53 valid) FATAL trap 18, ata_tf_write+0x1f0 divq %r10,%eax (:783) survives, rc=0, clamped taskfile == go1
go3 current_heads=0 (w55) FATAL trap 18, ata_tf_write+0x218 divq %rdi (:785) survives, clamped == go1
go4 control LBA mode rc=0; SECTOR=0x34 CYL=0x0012 DRIVE=0xe0 (LBA bit) CORRECT identical
go5 verbatim copy, current_sectors=0 FATAL trap 18 at module IP 0xffffffff81de5df0 = copy's ata_tf_write+0x1f0 survives (copy compiled from fixed source)
go6 control verbatim copy, sane CHS CORRECT, == go1 identical
go7 full funnel, current_sectors=0 attach SURVIVES (ad0: 0MB <DF2491 ZEROGEOM DISK>), gate sets CHS, first I/O β†’ FATAL trap 18 at :783 survives (rc=0)
go8 control full funnel, sane CHS attach + first I/O complete, taskfile CORRECT identical
go9 full funnel, current_heads=0 attach survives, first I/O β†’ FATAL trap 18 at :785 survives
go10 raw words, sectors=0, no w53 bit FATAL trap 18 at :783 (else-branch :778-780) survives (16/63 clamp branch)

Trap-IP pinning (decisive evidence)

nm /boot/kernel/kernel β†’ ata_tf_write at 0xffffffff8032a910 (local symbol β€” the kernel's own static copy). Guest objdump of the stock kernel (ata_tf_write_trap_disasm.txt):

ffffffff8032aaef: movzwl 0x12(%rsi),%edi     ; current_heads
ffffffff8032aaf3: movzwl 0x18(%rsi),%r14d    ; current_sectors
ffffffff8032aaf8: movslq %r14d,%r10
ffffffff8032aafe: xor    %edx,%edx
ffffffff8032ab00: div    %r10                ; lba % sectors   <-- TRAP go2/go7/go10 IP
ffffffff8032ab03: lea    0x1(%rdx),%eax       ; +1  β†’ ATA_SECTOR  (:783)
ffffffff8032ab14: imul   %r14d,%edi           ; sectors*heads
ffffffff8032ab28: div    %rdi                ; lba/(s*h)       <-- TRAP go3/go9 IP (:785)

go2/go7/go10 trap IP 0xffffffff8032ab00 = ata_tf_write+0x1f0 = the div feeding (lba % sectors)+1; go3/go9 trap IP 0xffffffff8032ab28 = ata_tf_write+0x218 = the cylinder division. Instruction-for-instruction match with the source lines.

Fix validation (fix.diff, applied in guest /usr/src only)

fix.diff extends the ata_tf_write CHS-branch guard to the exact ata-disk.c:469-470 idiom (&& current_heads && current_sectors), guards the raw-words branch the same way, and clamps to 16/63 when both are invalid:

  • kernel rebuilt (make -j6 nativekernel KERNCONF=X86_64_GENERIC, fix_build.log, BUILD_RC=0), rig rebuilt against the fixed source (build.sh checker notes the sha change β€” expected), rebooted;
  • every previously-fatal scenario (go2/3/5/7/9/10) survives with rc=0 and a clamped CHS taskfile identical to the sane control (fix_run.log);
  • controls go1/go4/go6/go8 are bit-identical to the stock kernel (same register values, same LBA bit behavior) β€” no behavior change for sane devices.

Reachability / impact ceiling (honest)

  • Reachable at first I/O to any nata disk whose IDENTIFY forces CHS mode with a zero geometry word β€” including the geom partition-table probe that fires automatically during attach of the disk device (the funnel runs exactly that attach). A malicious device (drive firmware, bridge firmware, or a virtual device presented to the guest) delivers the bytes unauthenticated at attach time (AV:P).
  • Impact is panic / local DoS β€” #DE on a device-controlled divisor; there is no memory-safety primitive (the divisor only selects the fault; the results of the divisions are truncated to taskfile bytes, so even a non-zero bogus geometry yields at most wrong CHS addressing, not OOB). No escalation path; per the team directive (panic β€” close).
  • DF-2489 covers the attach-time division sites; DF-2491 is the I/O-time site, independently reachable via the guard divergence shown in go7/go9 (attach survives, first I/O dies).

Rig honesty note

Fabricated state is strictly: (a) the IDENTIFY bytes (the attacking device), (b) the kernel-memory register file + fabricated struct resource (the device's register interface β€” this is what a real MMIO IDE block is), (c) the IDENTIFY-serving hw.begin_transaction stub for the attach phase of the funnel (DF-2489-established seam; swapped to the kernel's real chain before the first I/O under test), (d) the newbus device graph around the channel. Nothing between the parsed IDENTIFY words and the division is rig code: ata_getparam, ad_get_geometry, the ATA_D_USE_CHS gate, ata_controlcmd, ata_queue_request, ata_begin_transaction, ata_generic_command and ata_tf_write all execute from the running kernel's own text (trap symbol ata_tf_write+0x1f0/+0x218), and the verbatim #included copy (sha256-gated) traps at the identical offsets.

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

not_testable
↓ fix.diffper-fix-DF-2491

Confirmed kernel references

Detail

Exploit chain

Not an exploitation primitive (integer divide fault on a device-controlled divisor; no OOB, no controllable write). Chain to impact: malicious ATA device or IDENTIFY-answering element -> crafted IDENTIFY with version_major=0 (or lba_size=0) forcing ATA_D_USE_CHS plus a zero geometry word -> first I/O to the disk (including the automatic geom partition-table probe) -> #DE in kernel text -> Fatal trap 18, all CPUs stopped, system down. Repeatable at every boot/attach.

Evidence (decisive lines)

panic.go7_funnel_sectors0.txt (money shot: ad0: 0MB <DF2491 ZEROGEOM DISK> attach-survival line followed by Fatal trap 18, Stopped at ata_tf_write+0x1f0: divq %r10,%eax); panic.go2/go3/go5/go9/go10 (remaining variants, both division sites, verbatim-copy IP inside dfrig2491.ko); ata_tf_write_trap_disasm.txt (objdump pinning +0x1f0 to the (lba%sectors)+1 div at :783 and +0x218 to the CYL div at :785, kernel symbol base 0xffffffff8032a910); run_control.log (stock controls: CHS/LBA taskfiles verified against independent math); fix_run.log (fixed kernel: every fatal scenario survives, clamped registers); build.log (checker sha256 equality guest==audited tree); VERDICT.md (full narrative + matrix).

PoC changes

Seed had no runnable PoC. Built the DF-2210/DF-2489-class interposition rig: (1) fabricated channel with kernel-memory register file behind a struct resource r_bustag=X86_64_BUS_SPACE_MEM so the pre-division taskfile OUTBs at :769-770 are satisfied and observable; (2) the kernel own exported ata_generic_hw() wiring plus ata_controlcmd/ata_queue_request STALL dispatch so the kernel own STATIC ata_tf_write runs from kernel text; (3) rig device thread completing non-fatal variants via the kernel own ata_interrupt/ata_end_transaction/ata_finish chain; (4) verbatim #include of production ata-lowlevel.c (5 globals renamed, sha256-gated) for byte-identity; (5) full funnel reusing the DF-2489 IDENTIFY stub seam with the guard-divergence IDENTIFY (word53 valid + current_sectors=0 + raw sane) that passes DF-2489 sites but trips this one. One mid-run rig update: added taskfile dump-on-survival lines (dead code on the fatal paths) before the fixed-kernel A/B; the fatal runs used the initial variant.

Verified recommended fix

In ata_tf_write, gate the current_* branch on (atavalid & ATA_FLAG_54_58) && current_heads && current_sectors, gate the raw branch on heads && sectors, and fall back to 16/63 when both invalid (the ata-disk.c:469-470 guard idiom); see fix.diff.

Verdict

CONFIRMED on the stock INVARIANTS kernel, six ways. The CHS sub-path of the kernel own static ata_tf_write (sys/dev/disk/nata/ata-lowlevel.c:771-790) divides by IDENTIFY-supplied sectors / sectorsheads with no zero check: current_sectors=0 (word 56, flagged valid by word 53) gives Fatal trap 18 at ata_tf_write+0x1f0 (disassembled: the div feeding (lba%sectors)+1, :783); current_heads=0 gives trap 18 at +0x218 (lba/(sectorsheads), :785). The full production funnel proves the claimed chain end-to-end: a crafted IDENTIFY (word53 54-58 valid, current 16/0, raw 16/63, version_major=0, lba_size=0) makes the kernel own ad_get_geometry fall to its GUARDED raw-words branch so ad_attach SURVIVES (ad0 created; the DF-2489 ata-disk.c:147 site does NOT trip), the gate at ata-disk.c:485-486 sets ATA_D_USE_CHS, and the FIRST I/O dispatches the kernel own chain (ata_controlcmd -> ata_queue_request STALL path -> ata_begin_transaction -> ata_generic_command -> hw.tf_write) into the division by zero. A sha256-gated verbatim #included copy of the production file traps at the identical instruction offsets (byte-identity). No memory-safety primitive exists here: the divisor only selects the fault, quotients are truncated to taskfile bytes - impact is panic/DoS from a malicious ATA device at first I/O (AV:P, same class as DF-2489; panic-close per team directive). fix.diff (extend the ata-disk.c:469-470 guard idiom to ata_tf_write, guard the raw branch, 16/63 clamp) was applied in guest /usr/src, kernel rebuilt (nativekernel, BUILD_RC=0), and A/B validated: all six fatal scenarios now return rc=0 with a clamped CHS taskfile identical to the sane control, and the go1/go4/go6/go8 controls are bit-identical to stock.