Divide-by-zero kernel panic from zero geometry in ad_attach/ad_describe
Summary
ad_get_geometry() copies heads/sectors straight from untrusted ATA IDENTIFY response with no lower-bound check. Malicious ATA device reporting heads=0 or sectors=0 causes ad_attach line 147 (adp->total_secs/(adp->heads*adp->sectors)) to execute kernel integer divide by zero x86 #DE panicking host during device attach. Same divisor reused in ad_describe :540 bootverbose path. ad_get_geometry sets adp->heads/sectors from param.heads/sectors (or current_heads/current_sectors) u_int16_t IDENTIFY words crafted device can set to 0. Neither path enforces heads>=1 or sectors>=1. [VERIFIED pass2: reproduced 4 variants incl. through the RUNNING KERNELS OWN shipped ad_attach via the full production identify funnel - Fatal trap 18 divq at ad_attach+0x24b for heads 0 and sectors 0, and ad_describe+0x1bf in the bootverbose path so a 147-only fix would still panic verbose boots; nothing between ata_getparam and attach validates the words; geometry-clamp fix-validated - crafted device attaches with 16/63 clamps, control byte-identical; AV:P panic class, privesc closed]
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2489 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 5.5 KB | β raw | |
| VERDICT.md | β | 9.4 KB | β raw | |
| dfrig2489.c | β | 27.4 KB | view raw | |
| Makefile | β | 440 B | β download | |
| build.sh | β | 371 B | view raw | |
| run.sh | β | 264 B | view raw | |
| cycle.sh | β | 1.2 KB | view raw | |
| build.baseline.log | β | 2.7 KB | view raw | |
| build.patched.log | β | 6.4 KB | view raw | |
| run_control.log | β | 3.2 KB | view raw | |
| fix_run.log | β | 4.0 KB | view raw | |
| fix_build.log | β | 3.3 KB | view raw | |
| panic_go2_funnel_heads0.txt | β | 636 B | view raw | |
| panic_go3_funnel_sectors0.txt | β | 582 B | view raw | |
| panic_go4_verbatim_attach.txt | β | 582 B | view raw | |
| panic_go5_describe_bootverbose.txt | β | 649 B | view raw | |
| ad_attach_stock_disasm.txt | β | 10.9 KB | view raw | |
| ad_attach_fixed_disasm.txt | β | 12.1 KB | view raw | |
| serial_boot1_go1_go2.log | β | 3.1 KB | view raw | |
| env.txt | β | 1.9 KB | view raw | |
| fix.diff | β | 2.7 KB | view raw | |
| verdict.json | β | 5.6 KB | view raw |
DF-2489 β ata-disk.c divide-by-zero from device-supplied zero IDENTIFY geometry
Finding: sys/dev/disk/nata/ata-disk.c β ad_get_geometry() copies
adp->heads / adp->sectors raw from the ATA IDENTIFY words (word 3 /
word 6, else-branch at :476-480, no lower bound); a device reporting
heads=0 or sectors=0 makes ad_attach() at :147 compute
total_secs/(heads*sectors) β x86 #DE, Fatal trap 18: integer divide
fault at attach. The identical divisor is re-evaluated in the
bootverbose path of ad_describe() at :540. Medium / CWE-369.
Verdict: REPRODUCED (panic Γ4 variants), then FIX VALIDATED.
Rig (nata verbatim-interposition class β DF-2210/DF-249x)
dfrig2489.c (KLD, loaded AT BOOT via loader.conf; runtime kldload GPFs
on this INVARIANTS guest):
#includes the productionsys/dev/disk/nata/ata-disk.cVERBATIM (module plumbing neutralized, unused globalad_devclassrenamed;build.shsha256-checks the file =8773a2dfc4cfc372b1d2be6c2775fd72e0445e56cdc974615fb94654f6b78de1== auditedsys/tree). The rig therefore carries its own byte-identicalad_attach/ad_describe/ad_get_geometry.- Full production funnel dispatch (go1/2/3): the kernel's own global
ata_identify()(ata-all.c:683) is called on a fabricated channel (struct ata_channel, real"ata"devclass,ATA_IDLE, PIO-onlydma=NULL, productionata_queue_init()). It runs the REAL kmalloc'edata_device, the REALata_getparam()β the crafted 512-byte IDENTIFY image is served by ahw.begin_transactionstub through the REALata_queue_request/ata_startmachinery and parsed by the REAL le16toh/bswap/btrim code β the REAL newbus child, REALad_probe, and the RUNNING KERNEL'S OWNad_attach(nm:t ad_attach=0xffffffff80328d30). Everything from the IDENTIFY bytes to the fault is shipped production code. - The ata_if setmode/locking methods on the channel are the interface's
own DEFAULT implementations (
ata_if.mCODE blocks:ata_null_setmodeviaata_limit_mode,ata_null_locking); the scenarios run in a rig worker thread holding the MP token, i.e. the boot-attach context (the built-in atapicam driver's identify/attach also fires on every probed channel andxpt_bus_registerasserts the MP token, like at boot).
Scenarios (sysctl kern.dfrig2489.go=N, results on serial + status)
| N | what | stock kernel result |
|---|---|---|
| 1 | CONTROL: full funnel, real geometry (16383/16/63, LBA 0x2000000) | clean attach: ad0: 16384MB <DF2489 SANE GEOMETRY FW000001> at ata0-master PIO4 + bootverbose 33554432 sectors [33288C/16H/63S] 16 sectors/interrupt 1 depth queue, RAID metadata scan (4 reads, all "check1 failed"), RETURNED |
| 2 | full funnel, IDENTIFY heads=0 (word 3) | Fatal trap 18 at ad_attach+0x24b: divq %rsi,%eax (kernel own, shipped binary) |
| 3 | full funnel, IDENTIFY sectors=0 (word 6) | identical trap |
| 4 | VERBATIM rig copy ad_attach, heads=0 |
identical trap (divq) |
| 5 | VERBATIM rig copy ad_describe, heads=0 ad_softc, bootverbose |
48MB <...> line prints, then Fatal trap 18 at ad_describe+0x1bf: divq (:540) |
| 6/7 | semi-funnel diagnostics (sane) | clean (used to bracket the newbus machinery) |
Line attribution is airtight (ad_attach_stock_disasm.txt): the trap IP
0xffffffff80328f7b = ad_attach+0x24b is movslq %ecx,%rsi; div %rsi
β the u64 total_secs divided by the 32-bit heads*sectors product β
sandwiched between the info. field stores and the
device_add_child(dev, "subdisk", device_get_unit(dev)) sequence of
line 151.
Reachability (honesty)
Trigger: a malicious or buggy ATA/SATA device (or bridge/VM firmware)
reporting IDENTIFY word 3 = 0 and/or word 6 = 0, with word 53 not
reporting words 54-58 valid (or current_heads/current_sectors = 0). The
production chain copies IDENTIFY verbatim (ata_getparam,
ata-all.c:590-663 β the only check is a printable model byte) and
nothing validates the geometry before the division. Words 3/6 are
retired in modern ATA spec (real devices report 16/63), so this is
AV:P (physical/local device attach, boot or hot-plug) β a panic-class
DoS of the attach path, not an escalation: the only attacker
influence on the faulting instruction is the divisor (zero) and the
dividend; there is no memory corruption and no control beyond the #DE
trap (guest dead at db>).
Fix validation
fix.diff clamps the geometry at its single source in
ad_get_geometry (headsβ16, sectorsβ63 when 0; the current_* branch is
already guarded). With the clamp both :147 and :540 can never see a
zero divisor, and the values published to the disk layer
(d_nheads/d_secpertrack) stay sane.
Built with make -j6 nativekernel + installkernel (fix_build.log),
rig rebuilt from the patched source, exact scenarios re-run
(fix_run.log): go2 attaches cleanly with clamped
[..C/16H/63S] describe output; go1 control output is byte-identical to
the stock kernel.
Reproduce (host)
dfbsd-qemu/vm.sh reset with-src # copy this directory to the guest as /root/dfrig2489 dfbsd-qemu/vm.sh run_root 'sh /root/dfrig2489/build.sh' # KLD + boot-load setup dfbsd-qemu/vm.sh run_root 'shutdown -r now' # module loads at boot dfbsd-qemu/vm.sh run_root 'sh /root/dfrig2489/run.sh 1' # control (clean) dfbsd-qemu/vm.sh run_root 'sh /root/dfrig2489/run.sh 2' # Fatal trap 18 on serial dfbsd-qemu/vm.sh log # panic text
One fatal scenario per boot (vm.sh reset with-src between).
DF-2489 β VERDICT
Finding: sys/dev/disk/nata/ata-disk.c:147 and :540 β
ad_get_geometry() copies adp->heads/adp->sectors raw from the
device-supplied ATA IDENTIFY words (word 3 / word 6, else-branch at
:476-480 β the current_* branch at :469-475 is guarded, the raw branch
is not); a device reporting heads=0 or sectors=0 makes ad_attach()
compute total_secs/(heads*sectors) β x86 #DE, Fatal trap 18:
integer divide fault, at attach (boot or hot-plug). The identical
divisor is re-evaluated in the bootverbose path of ad_describe():540.
Verdict: REPRODUCED (panic, 4 variants, incl. the shipped kernel binary), then FIX VALIDATED. Panic-class DoS at device attach β not an escalation (closed explicitly below).
1. Root cause confirmed in source
ata-disk.c:477-478(ad_get_geometryelse-branch): raw copy ofparam.heads/param.sectors(u_int16_t IDENTIFY words 3/6) into theu_int8_tad_softc fields β no lower bound. The branch is taken whenever word 53 (atavalid) lacksATA_FLAG_54_58orcurrent_heads/current_sectorsare 0.- Nothing upstream validates the geometry:
ata_getparam()(ata-all.c:590-663) copies IDENTIFY verbatim intoatadev->param(le16toh + string bswap only; sole check = printable model byte),ata_identify()(ata-all.c:683-732) creates the child and callsbus_generic_attachβad_probe(config word only) βad_attach. ad_attach:147:info.d_ncylinders = adp->total_secs/(adp->heads*adp->sectors)β divisorheads*sectors== 0 β #DE.ad_describe:540(bootverbose): identical expression.- With a nonzero
lba_size(words 60/61) the dividend is nonzero (:489-490replaces total_secs) β a crafted device trivially arrangesX/0;0/0traps identically.
2. Rig (nata verbatim-interposition class β DF-2210/DF-249x shape)
dfrig2489.c KLD, boot-loaded via /boot/loader.conf (runtime
kldload GPFs on this INVARIANTS guest):
- Production
sys/dev/disk/nata/ata-disk.c#included verbatim (module plumbing neutralized; unusedad_devclassrenamed;build.shsha256-pins the file to the audited tree,8773a2df...== guest/usr/src). The rig carries byte-identicalad_attach/ad_describe/ad_get_geometryunder their production names. - Full production funnel (go1/2/3): the kernel's own global
ata_identify()runs on a fabricatedstruct ata_channel(real"ata"devclass,ATA_IDLE,dma=NULLPIO-only β legitimate perata-dma.c:153, productionata_queue_init()). The crafted 512-byte IDENTIFY is served by ahw.begin_transactionstub through the REALata_queue_request/ata_start/taskqueue machinery and parsed by the REALata_getparam(le16toh/bswap/btrim); REAL newbus child (device_add_childon the devclass-carrying channel), REALad_probe, and β the point β the RUNNING KERNEL'S OWNad_attach(nm:t ad_attach=0xffffffff80328d30). - ata_if setmode/locking on the channel = the interface's own DEFAULT
implementations (
ata_if.mCODE blocks). Scenarios run in a rig worker thread holding the MP token β the boot-attach context: the built-in atapicam driver'sidentify()adds a child to every probed channel and itsxpt_bus_register()asserts the MP token (cam_sim.c:104), which boot-time attach holds (first rig iteration without the MP lock hit that INVARIANTS assert β a rig-context artifact, fixed by mirroring the boot context, not by altering any production path). - Two early rig iterations wedged when the funnel was fired from the sysctl handler thread itself (attach-time sysctl/devfs registration from inside a live sysctl handler); moving dispatch to the dedicated worker thread resolved it. No production code was changed in either step.
3. Baseline results (stock kernel #0 Thu Jul 2 06:02:54 UTC 2026,
sha256 5dc83dac...)
| scenario | input | result |
|---|---|---|
| go1 CONTROL full funnel | IDENTIFY 16383/16/63, word53=0x0006, LBA 0x2000000 | clean attach: ad0: 16384MB <DF2489 SANE GEOMETRY FW000001> at ata0-master PIO4 + bootverbose ad0: 33554432 sectors [33288C/16H/63S] 16 sectors/interrupt 1 depth queue, ataraid metadata scan served (4 reads, "check1 failed"), RETURNED |
| go2 full funnel | word 3 heads = 0 | real getparam parse β kernel own ad_attach β Fatal trap 18: integer divide fault, IP 0xffffffff80328f7b = ad_attach+0x24b, Stopped at ad_attach+0x24b: divq %rsi,%eax (panic_go2_funnel_heads0.txt) |
| go3 full funnel | word 6 sectors = 0 | identical trap (panic_go3_funnel_sectors0.txt) |
| go4 verbatim copy | heads=0 post-parse state | identical divq trap in the rig-compiled production copy (panic_go4_verbatim_attach.txt) β byte-identity |
| go5 verbatim copy describe | ad_softc heads=0, bootverbose | 48MB <...> MB line prints, then trap 18 at ad_describe+0x1bf: divq β the :540 site (panic_go5_describe_bootverbose.txt) |
Instructionβline attribution is airtight (ad_attach_stock_disasm.txt):
trap IP is movslq %ecx,%rsi; div %rsi β u64 total_secs divided by
the 32-bit heads*sectors product, result stored then
device_get_unit/device_add_child(dev,"subdisk",...) = line 151;
i.e. exactly ata-disk.c:147. In the wild the :540 site is shadowed
by :147 (attach precedes describe, same divisor) but a :147-only fix
would leave every bootverbose boot panicking β the fix must (and does)
cover both.
4. Reachability honesty
Trigger: malicious/buggy ATA/SATA device, bridge dongle or VM disk
firmware reporting IDENTIFY word 3 = 0 and/or word 6 = 0 with word 53
not marking words 54-58 valid. Words 3/6 are retired in the modern ATA
spec β real devices report legacy 16/63 β which is exactly why nothing
sanity-checks them and a crafted device is unchallenged. Attack vector
is physical device attach (AV:P; the CVSS AV:L in the finding record is
generous β no unprivileged local user path exists on this guest: the
nata channels here are boot-time PCI devices, and vfs.usermount
irrelevant). Impact ceiling: kernel panic at attach (boot DoS /
hot-plug DoS). NOT an escalation β closed explicitly: the only
attacker-controlled values at the faulting instruction are the divisor
(0) and dividend; a #DE raises trap 18, stops all CPUs, drops to DDB β
no memory corruption, no controlled write, no route to uid=0.
5. Fix validation (single-fix kernel)
fix.diff clamps the geometry at its single source in
ad_get_geometry (headsβ16 / sectorsβ63 when 0; current_* branch
already guarded; total_secs still driven by LBA sizes). Applied to the
guest /usr/src, make -j6 nativekernel + installkernel
(fix_build.log: patched ata-disk.c compiled -Werror clean; kernel #1
Mon Oct 19 18:19:19 UTC 2026, sha256 7823c0ca...), rig rebuilt from
the PATCHED source (checker notes sha 0145d54b...), same scenarios
re-run (fix_run.log):
| scenario | stock kernel | fixed kernel |
|---|---|---|
| go1 control sane | clean, [33288C/16H/63S] describe |
identical output, byte-for-byte |
| go2 funnel heads=0 | Fatal trap 18 ad_attach+0x24b |
clean attach: ad1: 16384MB <DF2489 ZEROGEOM DISK FW000001> at ata0-master PIO4 + ad1: 33554432 sectors [33288C/16H/63S] ... β clamped geometry, :147 AND bootverbose :540 both executed safely, RAID scan ran, scenario DONE, guest alive |
| go4 verbatim copy heads=0 | divq trap |
no trap 18; attach proceeds through :147 and the full subdisk RAID scan (a later trap 12 in device_doattach is a rig-fabrication artifact β the go4 fake bsd_device lacks a sysctl_ctx, which the stock kernel never reached because it trapped first; the real-funnel go2 child is make_device'd and completes 100% cleanly) |
Fixed-binary disassembly (ad_attach_fixed_disasm.txt) shows the clamp
in the shipped text: test %dil,%dil; movb $0x10,0x8(%rax) /
test %sil,%sil; movb $0x3f,0x9(%rax) (ad_softc heads/sectors at
+0x8/+0x9) ahead of the (still present, now-safe) div.
Baseline reproduced = 1 (four variants), patched reproduced = 0. Fix status: fixed.
6. Honesty notes / limitations
- No physical ATA device exists under this QEMU (root is virtio-blk);
the rig replaces only the hardware-facing seams (channel device
graph, IDENTIFY/data/CONTROL answers, the ata_if DEFAULT
setmode/locking). For go1/2/3 every instruction from the IDENTIFY
bytes through
ata_getparam/ata_identify/newbus/ad_probe/ad_attachto the faultingdivqis the shipped kernel binary. - The panic fires in the rig worker thread on the guest; on a real
machine the identical fault fires from the boot attach (or hot-plug
reinit) funnel β the same first
ad_attachcall with the same device-supplied divisor. ad_describe'sunknown:nameunit in go5 is the fake device's nameunit; cosmetic, rig-only.- Related-but-distinct filed findings: DF-1883 (same sites, Low), DF-1604/DF-1738/DF-2491 (other zero-geometry divisors in XPT_CALC_GEOMETRY / ata-lowlevel CHS), DF-2496 (RAID geometry class, whose site-guard fix style this fix's source-clamp complements), DF-4798 (u16βu8 truncation in the same raw copy β the clamp also neutralizes its 0x0100β0 heads-trap corner).
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
n/a - panic only. Crafted ATA IDENTIFY (word3=0 or word6=0, word53 without ATA_FLAG_54_58) -> ata_getparam verbatim copy (ata-all.c:590) -> ad_get_geometry raw else-branch (ata-disk.c:477) -> ad_attach info.d_ncylinders = total_secs/(0*63) (ata-disk.c:147) -> x86 #DE -> Fatal trap 18, all CPUs stopped, DDB. No escalation path: the faulting instruction consumes only the zero divisor; no write primitive.
Evidence (decisive lines)
panic_go2_funnel_heads0.txt (kernel own binary, trap 18 at ad_attach+0x24b divq); panic_go3_funnel_sectors0.txt (word6=0, same); panic_go4_verbatim_attach.txt (verbatim copy, same divq); panic_go5_describe_bootverbose.txt (ad_describe+0x1bf divq, the :540 site, after the MB line prints); ad_attach_stock_disasm.txt (trap IP = movslq ecx,esi + div rsi before the device_add_child line-151 sequence - line attribution); run_control.log (sane geometry: clean attach, ad0 16384MB <DF2489 SANE GEOMETRY FW000001> at ata0-master PIO4 + bootverbose 33554432 sectors [33288C/16H/63S] line, RAID scan); fix_run.log (fixed kernel: go2 crafted heads=0 attaches cleanly with clamped [33288C/16H/63S], control byte-identical); ad_attach_fixed_disasm.txt (clamp in the shipped fixed text); build.baseline.log/build.patched.log (sha checker); env.txt
PoC changes
No seed pack existed (verify mode, orchestrator reserved DF-2489 only); rig authored from scratch following the DF-2210/DF-2496 nata verbatim-interposition class. Two rig iterations were needed: (1) scenarios initially fired from the sysctl handler thread wedged the guest during attach-time sysctl/devfs registration - moved to a dedicated kthread; (2) the built-in atapicam driver attaches to every probed channel and xpt_bus_register asserts the MP token - the worker now holds it, mirroring the boot-attach context. No production code altered; nata headers have no include guards so includes were ordered around the verbatim #include.
Verified recommended fix
Clamp degenerate device CHS geometry at its source in ad_get_geometry(): if adp->heads==0 set 16, if adp->sectors==0 set 63 (covers both the :147 and :540 divisions and keeps d_nheads/d_secpertrack sane)
Verdict
REPRODUCED on the stock INVARIANTS guest, then FIX VALIDATED. Driving the full production funnel (kernel own ata_identify -> real ata_getparam parse of a crafted IDENTIFY -> real newbus -> RUNNING KERNEL own ad_attach, nm 0xffffffff80328d30) with IDENTIFY word3 heads=0 (or word6 sectors=0) and word53=0x0006 produces Fatal trap 18 integer divide fault at ad_attach+0x24b (divq, total_secs/(heads*sectors), ata-disk.c:147); the bootverbose ad_describe divisor traps identically at ad_describe+0x1bf (ata-disk.c:540); the verbatim #included production copy behaves identically. ad_get_geometry else-branch ata-disk.c:477-478 copies the raw u16 IDENTIFY words with no lower bound and nothing between ata_getparam and ad_attach validates them. Panic-class DoS at device attach (boot/hot-plug, malicious or buggy device/bridge firmware, AV:P): no memory corruption, no route to uid=0 (closed: privesc not reachable from a #DE with attacker-controlled divisor only). Fix: clamp heads->16/sectors->63 when 0 at the single source in ad_get_geometry; single-fix kernel rebuilt, the same crafted IDENTIFY now attaches cleanly (ad1 16384MB
No comments yet.