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

bridge_delete_span() TAILQ_REMOVEs span bif from the per-cpu MEMBER list head β€” list-tail corruption cross-links span and member lists, leading to KASSERT panic / member-bif UAF and double-free

Summary

bridge_delete_span() executes TAILQ_REMOVE(&sc->sc_iflists[mycpuid], bif, bif_next) (:1155) but span bifs are linked on sc_spanlist (inserted at :1832). Removing the last span entry runs head->tqh_last = elm->tqe_prev against the member-list head, leaving sc_iflists[0].tqh_last = &sc->sc_spanlist.tqh_first. The LINK2 bonding round-robin TAILQ_INSERT_TAIL (:2140) then writes a member bif into sc_spanlist.tqh_first - cross-linking the lists. REPRODUCED 3x end-to-end on stock INVARIANTS guest ('panic: bridge_delete_span: not a span interface' after bonding traffic rotated member order). On non-INVARIANTS kernels the same chain unlinks and kfrees a live member bif while if_bridge still points at the softc and other cpus still hold their bifs - UAF/double-free at teardown (M_DEVBUF slab corruption, timing attacker-influenced via traffic). Root configures (bonding set + span port); deleting the span port corrupts; bonding traffic cross-links; teardown frees live objects. Fix validated (one-line: TAILQ_REMOVE from sc_spanlist): probe returns ENOENT, destroy clean.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3277 Β· 11 files
FileTypeDescriptionSize
README.md β€” 2.5 KB ↓ raw
bond_corrupt.c β€” 13.9 KB view raw
verdict.json β€” 4.1 KB view raw
env.txt β€” 743 B view raw
build.log β€” 341 B view raw
fix.diff β€” 1.2 KB view raw
run.sh β€” 153 B view raw
panic.txt β€” 2.9 KB view raw
run.log β€” 2.6 KB view raw
VERDICT.md β€” 4.4 KB ↓ raw
run_fixed.log β€” 1.5 KB view raw

DF-3277 β€” bridge_delete_span() unlinks span bif from the WRONG list β†’ member/span list cross-linkage β†’ KASSERT panic / UAF / double-free

What

bridge_delete_span() executes

TAILQ_REMOVE(&sc->sc_iflists[mycpuid], bif, bif_next);   /* if_bridge.c:1155 */

but span bifs are linked on sc->sc_spanlist (TAILQ_INSERT_HEAD(&sc->sc_spanlist, bif, bif_next), if_bridge.c:1832). When the removed span bif is the last element of the span list (tqe_next == NULL), TAILQ_REMOVE executes head->tqh_last = elm->tqe_prev against the per-cpu member list head, so sc_iflists[0].tqh_last is clobbered to point at &sc->sc_spanlist.tqh_first.

The next TAILQ_INSERT_TAIL on the member list β€” the LINK2 bonding round-robin in bridge_select_unicast() (if_bridge.c:2135-2143) β€” then executes *(sc_iflists[0].tqh_last) = alt_bif, i.e. it writes a member bif pointer into sc_spanlist.tqh_first, cross-linking the two lists. After that, span-list operations treat live member bifs as span ports: bridge_delete_span()'s KASSERT fires (INVARIANTS), or on stock kernels the member bif is unlinked from the member list and freed while still a member β†’ use-after-free / double-free at teardown (bridge_del_bif_handler KKASSERTs bif != NULL, or kfree hits freed memory).

Reproduce (guest, as root)

cd /root/poc/df-3277
cc -O -o bond_corrupt bond_corrupt.c
./bond_corrupt

The PoC (pinned to cpu0, all frames ETHERTYPE_ARP so the netisr hash keeps processing on cpu0):

  1. bridge0 with tap0/tap1 sharing one MAC (bonding set, STP on, fwddelay 1s), tap2 normal member; bridge link2 + up; tap fds opened before bridge-up so STP ports reach FORWARDING.
  2. spanport tap3 + -spanport tap3 β†’ corrupts sc_iflists[0].tqh_last.
  3. Learn 02:00:00:00:00:55 on tap0, then unicast to it from tap2 β†’ bonding round-robin TAILQ_INSERT_TAIL through the stale tqh_last β†’ sc_spanlist.tqh_first = <tap1's member bif>.
  4. Probe: BRDGDELS("tap1") β€” finds the MEMBER on the span list.

Expected

[+] list order before bonding traffic:    [member order: tap2 tap1 tap0]
[+] after bcast learn frame:    [rtable: 02:00:00:00:00:55->tap0]
[+] list order after bonding traffic:    [member order: tap2 tap0 tap1]
panic: bridge_delete_span: not a span interface
bridge_delete_span() at bridge_delete_span+0x87
bridge_ioctl_delspan() at bridge_ioctl_delspan+0x58

The member-order flip (tap1 moved to tail) is the INSERT_TAIL firing; the probe panic proves tap1 β€” a member with if_bridge == sc β€” was linked on sc_spanlist.

VERDICT.md
↓ download raw

DF-3277 β€” VERDICT

REPRODUCED β€” kernel panic (KASSERT), with a documented escalation path to use-after-free / double-free on non-INVARIANTS builds.

Environment

Same guest as DF-3276 (DragonFly 6.5-DEVELOPMENT, X86_64_GENERIC, 6 CPUs; file byte-identical to DragonFlyBSD master).

Root cause (code)

  • bridge_delete_span() if_bridge.c:1155: TAILQ_REMOVE(&sc->sc_iflists[mycpuid], bif, bif_next); β€” the bif is on sc->sc_spanlist (inserted at if_bridge.c:1832). Removing the last span element runs head->tqh_last = elm->tqe_prev with the wrong head: sc_iflists[0].tqh_last := &sc->sc_spanlist.tqh_first.
  • bridge_select_unicast() if_bridge.c:2135-2143: with LINK2 and a bonding set (equal-MAC members, equal priority, both forwarding), every bif_bond_weight packets it does TAILQ_REMOVE(...); TAILQ_INSERT_TAIL(&sc->sc_iflists[mycpuid], alt_bif, bif_next); β€” the INSERT_TAIL writes sc_spanlist.tqh_first = alt_bif through the stale pointer.

Reproduction narrative (3 panics; full chain captured on the 3rd)

Iterations (all on the single-tenant QEMU guest):

  1. First runs stalled: STP ports never reached FORWARDING with no peers — root-caused to bstp_enable_port() parking enabled ports in BLOCKING without a state selection; fixed in the PoC by opening the tap fds (media active) before bridge-up so bstp_initialization()'s final bstp_port_state_selection() drives them LISTENING→LEARNING→FORWARDING.
  2. Learn/lookup frames initially ETHERTYPE_IP with zero payload were freed by ip_lengthcheck (sys/netinet/ip_demux.c:163, ip_hl=0) before ever reaching bridge_input. Switched to ETHERTYPE_ARP β€” NETISR_ARP has no hashfn so hash==0 and all processing happens on cpu0, matching the corrupted sc_iflists[0].
  3. Full chain run (serial-console capture, run.log):
    t= 2 tap0: state=3 flags=0x17 | tap1: state=3 flags=0x37   # FORWARDING
[+] spanport tap3 added+deleted (list head corrupted)
[+] list order before bonding traffic:  [member order: tap2 tap1 tap0]
[+] after bcast learn frame:  [rtable: 02:00:00:00:00:55->tap0]  # learning works
[+] list order after bonding traffic:   [member order: tap2 tap0 tap1]  # INSERT_TAIL fired
[+] rtable after:  [02:...:66->tap2 02:...:55->tap0]
panic: bridge_delete_span: not a span interface
bridge_delete_span() at bridge_delete_span+0x87
bridge_ioctl_delspan() at bridge_ioctl_delspan+0x58
bridge_control_dispatch() at bridge_control_dispatch+0x64
netmsg_service_loop() at netmsg_service_loop+0xe9

The delspan("tap1") probe found tap1 β€” a member whose if_bridge == sc β€” on the span list; bridge_delete_span's KASSERT (if_bridge.c:1152) fired. Three independent runs panicked with the identical signature.

Impact / escalation

  • On INVARIANTS kernels (like this guest): deterministic panic.
  • On stock kernels the same chain silently unlinks the member bif from the per-cpu member list and kfrees it while the interface is still a bridge member (if_bridge still points at the softc; the other cpus' member lists still hold their bifs). Subsequent teardown hits KKASSERT(bif != NULL && bif->bif_onlist) in bridge_del_bif_handler (KKASSERT is compiled unconditionally) or kfrees memory still referenced by the span list β€” a use-after-free/double-free of M_DEVBUF objects. With attacker-shaped heap grooming (the freed bif is 40-ish bytes in the devbuf slab) this is a memory-corruption primitive; both the corruption writer (bonding round-robin) and the freeing path are driven by packet traffic once root has configured the topology.
  • Configuration requires root (BRDGADDS/BRDGDELS/BRDGSIFFLGS are BC_F_SUSER-gated), hence Medium severity rather than High.

Fix validation

fix.diff (one-line: unlink from &sc->sc_spanlist) applied to the guest's /usr/src alongside the DF-3276 fix; kernel rebuilt and rebooted; the identical PoC then ran to completion: bonding round-robin still reorders the member list, but the delspan("tap1") probe correctly returns ENOENT and ifconfig bridge0 destroy completes cleanly (run_fixed.log).

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Rebuilt kernel (combined fix_all.diff): identical PoC runs to completion -- member learning and bonding round-robin still operate (on the correct list), the delspan('tap1') probe correctly returns ENOENT, ifconfig destroy completes cleanly, no panic, guest up.

findings/poc/DF-3277/run_fixed.log; findings/poc/DF-3277/fix.diff
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Thu Sep 10 00:48:01 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

spanport add/del (root) -> sc_iflists[0].tqh_last corrupted -> bonding INSERT_TAIL (packet-driven, LINK2 + same-MAC members) -> sc_spanlist.tqh_first = member bif -> delspan/destroy walks member bif as span: KASSERT panic (INVARIANTS) or unlink+kfree of live member bif -> UAF/double-free (stock kernels). Memory-corruption primitive is root-config-gated, then traffic-driven.

Evidence (decisive lines)

['run.log: full serial-console chain -- states FORWARDING, member-order rotation, rtable learn, then the panic', 'panic.txt: second independent reproduction, identical stack (bridge_delete_span+0x87 <- bridge_ioctl_delspan+0x58)', 'run_fixed.log: patched kernel -- same PoC, bonding still reorders, delspan(tap1) probe returns ENOENT, destroy clean, guest up', 'fix.diff: one-line TAILQ_REMOVE head correction']

PoC changes

Written from scratch. Three PoC-environment pitfalls solved en route (documented so the pack reproduces first try): (1) tap fds must be opened BEFORE bridge-up so bstp_ifupdstatus sees IFM_ACTIVE and bstp_initialization drives ports to FORWARDING (bstp_enable_port parks enabled ports in BLOCKING without a state selection); (2) frames must be ETHERTYPE_ARP -- NETISR_ARP has no hashfn so processing stays on cpu0 (ETHERTYPE_IP frames with ip_hl=0 are freed by ip_lengthcheck before the bridge); (3) process pinned to cpu0 via sched_setaffinity (DragonFly has no cpuset(1)/sys/cpuset.h).

Verified recommended fix

bridge_delete_span: TAILQ_REMOVE(&sc->sc_spanlist, bif, bif_next) instead of &sc->sc_iflists[mycpuid]

Verdict

List-head confusion: bridge_delete_span() TAILQ_REMOVEs the span bif against &sc->sc_iflists[mycpuid] although span bifs live on sc_spanlist (if_bridge.c:1155 vs 1832). Removing the last span entry clobbers sc_iflists[0].tqh_last to &sc->sc_spanlist.tqh_first; the LINK2 bonding round-robin TAILQ_INSERT_TAIL (if_bridge.c:2140) then writes a member bif into sc_spanlist.tqh_first, cross-linking the lists. Reproduced three times end-to-end on the stock guest with a full instrumentation chain: same-MAC STP tap pair (FORWARDING), link2 bridge, spanport add+del, ARP-frame learn+forward pinned to cpu0 (member order visibly rotated by the round-robin: 'tap2 tap1 tap0' -> 'tap2 tap0 tap1'), then BRDGDELS('tap1') found the MEMBER on the span list -> 'panic: bridge_delete_span: not a span interface' (bridge_ioctl_delspan -> bridge_delete_span, cpuid 0). On non-INVARIANTS kernels the same chain silently unlinks and kfrees a live member bif (if_bridge still set; other cpus still hold their bifs) -> UAF/double-free at teardown; the impact on this INVARIANTS guest is the deterministic KASSERT panic.