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)
PoC verification
Evidence pack
findings/poc/DF-2491 Β· 23 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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-470requires the flag and non-zerocurrent_heads && current_sectorsβ guard FAILS β falls to raw 16/63 βad_attach:147divides by 1008 β attach SURVIVES (ad0: 0MB <DF2491 ZEROGEOM DISK>on the serial console β go7/go9 logs);ad_get_geometry:485-486still setsATA_D_USE_CHS(version_major=0);ata_tf_write:774tests only the flag β takes thecurrent_*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 globalata_generic_hw()wiring β kernel's ownata_begin_transaction()β kernel's ownata_generic_command()(ata-lowlevel.c:648) β kernel's own staticata_tf_write()(ata-lowlevel.c:750). The channel's I/O registers are a kernel-memory register file behind a fabricatedstruct 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 ownata_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-classhw.begin_transactionstub seam (the x86_64bus_space_read_multi_2MEM 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_CHSgate β 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 withata_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=0and 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_testableConfirmed 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.
No comments yet.