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)
PoC verification
Evidence pack
findings/poc/DF-3669 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:
ix_txgc_timer()(if_ix.c:352-374) re-arms itself unconditionally at thedone: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.ix_stop()only doescallout_stop(&txr->tx_gc_timer)(if_ix.c:1767) βcallout_stop()does not wait for an in-flight callback; a callback executing concurrently takes thegoto donepath (itslwkt_serialize_tryfails while detach holds all serializers) and re-arms the callout after the stop.ix_detach()terminatessc->timerandsc->fw_timer(if_ix.c:693,696) but never the per-ringtx_gc_timer, then frees the rings:ix_free_rings()(if_ix.c:721) βix_destroy_tx_ring()(if_ix.c:2102-2122, freestx_base/tx_buf/DMA maps) andkfree(sc->tx_rings)(if_ix.c:4002).- The re-armed 1-tick callout fires on freed memory: reads
txr->tx_sc->arpcom.ac_if.if_flags, thenlwkt_serialize_try(&txr->tx_serialize)β a write into freed slab memory β andcallout_reseton the freed embeddedcallout.
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).
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; earlyreturns at 357-359/364-368 are the only non-re-arming exits and require IFF_RUNNING to already be clear.ix_stop()(called fromix_powerdown()underifnet_serialize_all()inix_detach(), if_ix.c:686-689) issues onlycallout_stop(&txr->tx_gc_timer)(if_ix.c:1767). DragonFlycallout_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_timeriscallout_init_mp, reset viacallout_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 viaifnet_serialize_all), so it takesgoto 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 fortx_gc_timerβ releases resources (if_ix.c:698-735), andix_free_rings()(721) freestx_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_flagsfrom freed memory (UAF read), lwkt_serialize_try(&txr->tx_serialize)β atomic RMW write into freed slab memory,callout_reset()on the freed embeddedstruct 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
- Source walk (all lines cited above) β no
callout_terminatefortx_gc_timeranywhere in the driver (rg confirms onlycallout_stopat 1767). - 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. fix.diffadds the missingcallout_terminate()loop inix_detach()(guarded bysc->tx_rings != NULLfor the attach-failure path). Compile-validated: patchedif_ix.kobuilds with-Werror, clean (build_patched.log); guest/usr/srcrestored to pristine afterwards.
Recommended fix
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_testableCompile-validated only; runtime race validation needs an attached NIC.
build_baseline.log / build_patched.log (-Werror clean with fix).
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.
No comments yet.