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)
PoC verification
Evidence pack
findings/poc/DF-3443 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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).
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):
-
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:1675trace: chunk_mark_free β slab_cleanup β slotimer_callback β softclock_handler (panic.txt). -
Wedge manifestation (run 5): the PoC process was photographed in
ps ax -o pid,stat,wchan,commandas1211 D0 aslize ./df3443_poc tap0β state D0 (uninterruptible), wait channelaslize= the blocked path oflwkt_serialize_adaptive_enter()(kern/lwkt_serialize.c:240+): PRIQ_LOCK is sleeping on the serializer embedded in freed/reused memory while holdingpf_token; the guest then degraded to a full lockup (ssh banner timeout, vm.sh status down). (serial_wedge_run.log; note: the finalFatal trap 9in that log is my inspector module poking an address after the system was already wedged β a measurement artifact, not the finding.) -
Memory photographs (runs 4/6, fw3443.ko inspector): kernel pointers recovered from the
DIOCADDALTQcopy-back (pif=β¦48c0, pif_ifq=β¦9750, ifnet=β¦9580, altq_subq=β¦fb00); afterSIOCIFDESTROYthe 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). -
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 β
ifunitfinds 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
- sys/net/altq/altq_priq.c:114 (capture)
- sys/net/altq/altq_priq.c:161-184 (priq_add_queue deref)
- sys/net/altq/altq_priq.c:197-214 (priq_remove_queue deref)
- sys/net/pf/pf_ioctl.c:2031-2045 (string-match altq_disc handoff, no liveness)
- sys/net/if.c:1089 (kfree of if_snd.altq_subq at detach)
- sys/kern/kern_slaballoc.c:1675 (freelist assertion the corruption trips)
- sys/kern/lwkt_serialize.c:240 (adaptive enter sleep "aslize")
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:1675trace: 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 tap0D0 = uninterruptible kernel sleep; wchanaslize= 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
fixedfix.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)']
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.
No comments yet.