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

lagg port-detach handler registered on never-fired ifnet_departure_event: persistent kernel UAF after member interface destroy

Summary

lagg_modevent registers lagg_port_ifdetach on the string key 'ifnet_departure_event'. That key is never EVENTHANDLER_INVOKE'd anywhere in the DragonFly kernel - if_detach() fires 'ifnet_detach_event', which is what every sibling subsystem registers on (bridge, pf, vlan, tun). The FreeBSD heritage event name survived the port; the handler compiles into an orphan list and never runs, making the only automatic port-removal path dead code. Destroying a member interface (e.g. ifconfig tap0 destroy frees the whole tap_softc with the ifnet embedded) leaves lp->lp_ifp dangling into freed heap permanently - not a race. VERIFIED on guest: after tap0 destroy, 'ifconfig lagg0' STILL lists 'laggport: tap0'; unpriv SIOCGLAGG dump still returns port[1]='tap0' read from freed tap_softc; after creating tap2-4 the unpriv dump returns port[1]='tap2' (dangling pointer into the reused chunk - kernel heap disclosure to unprivileged user); 'ifconfig tap2 destroy + laggproto lacp' -> Fatal trap 12 fault va 0x3c0 'Stopped at tapifioctl+0x158', guest down. Every later lagg operation touches freed memory: unpriv TX drives netmsg dispatch into the freed ifnet's ifq (UAF write); admin ops fault through the dangling port. uid0 chain not developed (would need grooming + pivot via lagg_port_destroy's unconditional writes); demonstrated ceiling = persistent unpriv heap disclosure + reliable panic. Fix validated (register on ifnet_detach_event): two-line fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3344 Β· 12 files
FileTypeDescriptionSize
README.md β€” 4.0 KB ↓ raw
VERDICT.md β€” 5.4 KB ↓ raw
laggleak.c β€” 1.6 KB view raw
udptx.c β€” 1.7 KB view raw
build.sh β€” 134 B view raw
run.sh β€” 209 B view raw
run.log β€” 3.9 KB view raw
panic.txt β€” 2.2 KB view raw
fix.diff β€” 539 B view raw
fix_validation.log β€” 1009 B view raw
manifest.json β€” 1.3 KB view raw
verdict.json β€” 5.5 KB view raw

DF-3344 β€” lagg port-detach handler registered on a never-fired event β†’ persistent kernel UAF

Summary

sys/net/lagg/if_lagg.c:218-220 registers lagg_port_ifdetach on ifnet_departure_event. That event does not exist anywhere else in the DragonFly kernel: if_detach() (sys/net/if.c:958) fires ifnet_detach_event (declared sys/net/if_var.h:724-725), and every other consumer β€” bridge (sys/net/bridge/if_bridge.c:609), pf (sys/net/pf/pf_if.c:118), vlan (sys/net/vlan/if_vlan.c:377), tun (sys/net/tun/if_tun.c:330) β€” registers on the correct name. The lagg handler therefore NEVER runs (the FreeBSD heritage name survived the port; DF renamed the event).

Consequence: destroying a member interface (ifconfig tap0 destroy while laggport tap0) does not remove the lagg port. lagg_port_destroy() is the only code that detaches a port, and nothing calls it on ifnet departure. The lagg keeps a struct lagg_port whose lp->lp_ifp points into the freed driver softc (for tap: kfree(sc, M_TAP) at sys/net/tap/if_tap.c:495 frees the whole tap_softc with the ifnet embedded).

Every later lagg operation touches freed memory:

  • unprivileged SIOCGLAGG (ifconfig lagg0 as any user): lagg_ioctl β†’ lagg_port2req() β†’ strlcpy(rp_portname, lp->lp_ifp->if_xname) reads the freed heap and copies it to userspace (kernel heap disclosure / use-after-free read; when the chunk is reused the "port name" becomes whatever object now owns the memory β€” demonstrated below).
  • unprivileged TX through the lagg: lagg_start β†’ sc_select_tx_port β†’ lagg_link_active() reads lp->lp_ifp->if_link_state/if_flags from freed memory, and on an "active" stale port dispatches a netmsg whose target ifp is freed (lagg_start_dispatch β†’ ifq_dispatch() β€” UAF write into the freed ifnet's tx queue).
  • privileged SIOCSLAGG laggproto lacp: lagg_lacp_attach β†’ lacp_port_create(dangling lp) β†’ if_addmulti(freed ifp) β†’ calls the freed ifnet's stale if_ioctl β†’ kernel panic (demonstrated: Fatal trap 12, Stopped at tapifioctl+0x158).
  • privileged ifconfig lagg0 destroy: lagg_port_destroy() writes if_type/if_ioctl/if_output/if_lagg restores into freed memory (UAF write).

Files

  • laggleak.c β€” unprivileged SIOCGLAGG port-table dump (hex of every port name)
  • udptx.c β€” unprivileged UDP flood via the lagg (tx-path trigger)
  • build.sh / run.sh β€” exact commands
  • run.log β€” full session capture (baseline β†’ destroy β†’ leak β†’ aliasing β†’ panic)
  • panic.txt β€” serial-console panic capture
  • fix.diff β€” the two-line event-name fix
  • VERDICT.md, manifest.json, verdict.json

Build (in guest)

cc -O -o /tmp/laggleak laggleak.c -I/usr/src/sys
cc -O -o /tmp/udptx   udptx.c

Run (expected: leak + panic)

# root: setup
ifconfig tap0 create && ifconfig tap1 create && ifconfig lagg0 create
ifconfig lagg0 laggproto failover laggport tap0 laggport tap1
ifconfig lagg0 10.66.7.1 netmask 255.255.255.0 up
ifconfig tap0 up && ifconfig tap1 up

# unprivileged: baseline β€” ports tap0, tap1
/tmp/laggleak lagg0

# root: destroy a member β€” NO cleanup runs (the bug)
ifconfig tap0 destroy

# unprivileged: port[1] STILL 'tap0' β€” read from freed tap_softc
/tmp/laggleak lagg0

# root: force slab reuse
ifconfig tap2 create; ifconfig tap3 create; ifconfig tap4 create

# unprivileged: port[1] now reads 'tap2' β€” dangling pointer into reused heap
/tmp/laggleak lagg0

# unprivileged tx through the dangling primary (survives here: stale flags
# read inactive, failover falls through to the live port)
/tmp/udptx 10.66.7.2 3000

# root: free the chunk again, then switch protocol β†’ PANIC
ifconfig tap2 destroy
ifconfig lagg0 laggproto lacp

Success criterion (met): stale member visible to an unprivileged user after destroy; member name mutates with heap reuse; Fatal trap 12 page fault in tapifioctl reached via the dangling port; guest down at DDB.

VERDICT.md
↓ download raw

DF-3344 β€” VERDICT

Status: reproduced (panic via privileged trigger; unprivileged UAF-read/leak demonstrated; unprivileged tx survived by luck of stale flags). Impact: panic (with a demonstrated unprivileged kernel-heap disclosure primitive and a structural UAF-write surface on the tx path).

Guest

DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026, X86_64_GENERIC (options INVARIANTS, pseudo-device lagg compiled in), stock kernel, QEMU/KVM guest via dfbsd-qemu/vm.sh.

Root cause (path:line)

What was demonstrated on the guest (run.log, panic.txt)

  1. Setup (root): lagg0 failover with laggport tap0 laggport tap1, tap0 = MASTER/primary, IP 10.66.7.1/24.
  2. Baseline (uid=1001 maxx): SIOCGLAGG via /tmp/laggleak β€” ports tap0, tap1, clean hex names.
  3. Root destroys tap0: ifconfig lagg0 still lists laggport: tap0 flags=1<MASTER> β€” no cleanup ran.
  4. Unprivileged UAF read: /tmp/laggleak as maxx still returns port[1] named tap0 β€” those bytes now live in the freed tap_softc (lagg_port2req β†’ strlcpy(rp_portname, lp->lp_ifp->if_xname), if_lagg.c:951). Any user can read this; 16 bytes of freed-heap content are copied out per port entry.
  5. Aliasing proof: root creates tap2/3/4 β†’ objcache hands tap2 the freed chunk β†’ the unprivileged dump now reports port[1] as 'tap2'. The dangling lp->lp_ifp provably aliases whatever object now owns that heap chunk β€” the leak tracks kernel heap reuse in real time, and lagg tx would dispatch into the aliased ifnet (cross-interface write/corruption surface).
  6. Unprivileged tx: 3000 UDP packets via lagg0 β€” guest survived (stale freed-memory flags read "inactive", failover selector fell through to the live tap1). The freed-memory reads in lagg_link_active (if_lagg.c:1571/1583 via LAGG_PORTACTIVE, if_lagg.h:155-158) did occur; whether it crashes depends on stale content.
  7. Panic (root trigger): ifconfig tap2 destroy (chunk freed a second time) then ifconfig lagg0 laggproto lacp β†’ lagg_lacp_attach (if_lagg.c:2085-2086) iterates ports β†’ lacp_port_create(dangling lp) β†’ if_addmulti(lp->lp_ifp=freed) (ieee8023ad_lacp.c:544) β†’ freed ifnet's stale if_ioctl path β†’ Fatal trap 12, page fault at 0x3c0, Stopped at tapifioctl+0x158 (serial console capture in panic.txt). Guest down at DDB.

Exploit chain (as far as taken)

  • uid=0 required for the setup (lagg/tap creation, member destroy) and for the demonstrated panic trigger.
  • Post-setup, the unprivileged surface is real and persistent: any user can (a) read freed/reused kernel heap through SIOCGLAGG port names (demonstrated live heap-reuse disclosure), (b) drive tx through the dangling primary (freed-memory-dependent crash/corruption), (c) any admin operation on the lagg (proto switch, destroy) faults through the dangling port.
  • Escalation to uid=0 was NOT developed: the primitive is a persistent dangling lp->lp_ifp into freed-then-reused heap (type-stable-ish via tap churn, arbitrary via same-size-class objects). A full chain would require grooming the freed chunk with a victim object and pivoting through the lagg's unconditional writes (lagg_port_destroy restores if_type/if_ioctl/if_output/if_lagg at if_lagg.c:763-766 into the dangling ifnet) β€” out of budget for this run; the panic + unprivileged leak are the demonstrated ceilings.

Fix validation

fix.diff: registers/deregisters on ifnet_detach_event (if_lagg.c:219/223). Applied inside the guest's /usr/src copy (sys/ tree untouched), make nativekernel KERNCONF=X86_64_GENERIC, rebooted into the patched kernel, re-ran the exact trigger sequence: see fix_validation.log / verdict.json:fix_* fields.

Note on the earlier "ifconfig hang"

The ifconfig lagg0 laggproto lacp command appeared to hang over ssh β€” that was the panic stalling the system (trap β†’ DDB on console). vm.sh status β†’ down confirmed the crash.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline stock kernel #0: stale member after destroy + Fatal trap 12 in tapifioctl via laggproto lacp. Patched kernel #1 (fix.diff applied in-guest, nativekernel rebuild, reboot): ifconfig tap0 destroy cleanly removes the port (unpriv SIOCGLAGG: ports=1, no stale entry, tap1 becomes MASTER); laggproto lacp and roundrobin switches succeed; guest up. Bad behavior gone.

findings/poc/DF-3344/fix_validation.log (full sequence on kernel #1) vs run.log + panic.txt (kernel #0)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Sep 11 04:59:12 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (make nativekernel KERNCONF=X86_64_GENERIC with fix.diff applied to sys/net/lagg/if_lagg.c)

Confirmed kernel references

Detail

Exploit chain

root: lagg0 create + laggport tap0/tap1 + up + IP -> root: ifconfig tap0 destroy (dangling port installed; UAF is now persistent) -> unprivileged: SIOCGLAGG loop reads freed-then-reused kernel heap through lp_ifp->if_xname (demonstrated: name flipped tap0->tap2 when objcache reused the chunk) -> unprivileged tx can dispatch netmsgs into the dangling/aliased ifnet (content-dependent; survived in this run) -> privileged admin op on the lagg (laggproto lacp demonstrated) faults through the freed ifnet -> Fatal trap 12 panic. Full uid=0 chain not developed: would require grooming the freed chunk with a chosen victim object and pivoting via lagg_port_destroy's unconditional if_type/if_ioctl/if_output/if_lagg restores into the dangling ifnet (if_lagg.c:763-766); demonstrated ceiling is persistent unprivileged heap disclosure + reliable panic.

Evidence (decisive lines)

['findings/poc/DF-3344/run.log - steps [3]/[4]: destroyed tap0 still listed (root+unpriv), post-destroy hex name from freed heap', "findings/poc/DF-3344/run.log - step [6]: unprivileged dump flips port[1] 'tap0'->'tap2' after tap2 create (dangling pointer into reused chunk)", "findings/poc/DF-3344/panic.txt - 'Fatal trap 12 ... fault virtual address = 0x3c0 ... Stopped at tapifioctl+0x158' reached via laggproto lacp on dangling member", 'findings/poc/DF-3344/fix_validation.log - patched kernel #1: ports=1 after destroy, LACP-OK/RR-OK, guest up']

PoC changes

no seed existed (new pass-2 finding); laggleak.c/udptx.c written from scratch. ifconfig syntax corrected during bring-up: DF uses 'laggport'/'laggproto', not FreeBSD 'addm'. SIOCGLAGG requires a preallocated port array passed via ra_port/ra_size (kernel recounts and clamps).

Verified recommended fix

Register/Deregister lagg_port_ifdetach on ifnet_detach_event (the event if_detach actually fires) - see fix.diff; optionally assert at registration that the event list is the live one.

Verdict

lagg_port_ifdetach is registered on ifnet_departure_event (if_lagg.c:218-220), an eventhandler key that nothing in the DragonFly kernel ever invokes (if_detach fires ifnet_detach_event, if.c:958); the only automatic port-removal path is therefore dead code. Destroying a member interface (e.g. ifconfig tap0 destroy frees the whole tap_softc with its embedded ifnet, if_tap.c:495) leaves a persistent dangling lp->lp_ifp in sc_ports. Demonstrated on the guest: (1) unprivileged SIOCGLAGG (any uid) keeps returning the destroyed member, reading its name from freed heap - after slab reuse the 'port name' mutated from 'tap0' to 'tap2', i.e. a live kernel-heap-reuse disclosure to unprivileged users; (2) unprivileged UDP tx through the dangling failover primary exercised freed-memory reads in lagg_link_active (survived - stale flags read inactive); (3) privileged 'ifconfig lagg0 laggproto lacp' drove lacp_port_create -> if_addmulti through the freed ifnet and panicked the kernel: Fatal trap 12, page fault at 0x3c0, Stopped at tapifioctl+0x158; guest down at DDB. Not a race - the UAF is permanent until the lagg is destroyed, and lagg destroy itself writes restores into the freed ifnet (if_lagg.c:763-766). Two-line fix (ifnet_detach_event) validated on a rebuilt kernel: member destroy now removes the port, the previously-fatal trigger succeeds, guest stays up.