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

altq_priq: dangling pif_ifq use-after-free - interface destroyed mid-pf-transaction leaves PRIQ_LOCK operating on freed ifnet/altq_subq (slab freelist corruption panic / permanent pf_token wedge)

Summary

priq_add_altq() stores pif->pif_ifq = &ifp->if_snd with no ifnet lifetime protection. A pf altq transaction spans multiple ioctls and the queue-spec DIOCADDALTQ re-finds the discipline by ifname string match only (no liveness check); if_detach() frees ifp->if_snd.altq_subq and the ifnet itself without notifying pf/altq. Destroying the interface between the two ioctls makes priq_add_queue() read ifq->altq_subq out of freed M_IFNET memory and run lwkt_serialize_adaptive_enter/exit atomic RMW writes at offsets 0..16 of the freed 256-class M_DEVBUF chunk - exactly where kfree() parked the in-band slab free-list link. OBSERVED all three outcomes on stock INVARIANTS guest: (a) panic 'assertion ... failed in chunk_mark_free' when the slab timer walks the poisoned free list; (b) permanent uninterruptible wedge (D0/wchan=aslize) holding pf_token, guest then fully locked up; (c) silent runs with the lock entered+released on freed/reused memory (inspector KLD A/B dumps photograph the freelist link and mid-ioctl chunk reuse). Trigger root-gated, fully deterministic, no race needed. Siblings altq_cbq.c/altq_hfsc.c/altq_fairq.c share the pattern. Fix validated (revalidate ifunit under ifnet_lock - the exact nesting priq_getqstats already uses - mirrored in remove_queue; rebuilt kernel): clean ENODEV, freed chunk byte-identical, live-interface control unaffected.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3443 Β· 19 files
FileTypeDescriptionSize
README.md β€” 3.9 KB ↓ raw
VERDICT.md β€” 4.8 KB ↓ raw
RUNLOG.md β€” 2.6 KB ↓ raw
df3443_poc.c β€” 9.2 KB view raw
fw3443.c β€” 2.5 KB view raw
Makefile β€” 52 B ↓ download
setup_guest.sh β€” 1.0 KB view raw
guest_flow.sh β€” 1.2 KB view raw
build.sh β€” 231 B view raw
run.sh β€” 214 B view raw
build.log β€” 74 B view raw
run.log β€” 3.6 KB view raw
run.2.log β€” 1.0 KB view raw
panic.txt β€” 1.6 KB view raw
serial_wedge_run.log β€” 2.2 KB view raw
env.txt β€” 267 B view raw
fix.diff β€” 1.5 KB view raw
fix_build.log β€” 19.5 KB view raw
fix_run.log β€” 1.6 KB view raw

DF-3443 β€” altq_priq dangling pif_ifq use-after-free (interface destroyed mid-pf-transaction)

File: sys/net/altq/altq_priq.c Lines: 114 (pointer captured), 177–179 & 207–209 (unvalidated dereference), plus 348 (priq_class_create uses stale pif_ifq under ALTQ_RED). Class: CWE-416 use-after-free (kernel heap, RMW write). Severity: Low (trigger requires root: /dev/pf 0600 + FWRITE) β€” but the primitive itself is deterministic kernel memory corruption. Bucket: memcorrupt.

Root cause

priq_add_altq() (altq_priq.c:114) stores pif->pif_ifq = &ifp->if_snd with no reference on the ifnet. A pf altq transaction spans several ioctls (DIOCXBEGIN(PF_RULESET_ALTQ) β†’ DIOCADDALTQ(interface) β†’ DIOCADDALTQ(queue) β†’ DIOCXCOMMIT). The queue-spec ioctl finds the discipline by string match on ifname in the inactive list (pf_ioctl.c:2038–2044) with no liveness check. Nothing hooks ifnet destruction back into pf/altq (if_detach frees if_snd.altq_subq at if.c:1089 and the ifnet itself, without purging pf's altq entries).

If the interface is destroyed between the interface-spec and queue-spec ioctls, priq_add_queue() does:

ifq = pif->pif_ifq;              /* &freed_ifnet->if_snd            */
PRIQ_LOCK(ifq);                  /* read freed ifnet -> freed array
                                     -> RMW lwkt_serializer in freed
                                     M_DEVBUF chunk (offsets 0..16) */

i.e. a read through freed M_IFNET memory followed by atomic RMW writes at offset 0–16 of a freed M_DEVBUF chunk β€” exactly where the DF slab allocator keeps the in-band free-list link (c_Next) of the freed chunk.

Reproduce (guest, root)

sh findings/poc/DF-3443/setup_guest.sh          # build PoC + ALTQ pf.ko + fw3443.ko
cd /root && ./df3443_poc tap0                   # full trigger (destroys tap0)
cd /root && ./df3443_poc tap0 --nodestroy       # control (live interface)

Expected observations (across runs, all seen): 1. panic assertion "(((intptr_t)chunk ^ (intptr_t)z) & ZoneMask) == 0" failed in chunk_mark_free (kern_slaballoc.c:1675) β€” the serializer RMW destroyed the freed chunk's c_Next; the slab-maintenance timer later walks the poisoned free list into a bogus chunk. 2. wedge β€” the ioctl thread blocks forever in lwkt_serialize_adaptive_enter's sleep (wchan aslize, state D0) on a garbage interlock in freed memory, holding pf_token; the system progressively locks up (ssh stops answering). 3. silent β€” the freed interlock happens to read 0/unlocked; the lock is entered+released on freed memory with no immediate symptom (corruption may still be planted). 4. Control (--nodestroy) never fails.

The inspector module fw3443.ko (hw.fw3443.addr / hw.fw3443.dump) photographs the freed chunks before/after the trigger; the PoC prints the pif/pif_ifq/ifnet/altq_subq chain recovered from the (legitimate) DIOCADDALTQ copy-back.

Why the rebuilt pf.ko

The shipped pf.ko is compiled without the ALTQ ioctl glue (module builds do not define ALTQ; #ifdef ALTQ at pf_ioctl.c:90 gates the handlers), so DIOCADDALTQ returns ENODEV. The kernel itself already contains the priq scheduler (options ALTQ_PRIQ), so rebuilding only pf.ko against KERNBUILDDIR=/usr/obj/usr/src/sys/X86_64_GENERIC enables the ALTQ ioctls with stock kernel code. No kernel behavior is otherwise modified.

Fix

fix.diff: revalidate the interface (ifunit(a->ifname) under ifnet_lock, compare pif->pif_ifq == &ifp->if_snd) in priq_add_queue()/priq_remove_queue() β€” the same lock nesting priq_getqstats() already uses. Validated on an in-guest nativekernel build: trigger returns ENODEV (no freed-memory access), control path still works. The same pattern exists in altq_cbq.c, altq_hfsc.c, altq_fairq.c and should be fixed there too (out of this file's scope).

VERDICT.md
↓ download raw

DF-3443 VERDICT β€” altq_priq.c dangling pif_ifq UAF

Status: reproduced (impact: dos β€” wedge and/or panic; underlying primitive: deterministic UAF write into the kernel slab free list). Confidence: certain.

Narrative

priq_add_altq() captures pif->pif_ifq = &ifp->if_snd (altq_priq.c:114) without any ifnet lifetime protection. pf's altq transaction spans multiple ioctls and locates the discipline by ifname string-match (pf_ioctl.c:2038–2044); if_detach() frees ifp->if_snd.altq_subq (if.c:1089) and the ifnet itself without notifying pf. Destroying the interface between the interface-spec and queue-spec DIOCADDALTQ ioctls therefore leaves priq_add_queue() (altq_priq.c: 177–179) executing PRIQ_LOCK(pif->pif_ifq) β€” a read through the freed ifnet (ifq->altq_subq at freed_ifnet+464+64) followed by lwkt_serialize_adaptive_ enter/exit atomic RMW writes at offsets 0..16 of the freed altq_subq chunk (192-byte object, 256-class M_DEVBUF slab) β€” precisely where kfree() parked the in-band free-list link (c_Next).

Runtime proof chain (guest DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC, INVARIANTS):

  1. Panic manifestation (run 1, PoC v2, stock kernel + ALTQ-enabled pf.ko): minutes after the trigger the slab-maintenance timer walked the poisoned free list: panic: assertion "(((intptr_t)chunk ^ (intptr_t)z) & ZoneMask) == 0" failed in chunk_mark_free at kern_slaballoc.c:1675 trace: chunk_mark_free ← slab_cleanup ← slotimer_callback ← softclock_handler (panic.txt).

  2. Wedge manifestation (run 5): the PoC process was photographed in ps ax -o pid,stat,wchan,command as 1211 D0 aslize ./df3443_poc tap0 β€” state D0 (uninterruptible), wait channel aslize = the blocked path of lwkt_serialize_adaptive_enter() (kern/lwkt_serialize.c:240+): PRIQ_LOCK is sleeping on the serializer embedded in freed/reused memory while holding pf_token; the guest then degraded to a full lockup (ssh banner timeout, vm.sh status down). (serial_wedge_run.log; note: the final Fatal trap 9 in that log is my inspector module poking an address after the system was already wedged β€” a measurement artifact, not the finding.)

  3. Memory photographs (runs 4/6, fw3443.ko inspector): kernel pointers recovered from the DIOCADDALTQ copy-back (pif=…48c0, pif_ifq=…9750, ifnet=…9580, altq_subq=…fb00); after SIOCIFDESTROY the freed 256-class chunk shows the slab freelist pointer in word0 (fffff8008f8afc00) and gets partially reused mid-ioctl (words 2–7 become a live "tap…"/pif-containing object between the A and B dumps), while the kernel-visible *(pif_ifq->altq_subq) still resolves into the freed region. Two of five full-trigger runs produced catastrophic failure, three returned silently (freed interlock read 0).

  4. Control (--nodestroy): identical sequence against a live tap0 β€” clean, pf responsive, no panic (proves the destroy is the differentiator).

Exploit chain

Not applicable as privilege escalation: every step (pf ioctls, interface destroy) requires root. The corruption primitive itself (freelist-link overwrite at a freed 256-class chunk + last_td pointer write) would be a classic slab-poisoning bed for an unprivileged path, but this path is root-gated; impact is therefore kernel-panic / system-wedge DoS plus latent heap corruption.

Fix validation

fix.diff adds ifnet revalidation under ifnet_lock() (the exact nesting priq_getqstats() already uses) to priq_add_queue()/priq_remove_queue(). Built with make -j6 nativekernel in-guest (fix_build.log), rebooted, re-ran:

  • baseline (unpatched): wedge/panic as above;
  • patched: the queue-spec ioctl after destroy returns a clean error (ENODEV β€” ifunit finds no tap0), no freed-memory access, guest stays healthy; control run (--nodestroy) still configures the queue normally (no regression). fix_run.log.

Siblings with the same pattern (should receive the same fix): altq_cbq.c (cbq_add_queue), altq_hfsc.c, altq_fairq.c.

kernel_refs

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.

DF-3443 β€” runtime evidence notes (chronological)

Run 1 (first successful trigger, PoC v2)

  • Fresh boot; pf.ko rebuilt with ALTQ (stock module lacks ALTQ ioctl glue; kernel itself has priq_* compiled in via options ALTQ_PRIQ).
  • PoC v2 sequence: DIOCXBEGIN(ALTQ) -> DIOCADDALTQ(interface-spec, tap0) -> SIOCIFDESTROY(tap0) -> DIOCADDALTQ(queue-spec).
  • Result: kernel panic within ~90s: panic: assertion "(((intptr_t)chunk ^ (intptr_t)z) & ZoneMask) == 0" failed in chunk_mark_free at /usr/src/sys/kern/kern_slaballoc.c:1675 trace: chunk_mark_free <- slab_cleanup <- slotimer_callback <- softclock_handler. Mechanism: PRIQ_LOCK/UNLOCK (lwkt_serializer interlock RMW + last_td write) executed at offset 0 of the kfree'd altq_subq chunk clobbers the in-band free-list link (c_Next); the slab-maintenance timer later walks the corrupted free list into a bogus chunk -> assertion panic.
  • Captured in panic.txt (serial console).

Run 2 (control, --nodestroy)

  • Same environment; queue-spec issued against a LIVE tap0.
  • Result: clean, no panic, pf responsive. (control PASS)

Run 3 (plain, no churn)

  • Silent: ioctl returned (freed interlock read 0/unlocked); no panic within the observation window. Corruption may still have occurred (balanced RMW on freed chunk) but was not observed.

Run 4 (inspector module fw3443.ko loaded)

  • Recovered kernel pointers from ioctl copy-back (altq_disc): pif=0xfffff8008bb648c0 pif_ifq=0xfffff8011894a450 ifnet=0xfffff8011894a280 altq_subq=0xfffff8008f890600
  • Memory dump of live subq array (chunk base = ifsq_lock): 0000000000000000 fffffffffffffffe 0000000000000000 <ifq> <ifnet> ...
  • After SIOCIFDESTROY: freed ifnet chunk shows partial REUSE (words 3-6 = pointers + "tap" string) within microseconds.

Run 5 (WEDGE - photographed live)

  • After destroy, queue-spec ioctl issued; PoC process observed in: ps ax -o pid,stat,wchan,command -> 1211 D0 aslize ./df3443_poc tap0 D0 = uninterruptible kernel sleep; wchan aslize = the blocked path of lwkt_serialize_adaptive_enter() (sys/kern/lwkt_serialize.c:240+). => PRIQ_LOCK is provably sleeping on the serializer embedded in the freed/reused ifnet's subq array, holding pf_token.
  • Guest then progressively locked up: ssh banner timeout, vm.sh status down.
  • Captured in serial_wedge_run.log.

Conclusion

Two distinct manifestations, both from the same dangling pif_ifq: (a) silent/blocked RMW on freed memory -> slab freelist corruption -> allocator assertion panic (run 1); (b) adaptive-serializer sleep on garbage interlock in freed memory, uninterruptible, holding pf_token -> progressive system-wide wedge (run 5).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to in-guest /usr/src, make -j6 nativekernel (NK-DONE-RC=0, fix_build.log) + installkernel + reboot into #1. Re-ran exact PoC: queue-spec DIOCADDALTQ now returns ENODEV ('Operation not supported by device'), inspector A/B dumps of the freed chunk byte-identical (freelist link untouched - no serializer RMW on freed memory), guest healthy after churn + 48 tap cycles; --nodestroy control still configures the queue normally (no regression). Baseline (unpatched #0) had panic + D0/aslize wedge manifestations.

['fix.diff', 'fix_build.log', 'fix_run.log', 'run.log (FIX RUNS section)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Sep 12 20:32:25 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root only: open /dev/pf -> DIOCXBEGIN(PF_RULESET_ALTQ) -> DIOCADDALTQ(interface-spec, tapN) [kernel captures pif_ifq] -> SIOCIFDESTROY(tapN) [ifnet + altq_subq kfree'd, pf entry keeps dangling pointer] -> DIOCADDALTQ(queue-spec) [priq_add_queue PRIQ_LOCKs through freed memory -> serializer RMW corrupts freed chunk offsets 0..16 incl. slab freelist link / sleeps forever on garbage interlock]. No unpriv->root route (all steps root-gated); primitive is a freed-chunk freelist-link overwrite (classic slab poisoning bed) but only reachable by an already-privileged user.

Evidence (decisive lines)

['panic.txt: chunk_mark_free assertion panic + D0/aslize ps capture', 'run.log: A/B freed-chunk photographs (freelist link 0xfffff8008f8afc00 in word0; words 2-7 reused mid-ioctl), control and fix runs', 'serial_wedge_run.log: console trail of the full lockup (final trap 9 is the inspector module probing an already-wedged box - artifact, noted)', "fix_run.log: patched kernel -> 'DIOCADDALTQ(queue-spec): Operation not supported by device' (ENODEV), freed chunk untouched, guest healthy", 'fix_build.log: make -j6 nativekernel NK-DONE-RC=0']

PoC changes

PoC authored fresh (no seed). Iterations: v1 used a standalone DIOCBEGINALTQS which is not a top-level ioctl case (ENODEV) - switched to DIOCXBEGIN(PF_RULESET_ALTQ) per pfctl's real flow; stock pf.ko lacks the ALTQ ioctl glue (module builds never define ALTQ) so pf.ko was rebuilt against KERNBUILDDIR (kernel already contains priq_* symbols); added --nodestroy control, route-socket spray mode, tap churn amplifier, and the fw3443.ko sysctl inspector (hw.fw3443.addr/dump) to photograph the freed chunks; struct geometry taken from the inspector at load.

Verified recommended fix

In priq_add_queue()/priq_remove_queue(), take ifnet_lock() and revalidate: ifp=ifunit(a->ifname); if (ifp==NULL || pif->pif_ifq!=&ifp->if_snd) { ifnet_unlock(); return ENODEV; } before dereferencing pif->pif_ifq (mirrors priq_getqstats lock nesting);

Verdict

Reproduced on the stock INVARIANTS guest (plus an ALTQ-enabled rebuild of the pf.ko module; the priq scheduler itself is in-kernel via 'options ALTQ_PRIQ'). priq_add_altq() stores pif->pif_ifq=&ifp->if_snd (altq_priq.c:114) with no ifnet lifetime protection; pf's multi-ioctl altq transaction re-finds the discipline by ifname string match only (pf_ioctl.c:2038-2044), and if_detach() frees if_snd.altq_subq (if.c:1089) and the ifnet without notifying pf. Destroying the interface between the interface-spec and queue-spec DIOCADDALTQ ioctls makes priq_add_queue() (altq_priq.c:177-179) read the altq_subq pointer out of freed M_IFNET memory and run lwkt_serializer atomic RMW writes at offsets 0..16 of the freed 256-class M_DEVBUF chunk - exactly where kfree parked the in-band slab freelist link. Manifestations: (a) panic 'assertion (((intptr_t)chunk ^ (intptr_t)z) & ZoneMask) == 0 failed in chunk_mark_free' (kern_slaballoc.c:1675) when the slab timer walks the poisoned free list; (b) permanent uninterruptible wedge: PoC photographed in state D0, wchan 'aslize' (lwkt_serialize_adaptive_enter blocked path, kern/lwkt_serialize.c:240+), holding pf_token, guest then fully locked up; (c) silent runs where the freed interlock read 0 and the lock was entered+released on freed/reused memory (A/B memory photographs via the fw3443.ko inspector show the freed chunk's freelist link and mid-ioctl reuse). Control run (--nodestroy) is always clean. Trigger requires root (/dev/pf 0600 + FWRITE), so no privilege boundary is crossed; impact is kernel memory corruption manifesting as panic/wedge DoS. Fix (revalidate ifunit(a->ifname) under ifnet_lock in priq_add_queue/priq_remove_queue, the nesting priq_getqstats already uses) validated on an in-guest nativekernel build: trigger now returns clean ENODEV with the freed chunk byte-identical before/after the ioctl, and the live-interface control path still configures queues normally. Same pattern exists in altq_cbq.c/altq_hfsc.c/altq_fairq.c and should get the same fix.