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

ix_detach never terminates the self-re-arming per-ring tx_gc_timer: pending callout fires on freed tx_rings β†’ use-after-free (write into freed slab + callout-queue corruption)

Summary

ix_txgc_timer re-arms itself every tick unconditionally at its done: label, so a pending armed callout exists whenever the interface runs. ix_stop only callout_stop()s it - which cannot prevent an in-flight callback from re-arming after the stop. ix_detach terminates sc->timer and sc->fw_timer but NOT tx_gc_timer, then frees tx_base/tx_buf/maps and kfree's tx_rings. The orphaned 1-tick callout then reads txr->tx_sc->...if_flags from freed memory, performs lwkt_serialize_try (atomic write) on the freed serializer, and callout_resets the freed embedded callout. Root-only trigger (kldunload/PCI hot-remove) with a per-tick race window -> UAF write into freed M_DEVBUF slab and callout-subsystem corruption. Fix: callout_terminate loop over tx_rings at detach.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3669 Β· 8 files
FileTypeDescriptionSize
README.md β€” 1.9 KB ↓ raw
VERDICT.md β€” 3.5 KB ↓ raw
fix.diff β€” 676 B view raw
build_baseline.log β€” 15.8 KB view raw
build_patched.log β€” 15.8 KB view raw
env.txt β€” 1.0 KB view raw
manifest.json β€” 635 B view raw
verdict.json β€” 2.4 KB view raw

DF-3669 β€” ix(4) detach leaves self-re-arming tx_gc_timer callout armed β†’ use-after-free of tx_rings

File: sys/dev/netif/ix/if_ix.c Β· Severity: Low (root-only trigger: kldunload/hot-remove) Β· Class: lifetime/UAF

PoC

No runnable PoC: requires (a) an attached ixgbe NIC (guest has none, env.txt) and (b) an unprivileged-code-unreachable event β€” driver detach (kldunload or PCI hot-removal), which is root-only. Documented source-level race:

  1. ix_txgc_timer() (if_ix.c:352-374) re-arms itself unconditionally at the done: label: callout_reset(&txr->tx_gc_timer, 1, ix_txgc_timer, txr) (if_ix.c:373) β€” while the interface is RUNNING there is always a pending armed callout.
  2. ix_stop() only does callout_stop(&txr->tx_gc_timer) (if_ix.c:1767) β€” callout_stop() does not wait for an in-flight callback; a callback executing concurrently takes the goto done path (its lwkt_serialize_try fails while detach holds all serializers) and re-arms the callout after the stop.
  3. ix_detach() terminates sc->timer and sc->fw_timer (if_ix.c:693,696) but never the per-ring tx_gc_timer, then frees the rings: ix_free_rings() (if_ix.c:721) β†’ ix_destroy_tx_ring() (if_ix.c:2102-2122, frees tx_base/tx_buf/DMA maps) and kfree(sc->tx_rings) (if_ix.c:4002).
  4. The re-armed 1-tick callout fires on freed memory: reads txr->tx_sc->arpcom.ac_if.if_flags, then lwkt_serialize_try(&txr->tx_serialize) β€” a write into freed slab memory β€” and callout_reset on the freed embedded callout.

Fix validation (compile-level)

fix.diff adds callout_terminate() for every tx_gc_timer in ix_detach() before rings are freed (terminate disarms AND waits for running callbacks). Compile-validated on the guest's md5-identical /usr/src (build_patched.log, -Werror clean).

VERDICT.md
↓ download raw

DF-3669 β€” VERDICT

Status: inconclusive (runtime) / source-level certain. Impact class: use-after-free on driver detach. Root-only trigger, narrow race window.

Root cause and race walk

  • ix_txgc_timer() (if_ix.c:352-374) is a 1-tick self-re-arming callout (re-arm at if_ix.c:373). While the interface is RUNNING, an armed callout is pending essentially always; early returns at 357-359/364-368 are the only non-re-arming exits and require IFF_RUNNING to already be clear.
  • ix_stop() (called from ix_powerdown() under ifnet_serialize_all() in ix_detach(), if_ix.c:686-689) issues only callout_stop(&txr->tx_gc_timer) (if_ix.c:1767). DragonFly callout_stop() disarms a pending callout but does not wait out a currently executing callback.
  • A concurrently executing callback on the ring's pinned CPU (tx_gc_timer is callout_init_mp, reset via callout_reset_bycpu(..., txr->tx_intr_cpuid) at if_ix.c:1157) observes: flags check passes (pre-stop), lwkt_serialize_try(&txr->tx_serialize) FAILS (detach holds all serializers via ifnet_serialize_all), so it takes goto done β†’ callout_reset(1 tick) β€” re-armed after the stop.
  • ix_detach() then: callout_terminate(&sc->timer) (693), callout_terminate(&sc->fw_timer) (696) β€” no terminate for tx_gc_timer β€” releases resources (if_ix.c:698-735), and ix_free_rings() (721) frees tx_base/tx_buf/DMA maps (ix_destroy_tx_ring, if_ix.c:2090-2123) and the ring array itself (kfree(sc->tx_rings), if_ix.c:4002).
  • ~10 ms later (1 tick at hz=100) the orphaned callout fires:
  • reads txr->tx_sc->arpcom.ac_if.if_flags from freed memory (UAF read),
  • lwkt_serialize_try(&txr->tx_serialize) β€” atomic RMW write into freed slab memory,
  • callout_reset() on the freed embedded struct callout β€” corrupts the per-CPU callout queue. If the slab is reused (M_DEVBUF) between free and fire, the callback dispatch itself reads attacker-influenced function/argument pointers.

Trigger conditions & severity

  • Requires an attached ix(4) device and driver detach (kldunload if_ix is impossible when built-in; device_delete/hot-remove or module unload on a modular kernel) β€” root-only, so Low per the audit rubric.
  • The race window is one callback execution per tick; under TX GC load with a module build, repeated kldunload-under-traffic eventually hits it.

Verification performed

  1. Source walk (all lines cited above) β€” no callout_terminate for tx_gc_timer anywhere in the driver (rg confirms only callout_stop at 1767).
  2. Guest has no ixgbe NIC (env.txt): ix_attach() never runs, no callout is ever armed β†’ runtime reproduction impossible on this guest; also the trigger needs root, so it would be out of scope for an unpriv escalation anyway.
  3. fix.diff adds the missing callout_terminate() loop in ix_detach() (guarded by sc->tx_rings != NULL for the attach-failure path). Compile-validated: patched if_ix.ko builds with -Werror, clean (build_patched.log); guest /usr/src restored to pristine afterwards.

fix.diff in this pack.

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

Compile-validated only; runtime race validation needs an attached NIC.

build_baseline.log / build_patched.log (-Werror clean with fix).
↓ fix.diffper-fix-DF-3669

Confirmed kernel references

Detail

Evidence (decisive lines)

VERDICT.md (full path:line race walk); build_patched.log; env.txt

PoC changes

New pass-2 finding, no seed.

Verified recommended fix

callout_terminate() every tx_gc_timer in ix_detach() before ix_free_rings() (fix.diff).

Verdict

Source-level certain defect, runtime-untestable: ix_txgc_timer (if_ix.c:352-374) re-arms itself every tick (373); ix_stop only callout_stop()s it (1767), which cannot stop an in-flight callback from re-arming; ix_detach terminates sc->timer and sc->fw_timer (693,696) but never the per-ring tx_gc_timer, then frees tx_base/tx_buf/maps (721 -> 2090-2123) and kfree's tx_rings (4002). The orphaned 1-tick callout then fires on freed memory: UAF read of if_flags, UAF write via lwkt_serialize_try on the freed serializer, and callout-queue corruption via callout_reset on the freed embedded callout. Trigger is root-only (kldunload/hot-remove of an attached ixgbe NIC) with a per-tick race window -> Low severity. Guest has no ixgbe device (env.txt), so no runtime reproduction; fix.diff (callout_terminate per ring at detach) compile-validated with -Werror on md5-identical guest source.