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

ath_tx_start() discards ath_tx_normal_setup() failure ('done: return 0') β€” leaked ath_buf + 10-descriptor slab + dmamap per failure, phantom TIM set, caller told success

Summary

ath_tx_start() stores the setup result in r, jumps to done: on failure, but done: unconditionally returns 0. Every setup failure path (bus_dmamap_load NOWAIT ENOMEM, defrag/linearize failure, or a type-3 injected frame on the legacy raw path) has already freed the mbuf inside ath_tx_dmasetup and leaves the ath_buf obtained by the caller un-queued and un-returned. The caller believes TX succeeded, sets the watchdog timer, and calls ath_tx_update_tim marking a power-save TIM with nothing queued. Each occurrence permanently removes one ath_buf plus its private 10-descriptor slab and dmamap from the fixed pools; sustained failures under attacker-assistable memory pressure drain sc_txbuf and wedge TX (persistent DoS, needs reset). VERIFIED: source-level control-flow machine-check (verify_df3637.py 8/8 PASS incl. 'done: block returns literal 0'). No memory corruption. Guest hardware-absent: not_reproduced at runtime, source-certain. One-line fix: return r.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3637 Β· 9 files
FileTypeDescriptionSize
README.md β€” 2.9 KB ↓ raw
VERDICT.md β€” 3.3 KB ↓ raw
verify_df3637.py β€” 2.5 KB view raw
run.sh β€” 111 B view raw
run.log β€” 619 B view raw
env.txt β€” 708 B view raw
fix.diff β€” 296 B view raw
verdict.json β€” 3.2 KB view raw
manifest.json β€” 890 B view raw

DF-3637 β€” ath_tx_start() swallows ath_tx_normal_setup() failure: leaked ath_buf + descriptor slab, phantom TIM

What

sys/dev/netif/ath/ath/if_ath_tx.c:2081-2082 β€” ath_tx_start() stores the result of ath_tx_normal_setup() in r, jumps to done: on failure, but done: unconditionally return 0;. The failure is never propagated.

Consequence (source-level chain)

  1. ath_tx_normal_setup() (if_ath_tx.c:1557) calls ath_tx_dmasetup() (if_ath_tx.c:294). On bus_dmamap_load* failure (ENOMEM with BUS_DMA_NOWAIT, or linearize/defrag failure under memory pressure) the mbuf is freed inside ath_tx_dmasetup() (if_ath_tx.c:320/338/349) and a non-zero error is returned. The "bogus frame type" branch (if_ath_tx.c:1706-1712) also frees the mbuf and returns EIO (reachable with an injected type-3 frame via the params==NULL raw path, which needs privileged BPF access).
  2. ath_tx_start() sees r != 0, jumps to done:, returns 0.
  3. The bf obtained by the caller (e.g. ath_raw_xmit() if_ath_tx.c:2407 ath_getbuf(); ath_transmit() in if_ath.c) is now: not queued anywhere, not returned to sc->sc_txbuf, mbuf already freed, bf_m never set. The caller believes TX succeeded: ath_raw_xmit() proceeds to set sc->sc_wd_timer = 5 and β€” worse β€” calls ath_tx_update_tim(sc, ni, 1) (if_ath_tx.c:2444), marking the power-save TIM for the node even though nothing is queued.
  4. Each occurrence permanently leaks one ath_buf plus its private block of ATH_MAX_SCATTER(10) DMA descriptors (allocated 1:10 in ath_desc_alloc(), if_ath.c:3813-3816) and its bus_dmamap. ATH_TXBUF is a fixed pool (~500); exhaustion wedges TX on the interface (persistent local/remote-assisted DoS under memory pressure).

Reachability

Requires an ath(4) interface and either (a) bus_dmamap_load_mbuf* NOWAIT failure β€” attacker-assistable by driving the system into mbuf/DMA-map pressure while aggregated TX retries clone buffers β€” or (b) an injected 802.11 frame with FC type bits 3 via the legacy raw path (needs write access to /dev/bpf on a monitor vap, i.e. root). Guest has no ath hardware β€” runtime reproduction is impossible here; this pack carries the source-level proof instead.

How to verify (what run.sh does)

verify_df3637.py walks the exact cited lines in the read-only sys/ tree and asserts: (1) r = ath_tx_normal_setup(...) is followed by if (r != 0) goto done;; (2) the done: label returns literal 0; (3) every error return of ath_tx_dmasetup frees the mbuf first. Exit 0 == defect confirmed at source level.

Files

  • verify_df3637.py β€” source-level verification script (the "PoC")
  • run.sh β€” runs it, tees to run.log
  • run.log β€” decisive output
  • env.txt β€” audit-host + guest environment
  • VERDICT.md β€” full narrative
  • fix.diff β€” the one-line fix, never applied to sys/
  • verdict.json / manifest.json
VERDICT.md
↓ download raw

DF-3637 β€” ath_tx_start() swallows ath_tx_normal_setup() failure

REPRODUCED: No β€” not at runtime. The DragonFly audit guest (6.5-DEVELOPMENT X86_64_GENERIC, vtnet0/lo0 only) has no Atheros PCI/USB hardware; if_ath is compiled in but no device attaches (dev.ath sysctl tree absent, no wlan interfaces). The TX software-queue engine cannot be exercised on this machine, so the honest runtime verdict is not_reproduced with hardware-absent recorded in env.txt.

CONFIRMED: Yes β€” at source level, 8/8 assertions (run.log):

  1. ath_tx_start() (sys/dev/netif/ath/ath/if_ath_tx.c:1866) stores the result of ath_tx_normal_setup() in r at if_ath_tx.c:2021.
  2. On failure (r != 0) it jumps to done: (if_ath_tx.c:2023-2024) which unconditionally return 0; (if_ath_tx.c:2081-2082) β€” the error is discarded.
  3. Every failure mode inside ath_tx_normal_setup()/ath_tx_dmasetup() that can produce r != 0 has already freed the caller's mbuf (if_ath_tx.c:320, 338, 349, 356, 1599, 1711) β€” so the mbuf is not leaked, but the ath_buf obtained by the caller IS: * ath_raw_xmit() grabs bf = ath_getbuf(sc, ATH_BUFTYPE_MGMT) at if_ath_tx.c:2407 and only returns it via goto bad2 when it sees a non-zero return (if_ath_tx.c:2422-2425). With the swallowed error it takes the success path: sets sc->sc_wd_timer = 5, calls ath_tx_update_tim(sc, ni, 1) (if_ath_tx.c:2444) β€” asserting a TIM for a node with nothing queued β€” and returns 0. * ath_transmit() (if_ath.c) has the same success/failure contract.
  4. Each leaked ath_buf permanently removes one buffer plus its private 10-descriptor slab (ATH_MAX_SCATTER=10 per buffer, ath_descdma_setup(..., ath_txbuf, ATH_MAX_SCATTER) at if_ath.c:3813-3816) and its bus_dmamap from the fixed pools. Sustained failure (bus_dmamap_load NOWAIT ENOMEM under memory pressure β€” attacker-assistable by mbuf/cluster pressure against an associated peer β€” or a type-3 injected frame on the privileged legacy raw path, if_ath_tx.c:1706-1712) drains sc->sc_txbuf until _ath_getbuf_locked returns NULL (ast_tx_nobuf) and the interface can no longer transmit: a persistent wedge requiring reset.

EXPLOIT CHAIN: none (no memory corruption; resource-exhaustion DoS class). Local or remote-assisted (association + memory pressure) on hosts with ath(4) hardware.

WHY NOT A FALSE POSITIVE: the control flow is machine-verified (verify_df3637.py, 8/8 PASS); FreeBSD history keeps the same done: return 0; β€” this is an upstream-inherited defect, not a DragonFly-only artifact.

FIX VALIDATION: not_testable β€” requires ath(4) hardware to observe the pool drain. fix.diff is the trivially-correct one-liner (return r;); both paths reaching done: carry the intended value (r==0 on success).

Files: verify_df3637.py, run.sh, run.log, env.txt, fix.diff, verdict.json, manifest.json, README.md

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 changes 'return 0;' to 'return r;' at the done: label - trivially correct since r==0 on the success fall-through; cannot be runtime-validated on the hardware-less guest.

findings/poc/DF-3637/fix.diff; control-flow proof in run.log lines 3-4.
↓ fix.diffper-fix-DF-3637

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-3637/run.log (8/8 PASS incl. 'done: block returns literal 0 (r discarded)' citing if_ath_tx.c:2081-2084 and the freed-mbuf error paths of ath_tx_dmasetup at :320/338/349); VERDICT.md narrative; env.txt hardware-absence record for the guest.

PoC changes

No seeded PoC existed; authored a source-level verification script (verify_df3637.py) as the reproducible artifact because the defect is hardware-gated (no ath NIC on guest).

Verified recommended fix

ath_tx_start(): return r instead of 0 at the done: label (if_ath_tx.c:2082).

Verdict

Source-level confirmed (8/8 machine-checked assertions, run.log): ath_tx_start() at sys/dev/netif/ath/ath/if_ath_tx.c:2081-2082 discards the error from ath_tx_normal_setup() (r set at :2021, 'if (r != 0) goto done;' at :2023-2024, 'done: return 0;'), so every setup failure (busdmamap_load NOWAIT ENOMEM, linearize failure, injected type-3 frame) permanently leaks one ath_buf plus its 10-descriptor slab and dmamap, while the caller (ath_raw_xmit :2422 / ath_transmit) believes TX succeeded and even sets the node TIM (:2444). Fixed ATH_TXBUF pool drains to ast_tx_nobuf and the interface wedges - a persistent DoS on ath(4) hosts under memory pressure. Runtime reproduction was impossible: the audit guest (DragonFly 6.5-DEVELOPMENT X86_64_GENERIC) has no Atheros hardware (dev.ath sysctl tree absent, only vtnet0/lo0), so honest status is not_reproduced/hardware-absent; impact 'none' reflects the run, not the defect's ceiling (DoS).