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

v1 ng_l2tp session-hook / hook-private use-after-free: unreferenced cached hook+hpriv used across a microsecond-scale window while concurrent rmhook frees both

Summary

ng_l2tp_recv_lower() caches the session hook and its hpriv from a lockless LIST_FOREACH over node->hooks with no reference and no lock, then dereferences hpriv->conf/hpriv->nr and delivers through the cached hook - a measured ~13us window with a 64KB offset-strip packet. ng_l2tp_disconnect() kfrees the hpriv the instant an rmhook lands, and the framework frees the hook struct immediately after. v1 netgraph delivers data synchronously in the feeder's thread, so any feeder running off the control-plane CPU makes the cached pointers use-after-free: freed hook/hpriv chunks are recycled by the next hook creation (same-zone LIFO), yielding cross-session packet misdelivery through a reallocated stale hook and kernel reads/writes through a dangling 12-byte hpriv (hpriv->nr write is packet-influenced). On DragonFly specifically, every practically reachable entry pair serializes on cpu0's netisr (ng_socket and ng_ksocket so_port pinning - verified with in-kernel probes: 100% of 2.3M frees and 173k deliveries on cpu0), so the race does not manifest; it becomes live for lower hooks fed from non-socket contexts (ng_ether-style link layers) - undemonstrable on the audit guest (the eiface escape hatch panics - DF-3486). VERDICT: not_reproduced with a full verification campaign (9 harness iterations, in-kernel diagnostic module with stale-detectors and per-CPU histograms, 2.3M hook frees vs 13us windows - zero stale accesses). Generic-SMP UAF (cross-session VPN data misdelivery + kernel UAF read/write); DF-today hardening-grade Low.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3495 Β· 14 files
FileTypeDescriptionSize
README.md β€” 3.7 KB ↓ raw
VERDICT.md β€” 5.5 KB ↓ raw
l2tp_hook_uaf.c β€” 13.5 KB view raw
l2tp_hook_uaf_eiface.c β€” 13.6 KB view raw
build.sh β€” 157 B view raw
run.sh β€” 131 B view raw
build.log β€” 199 B view raw
run.log β€” 1.3 KB view raw
env.txt β€” 307 B view raw
probe.diff β€” 3.1 KB view raw
fix.diff β€” 2.3 KB view raw
panic.txt β€” 1.3 KB view raw
manifest.json β€” 1.2 KB view raw
verdict.json β€” 3.7 KB view raw

DF-3495 β€” v1 ng_l2tp session-hook / hook-private use-after-free (pass 2)

File: sys/netgraph/l2tp/ng_l2tp.c Type: UAF / lifetime (CWE-416), Severity Low on DragonFly (see VERDICT) PoC verdict: not_reproduced on the audit guest β€” root-caused; the code defect is real but every practically-reachable trigger pair serializes on cpu0's netisr on DragonFly (details in VERDICT.md).

What the bug is

ng_l2tp_recv_lower() (ng_l2tp.c:769-781) looks up the session hook with a lockless LIST_FOREACH over node->hooks, caching both hook and hpriv, then dereferences hpriv->conf / hpriv->nr (861-872) and delivers through the cached hook (882) with no reference and no lock. Concurrently, ng_l2tp_disconnect() (661-664) kfree()s the hpriv the moment an rmhook lands on the session hook, and the framework (ng_disconnect_hook β†’ ng_unref_hook, ng_base.c:831-858) frees the hook struct immediately after. v1 netgraph delivers data synchronously in the feeder's context (ng_base.c:1678-1999), so on an SMP system where the feeder and the control plane run on different CPUs, the cached pointers can be used after free.

How to build / run the PoC

On the DragonFly guest as root:

kldload ng_socket ng_ksocket ng_l2tp
cc -O2 -Wall -pthread -o /tmp/l2tp_hook_uaf /tmp/l2tp_hook_uaf.c
/tmp/l2tp_hook_uaf 90 2 3        # seconds, injectors, teardown threads

Graph built by the PoC (mirrors a real mpd-style L2TP-over-UDP deployment):

ng_socket(injN) --h_l2--> ng_l2tp(L):ctrl
L:lower <--inet/dgram/udp--> ng_ksocket bound to 127.0.0.1:<port>
injN:s1 <----> L:session_0001     (flooded with session-id 0x0001 packets)
injN:s3 <----> L:session_0003     (decoy; teardown ping-pongs the hooks)
  • Injector threads: UDP sendto of L2TP data packets (hdr 0x0a02 = SEQ|OFF|V2, tid 0, sid 0x0001, offset 64000) into the ksocket β†’ ng_l2tp_recv_lower with a measured ~13 Β΅s window between hook-cache and delivery (the 64 KB offset-strip walks the mbuf chain after the lookup).
  • Teardown threads: rmhook session_XXXX + connect ping-pong (2.3M hook frees in 90 s).
  • Oracle: any packet received at the socket node from hook s3 was delivered through a stale (freed-then-reallocated) cached hook β€” impossible on a correct kernel.

Expected result on a vulnerable-and-concurrent kernel: delivered s3 > 0 (cross-session misdelivery through a UAF hook) and/or kernel panic. Observed on the audit guest: delivered s3 = 0, no stale accesses detected by in-kernel probes (see VERDICT.md for why β€” CPU pinning serialization).

In-kernel probes

probe.diff is a diagnostic-only patch (never applied to the repo sys/) that adds net.graph.l2probe.* counters:

  • frees / free_cpu[] β€” session-hook disconnects + which CPU ran them
  • deliv / deliv_cpu[] β€” delivery-point executions + CPU
  • stale_inv β€” delivery attempted through a freed (HK_INVALID) hook
  • stale_realloc β€” delivery attempted through a freed-then-reallocated hook (the definitive in-kernel UAF detection)
  • hpriv_bad β€” cached hpriv whose session_id no longer matches (stale hpriv)
  • walkn / walkns β€” offset-strip executions and total ns (window size)

Files

  • l2tp_hook_uaf.c β€” main PoC (ksocket variant, the decisive run)
  • l2tp_hook_uaf_eiface.c β€” variant that tries a genuinely parallel data path via ng_eiface; it trips the already-known DF-3486 panic (if_attach from netisr on INVARIANTS kernels) instead β€” included for completeness
  • probe.diff β€” diagnostic kernel-module patch (guest-local only)
  • fix.diff β€” proposed defensive fix for the lifetime defect
  • build.sh, run.sh, build.log, run.log, env.txt
  • VERDICT.md, manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-3495 VERDICT β€” not_reproduced (root-caused), defect real

Bottom line

The session-hook / hook-private use-after-free exists in the code β€” an unreferenced hook + hpriv pair is cached from a lockless LIST_FOREACH (ng_l2tp.c:769-781) and used ~13 Β΅s later (861-882, measured) while any concurrent rmhook frees both objects immediately (661-664 + ng_base.c:831-858). On this DragonFly guest it could not be triggered: every practically reachable (feeder, control-plane) pair of entry points executes serialized on cpu0's netisr thread, so the free and the use never overlap. Verdict: not_reproduced, impact none on this system via these paths.

What was run (decisive final run, run.log)

  • Runtime 90 s, 2 injector threads (L2TP-over-UDP via ng_ksocket, 64 KB packets, sid 0x0001), 3 teardown threads (rmhook/connect ping-pong).
  • 797,646 UDP packets injected; 173,656 delivery-point executions (probe deliv); 2,309,380 session-hook frees (probe frees); 346,775 offset-strip walks totalling 4.55 s of cumulative lookupβ†’delivery window (avg 13.1 Β΅s per packet).
  • Zero UAF observations: stale_inv = 0, stale_realloc = 0, hpriv_bad = 0; userspace oracle delivered s3 = 0.
  • If the free and the use could overlap even 0.01% of the time, ~2.3M frees against 173K Γ— 13 Β΅s of exposure would have produced thousands of hits. Zero is conclusive for non-concurrency, not for absence of the window.

Why it does not overlap (the root cause, all verified in-kernel)

The probes record the executing CPU of every delivery and every free:

net.graph.l2probe.deliv_cpu: 173656 0 0 0 0 0 ...
net.graph.l2probe.free_cpu: 2309380 0 0 0 0 0 ...

100% of both paths run on cpu0. On DragonFly:

  1. ng_socket control sockets (ngctl-style rmhook/connect) execute their pru_send handlers on so->so_port, which socreate() assigns as netisr_cpuport(0) (sys/kern/uipc_socket.c:259). All netgraph control messages from userland therefore run on cpu0's netisr thread.
  2. ng_socket data sockets likewise (same socreate path) β€” verified earlier in the session with a socket-fed lower hook: also 100% cpu0.
  3. ng_ksocket (the real mpd L2TP-over-UDP deployment): the underlying UDP socket's so_port comes from udp_initport() β†’ inp_initport() = the CPU where the socket was created (sys/netinet/ip_demux.c:462-476). The ksocket is created inside ng_ksocket_newhook, which runs while processing the mkpeer control message β€” on cpu0. Every inbound UDP packet is re-dispatched to that socket's port (cpu0) before the upcall delivers into the graph, so recv_lower runs on cpu0 as well (probes confirm).
  4. Varying source ports/flows cannot change this: the pinning is per-socket at creation, not per-packet (32- and 64-socket pools were tried β€” still 100% cpu0).
  5. The one constructible off-cpu0 feeder — ng_eiface's transmit path (ifq_dispatch → hash-spread netisr CPUs → ng_eiface_start → NG_SEND_DATA into l2tp:lower) — panics the guest at node creation on INVARIANTS kernels: panic: try holding ifnet lock in netisr (ng_eiface_constructor → ether_ifattach_bpf → if_attach → ifnet_lock, KASSERT at sys/net/if.c:3787). This is the already-filed DF-3486 (panicked guest recovered; boot.log excerpt in panic.txt). An ng_ether-fed "lower" hook (L2TP-over-Ethernet) would be the remaining genuinely-parallel configuration, but this SLIRP-only guest cannot inject raw host→guest Ethernet frames to drive it.

Residual risk assessment

  • The defect is a true lifetime bug of the classic v1-netgraph style; any future (or existing non-socket) feeder that runs ng_send_data into l2tp:lower from a non-cpu0 context (ng_ether-style link layers, other node types executing in their own contexts) makes it live: freed hook + 12-byte hpriv chunks get recycled by the very next hook creation in the teardown loop (same-zone LIFO), yielding cross-session packet misdelivery (the s3 oracle) and kernel memory reads/writes through a dangling pointer.
  • DF-0492 (pass 1, High) remains the practically-reachable race in this file: its rack/xack timers run in softclock context, which is NOT serialized with the cpu0 data path.
  • The teardown+traffic machinery built here (2.3M frees / 90 s against a 13 Β΅s exposure window with in-kernel stale-detectors) is exactly the rig needed to re-verify DF-3495 the day an off-cpu0 feeder exists.

Fix

fix.diff (defensive, authored against the read-only tree; not applied): hold a hook reference across the cached lookup→delivery span in ng_l2tp_recv_lower() and defer the hpriv free past the last hook reference. Because the race did not reproduce on this system, fix validation is honestly marked not_testable — a fixed kernel is observationally identical here.

Artefacts

  • run.log β€” final decisive run (counters + probe sysctls)
  • env.txt β€” guest uname/modules
  • probe.diff β€” the diagnostic ng_l2tp module patch used for the counters
  • panic.txt β€” DF-3486 panic tripped by the eiface variant (context)
  • l2tp_hook_uaf.c, l2tp_hook_uaf_eiface.c β€” PoC sources

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.

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log (final 90s run: 2.3M frees, 0 hits + deliv_cpu/free_cpu sysctls showing 100% cpu0); VERDICT.md (full root cause); probe.diff (diagnostic counters); l2tp_hook_uaf.c (PoC); l2tp_hook_uaf_eiface.c (eiface variant tripping DF-3486); panic.txt

PoC changes

Complete harness authored from scratch (no seed): netgraph graph builder via raw NGM_ control messages, L2TP-over-UDP injector via ng_ksocket, rmhook/connect teardown churn, s3-arrival misdelivery oracle, plus a guest-local diagnostic ng_l2tp module patch (probe.diff) adding net.graph.l2probe. counters (frees/deliv/stale_inv/stale_realloc/hpriv_bad/walk timing/per-CPU histograms) to observe the race from inside the kernel. Iterations: ng_socket-lower variant, ksocket variants (single + 64-socket pools), 20KB/32KB/64KB offset-strip packets, eiface variant.

Verified recommended fix

Hold a hook reference across the cached lookup-to-delivery span in ng_l2tp_recv_lower() and defer the hpriv free to node shutdown (dead list); see fix.diff

Verdict

The session-hook/hpriv use-after-free is real in the code (unreferenced hook+hpriv cached from a lockless LIST_FOREACH at ng_l2tp.c:769-781 and used ~13us later at 861-882 while ng_l2tp_disconnect() at 661-664 kfrees hpriv on any concurrent rmhook), but it could not be triggered on this DragonFly guest: in-kernel probes prove 100% of both the data path and the control path execute on cpu0's netisr thread (ng_socket so_port = netisr_cpuport(0); ng_ksocket's UDP socket port pinned to its creation cpu = cpu0), so the free and the use never overlap. Decisive run: 2,309,380 hook frees vs 173,656 deliveries with 13.1us average lookup-to-delivery windows and zero stale-hook/stale-hpriv observations (probes stale_inv/stale_realloc/hpriv_bad all 0, userspace s3-oracle 0). The one constructible off-cpu0 feeder (ng_eiface transmit path) panics at node creation on INVARIANTS kernels (known DF-3486); ng_ether-fed lower hooks remain the theoretically-exposed configuration but cannot be driven on this SLIRP-only guest. Filed as Low: hardening-grade on DragonFly today, live for any non-socket feeder context.