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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2489 Β· 22 files
FileTypeDescriptionSize
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 production sys/dev/disk/nata/ata-disk.c VERBATIM (module plumbing neutralized, unused global ad_devclass renamed; build.sh sha256-checks the file = 8773a2dfc4cfc372b1d2be6c2775fd72e0445e56cdc974615fb94654f6b78de1 == audited sys/ tree). The rig therefore carries its own byte-identical ad_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-only dma=NULL, production ata_queue_init()). It runs the REAL kmalloc'ed ata_device, the REAL ata_getparam() β€” the crafted 512-byte IDENTIFY image is served by a hw.begin_transaction stub through the REAL ata_queue_request/ata_start machinery and parsed by the REAL le16toh/bswap/btrim code β€” the REAL newbus child, REAL ad_probe, and the RUNNING KERNEL'S OWN ad_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.m CODE blocks: ata_null_setmode via ata_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 and xpt_bus_register asserts 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).

VERDICT.md
↓ download raw

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_geometry else-branch): raw copy of param.heads/param.sectors (u_int16_t IDENTIFY words 3/6) into the u_int8_t ad_softc fields β€” no lower bound. The branch is taken whenever word 53 (atavalid) lacks ATA_FLAG_54_58 or current_heads/current_sectors are 0.
  • Nothing upstream validates the geometry: ata_getparam() (ata-all.c:590-663) copies IDENTIFY verbatim into atadev->param (le16toh + string bswap only; sole check = printable model byte), ata_identify() (ata-all.c:683-732) creates the child and calls bus_generic_attach β†’ ad_probe (config word only) β†’ ad_attach.
  • ad_attach:147: info.d_ncylinders = adp->total_secs/(adp->heads*adp->sectors) β€” divisor heads*sectors == 0 β†’ #DE. ad_describe:540 (bootverbose): identical expression.
  • With a nonzero lba_size (words 60/61) the dividend is nonzero (:489-490 replaces total_secs) β€” a crafted device trivially arranges X/0; 0/0 traps 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; unused ad_devclass renamed; build.sh sha256-pins the file to the audited tree, 8773a2df... == guest /usr/src). The rig carries byte-identical ad_attach/ad_describe/ad_get_geometry under their production names.
  • Full production funnel (go1/2/3): the kernel's own global ata_identify() runs on a fabricated struct ata_channel (real "ata" devclass, ATA_IDLE, dma=NULL PIO-only β€” legitimate per ata-dma.c:153, production ata_queue_init()). The crafted 512-byte IDENTIFY is served by a hw.begin_transaction stub through the REAL ata_queue_request/ata_start/taskqueue machinery and parsed by the REAL ata_getparam (le16toh/bswap/btrim); REAL newbus child (device_add_child on the devclass-carrying channel), REAL ad_probe, and β€” the point β€” the RUNNING KERNEL'S OWN ad_attach (nm: t ad_attach = 0xffffffff80328d30).
  • ata_if setmode/locking on the channel = the interface's own DEFAULT implementations (ata_if.m CODE blocks). Scenarios run in a rig worker thread holding the MP token β€” the boot-attach context: the built-in atapicam driver's identify() adds a child to every probed channel and its xpt_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_attach to the faulting divq is 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_attach call with the same device-supplied divisor.
  • ad_describe's unknown: 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_testable
↓ fix.diffper-fix-DF-2489

Confirmed 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 [33288C/16H/63S]) and the control describe output is byte-identical to stock; the clamp is visible in the fixed binary (movb 0x10/0x3f into ad_softc+0x8/+0x9 before the div).