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

TBI-mode nge(4) deletes itself: sc->nge_miibus = dev passed to device_delete_child(dev, dev) β€” unbounded nge_detach recursion on detach, live-device kobj_delete UAF on attach failure

Summary

When no PHY is found and NGE_CFG_TBI_EN is set, attach points the miibus handle at the device itself; nge_detach then calls device_delete_child(dev, dev). On normal detach the nested device_detach re-enters nge_detach recursively forever (double bus_teardown_intr, double ether_ifdetach, double contigfree of the 3.3MB jumbo arena) - guaranteed panic. On the attach-fail path the nested detach unlinks the LIVE device from the PCI bus children mid-attach and kobj_delete()s it - newbus UAF for the rest of device_attach. TBI (fiber) boards + root trigger. Self-reference-guard fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4114 Β· 7 files
FileTypeDescriptionSize
README.md β€” 3.6 KB ↓ raw
VERDICT.md β€” 3.4 KB ↓ raw
presence_probe.sh β€” 425 B view raw
env.txt β€” 347 B view raw
fix.diff β€” 600 B view raw
manifest.json β€” 798 B view raw
verdict.json β€” 3.2 KB view raw

DF-4114 β€” nge(4) TBI mode: sc->nge_miibus = dev self-reference makes nge_detach delete itself β€” recursion panic / live-device free (UAF)

What this is

When no MII PHY is found and the TBI (ten-bit/fiber) interface is enabled, nge_attach points the miibus handle at the device itself:

} else if (CSR_READ_4(sc, NGE_CFG) & NGE_CFG_TBI_EN) {   /* if_nge.c:860 */
        sc->nge_tbi = 1;
        sc->nge_miibus = dev;                             /* if_nge.c:864 - SELF */

nge_detach then hands that handle to newbus as if it were a child:

if (sc->nge_miibus)
        device_delete_child(dev, sc->nge_miibus);          /* if_nge.c:941-942 = (dev, dev) */

device_delete_child(parent, child) (sys/kern/subr_bus.c:1284-1310) unconditionally does:

if ((error = device_detach(child)) != 0)          /* subr_bus.c:1292 */
        return(error);
while ((grandchild = TAILQ_FIRST(&child->children)))    /* :1296 */
        device_delete_child(child, grandchild);
if (child->devclass)
        devclass_delete_device(child->devclass, child);  /* :1302-1303 */
TAILQ_REMOVE(&dev->children, child, link);        /* :1304 - child NOT a member of dev->children */
TAILQ_REMOVE(&bus_data_devices, child, devlink);  /* :1305 */
kobj_delete((kobj_t)child, M_BUS);                /* :1306 - frees the device */

Two distinct blow-ups:

A. Normal detach (kldunload if_nge / devctl) β€” unbounded recursion. nge_detach is invoked from device_detach while dev->state is still DS_ATTACHED (state is only set to DS_NOTPRESENT at subr_bus.c:2149, after DEVICE_DETACH returns). The nested device_delete_child(dev, dev) β†’ device_detach(dev) sees DS_ATTACHED (subr_bus.c:2136-2137) β†’ calls DEVICE_DETACH(dev) β†’ nge_detach again β†’ same device_delete_child(dev, dev) β†’ … Each level re-runs the attached block: second bus_teardown_intr on the stale cookie, second ether_ifdetach/if_detach on an already-detached ifnet, double contigfree of nge_ldata and the 3.3 MB jumbo arena β€” list corruption / double-free panics within a few levels, or kernel stack exhaustion if it somehow gets further. Panic either way.

B. Attach-failure path β€” live device freed (UAF). nge_attach calls nge_detach(dev) directly on its own goto fail (if_nge.c:921) β€” e.g. bus_setup_intr failure (if_nge.c:913-916) after the TBI branch was taken. There dev->state is DS_ATTACHING, so the nested device_detach(dev) returns 0 immediately (subr_bus.c:2136-2137 "state != DS_ATTACHED β†’ return(0)"), the grandchild loop is empty, and execution reaches TAILQ_REMOVE(&dev->children, dev, link) (subr_bus.c:1304) β€” removing the device from a list it is not a member of, which (tqe links being per-entry) silently unlinks dev from its parent PCI bus's children list mid-attach β€” followed by kobj_delete((kobj_t)dev) (:1306) freeing the live device_t. device_attach then keeps using the freed device (state store, sysctl_fini, printf) β†’ use-after-free write/read in newbus on a root-triggered path.

Threat model

Requires (a) a TBI-mode DP83820 board (fiber/1000baseSX; no external PHY) β€” NGE_CFG_TBI_EN β€” and (b) root (kldunload/devctl/driver re-probe, or an IRQ-allocation failure at boot for path B). Privileged + specific hardware β†’ Low severity, but a genuine newbus-corrupting logic bug (and DFly-specific: the TBI branch is a DragonFly addition).

PoC status β€” untested (hardware absent)

Guest has no DP83820 of any kind (presence_probe.sh); the TBI branch cannot be entered. Source-level certainty: every step above is unconditional on that path.

Fix

Never delete ourselves β€” guard sc->nge_miibus != dev (fix.diff).

VERDICT.md
↓ download raw

DF-4114 β€” VERDICT

Finding: In TBI mode nge_attach sets sc->nge_miibus = dev (sys/dev/netif/nge/if_nge.c:864); nge_detach then calls device_delete_child(dev, sc->nge_miibus) = device_delete_child(dev, dev) (if_nge.c:941-942), which newbus does not support.

Status: untested. Hardware-gated: requires a TBI-mode DP83820 (NGE_CFG_TBI_EN, no external PHY) plus root (detach path) or an IRQ-allocation failure at boot (attach-fail path). The audit guest has no NS DP83820 at all (presence_probe.sh: NGE_ABSENT), so neither branch can be entered. Source-level certainty β€” traced step by step below.

Path A β€” normal detach (kldunload if_nge, devctl): unbounded recursion β†’ panic. 1. kldunload β†’ device_detach(dev) with dev->state == DS_ATTACHED (sys/kern/subr_bus.c:2136). 2. DEVICE_DETACH(dev) β†’ nge_detach(dev); state is still DS_ATTACHED (it becomes DS_NOTPRESENT only at subr_bus.c:2149, after the method returns). 3. nge_detach reaches if_nge.c:941-942 β†’ device_delete_child(dev, dev) β†’ subr_bus.c:1292 device_detach(dev) β†’ still DS_ATTACHED β†’ DEVICE_DETACH(dev) β†’ nge_detach again β†’ same call β†’ infinite recursion. Each frame re-runs the attached block: second bus_teardown_intr (stale cookie), second ether_ifdetach on an already-detached ifnet, contigfree of nge_ldata and the jumbo arena a second time (if_nge.c:949-954) β€” double-free/list corruption panics within a few recursion levels; unbounded stack otherwise.

Path B β€” attach-failure path (nge_attach goto fail β†’ direct nge_detach at if_nge.c:921, e.g. bus_setup_intr failure at :913-916 after the TBI branch): live device freed β†’ newbus UAF. 1. dev->state == DS_ATTACHING during the direct call, so device_is_attached(dev) is false and the stop/teardown block is skipped; sc->nge_miibus == dev is non-NULL β†’ device_delete_child (dev, dev). 2. Nested device_detach(dev) returns 0 immediately (subr_bus.c:2136-2137: state != DS_ATTACHED β†’ 0). 3. grandchild loop empty (TBI mode added no children) β†’ devclass_delete_device (removes the live device from the nge devclass), TAILQ_REMOVE(&dev->children, dev, link) (subr_bus.c:1304) β€” dev is not a member of its own children list; the per-entry tqe links make this silently unlink dev from the parent PCI bus children list mid-attach β€” then kobj_delete((kobj_t)dev, M_BUS) (subr_bus.c:1306) frees the live device. 4. device_attach/probe machinery continues on the freed device_t (state stores, devname printf, sysctl) β€” use-after-free read/write in newbus.

Impact ceiling: privileged DoS with certainty (panic/UAF); on TBI-equipped systems it also bricks driver re-probing for the slot. Not remotely triggerable; no unprivileged path.

Fix: never pass the self-reference to newbus β€” if (sc->nge_miibus && sc->nge_miibus != dev) (fix.diff; validated as text against the read-only tree, patch --dry-run clean; runtime validation impossible without a TBI DP83820 β†’ fix_status not_tested).

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
baseline no→ patch + rebuild →patched clean

fix.diff authored against the read-only sys/ tree (patch --dry-run clean); cannot be validated without a TBI-mode DP83820 board.

findings/poc/DF-4114/fix.diff
↓ fix.diffper-fix-DF-4114

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-4114/VERDICT.md (trace if_nge.c:860-864/913-922/941-942/949-954, subr_bus.c:1284-1310/2129-2153); presence_probe.sh + env.txt; fix.diff (self-child guard)

PoC changes

No seed PoC. Hardware-gated (no DP83820/TBI board on guest); presence probe only.

Verified recommended fix

Guard the detach self-reference: if (sc->nge_miibus && sc->nge_miibus != dev) device_delete_child(dev, sc->nge_miibus);

Verdict

Source-certain, hardware-gated. In TBI mode nge_attach sets sc->nge_miibus = dev (self-reference, sys/dev/netif/nge/if_nge.c:864); nge_detach then calls device_delete_child(dev, dev) (if_nge.c:941-942). Path A (normal detach via kldunload/devctl): the nested device_delete_child -> device_detach(dev) sees state still DS_ATTACHED (set to DS_NOTPRESENT only at subr_bus.c:2149, after DEVICE_DETACH returns) and re-enters nge_detach recursively - unbounded recursion re-running teardown each level (double bus_teardown_intr, double ether_ifdetach, double contigfree of nge_ldata and the jumbo arena, if_nge.c:949-954) -> guaranteed panic (list corruption/double-free or stack exhaustion). Path B (attach-fail: nge_attach goto fail calls nge_detach directly at if_nge.c:921, e.g. bus_setup_intr failure at :913-916 after the TBI branch): the nested device_detach returns 0 for DS_ATTACHING (subr_bus.c:2136-2137), the empty-grandchild loop is skipped, and TAILQ_REMOVE(&dev->children, dev, link) (subr_bus.c:1304) silently unlinks the live device from its PARENT PCI bus children list mid-attach, then kobj_delete((kobj_t)dev) (subr_bus.c:1306) frees the live device_t - use-after-free in newbus for the remainder of device_attach. Requires a TBI-mode DP83820 board plus root (or boot-time IRQ alloc failure) -> Low severity, privileged DoS + newbus corruption. Guest has no DP83820 (presence_probe.sh: NGE_ABSENT): honestly untested; not a false positive - every step is unconditional in the source.