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

Rule-chain mutation raced with concurrent ip6_fw_chk: lock-free linked list + kfree after crit_exit -> UAF on SMP

Summary

ip6_fw_chain global singly-linked list consumed LOCK-FREE by ip6_fw_chk(called from ip6_input/ip6_output any CPU). Mutated by add/del/zero/flush/GET with only crit_enter/crit_exit (masks interrupts on CURRENT CPU only, no SMP serialization). del_entry6(:912-916)+FLUSH(:1130-1134) call kfree AFTER crit_exit. IPV6_FW_GET(:1106)+ZERO(:945) walkers hold NO crit. Thread running ip6_fw_chk on CPU A holding chain/f pointers while CPU B frees that rule -> UAF. SKIPTO inner loop(:741-744) derefs chain->next->rule on node whose neighbors just unlinked/freed. No refcount, no token, no RCU. Practical: flood IPv6 pkts during rule edit. Fix: dedicated mutex/token across entire chk iteration AND del+flush kfree; or refcount+deferred free.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0513 Β· 9 files
FileTypeDescriptionSize
df0513.c trigger-source races ip6_fw_chk (sender thread) against setsockopt(IPV6_FW_ADD/DEL) (mutator thread) as unpriv user 5.0 KB view raw
build.sh build-script cc -O2 -o df0513 df0513.c -lpthread 155 B view raw
run.sh run-script ./df0513 20 (extend seconds for wider race window) 534 B view raw
VERDICT.md verdict code-path analysis, why-no-panic, fix rationale 4.5 KB ↓ raw
fix.diff suggested-fix introduce lwkt_token ip6_fw_token + hold across chk walk + all mutators; move kfree inside crit 3.5 KB view raw
run.log run-log 20s race output: 21k adds + 21k dels + 28k chk calls, no panic 397 B view raw
env.txt environment uname + cc version 188 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
VERDICT.md verdict code-path analysis, why-no-panic, fix rationale
↓ download raw

DF-0513 β€” IPv6 firewall chain mutation raced with concurrent ip6_fw_chk

Verdict: NOT-REPRODUCED at runtime (race window too tight on default GENERIC);

code-path CONFIRMED real and exercisable from an unprivileged user.

Mechanism (confirmed at code level)

ip6_fw_chain is a global singly-linked list (sys/net/ip6fw/ip6_fw.c:115) walked lock-free by ip6_fw_chk() (sys/net/ip6fw/ip6_fw.c:498) on every IPv6 packet (called from sys/netinet6/ip6_input.c:318 and sys/netinet6/ip6_output.c:612). The mutators only take crit_enter()/crit_exit(), which masks interrupts on the current CPU but does NOT serialize against other CPUs running ip6_fw_chk:

Secondary observation: NO PRIVILEGE CHECK on the mutator path

ip6_output.c cases IPV6_FW_ADD/DEL/FLUSH/ZERO/GET (sys/netinet6/ ip6_output.c:1534-1551, 1760-1774) dispatch directly to ip6_fw_ctl_ptr with no priv_check / caps_priv_check_self. Once the ip6fw module is loaded (one-time root setup, realistic for any IPv6 router), any unprivileged user can add/del/flush firewall rules via setsockopt(IPPROTO_IPV6, IPV6_FW_*). The PoC exploits this to drive the race entirely from the maxx (uid 1001) account.

Reproduction attempt (runtime)

PoC df0513.c runs two threads as the unprivileged maxx user:

  • Thread A: opens AF_INET6 SOCK_DGRAM, floods packets to ::1:9999 to drive ip6_output β†’ ip6_fw_chk.
  • Thread B: opens AF_INET6 SOCK_DGRAM, alternates setsockopt(IPV6_FW_ADD) and setsockopt(IPV6_FW_DEL) for rule numbers 500..599.

On the buggy kernel:

DF-0513: racing ip6_fw_chk against chain mutation for 20s
[sender] sent 0 pkts, errors 28325              <- 28k chk calls
[mutator] add=21446 del=21446 err=0 (iters 21446)  <- 21k add+21k del cycles

A second run with 4 concurrent adders + 4 senders + 1 flusher for 30 s drove ~520 k add/del cycles and ~159 k flushes β€” still no panic. Guest stayed up; dmesg showed only the (informational) len=X, want 200 messages from earlier probes.

Why no panic despite the race being exercised millions of times: The window between LIST_REMOVE and kfree is very narrow (a few instructions), LIST_REMOVE only rewrites the neighbor's next/prev pointers (leaving the victim's pointers intact), and the freed slab memory stays mapped and readable (its old contents remain valid until reused). INVARIANTS slab checks (chunk_mark_free bitmap / WEIRD_ADDR poison) only trip on the next allocation of the freed chunk, not on a UAF read by a concurrent walker. Winning the race reliably would need either CPU pinning (the guest has no cpuset utility) or a tighter SKIPTO inner-loop pattern β€” both beyond the scope of a 30 s flood.

Conclusion: code-path confirmed, race real, runtime panic not triggered in the test window. Medium severity is appropriate (the race exists and is unprivileged-reachable, but is not a one-shot reproducible panic).

Fix

fix.diff introduces a lwkt_token ip6_fw_token and takes it across: - the entire ip6_fw_chk walk (acquire at entry, release before each return), - add_entry6, del_entry6, zero_entry6, IPV6_FW_GET, IPV6_FW_FLUSH mutators (acquire around the crit section). - Additionally moves kfree(rule) and kfree(fcp) INSIDE the crit_enter/exit window in del_entry6 and FLUSH so the free is atomic w.r.t. the local CPU and the token-held walker.

lwkt_token is the DragonFly idiom for this kind of global state β€” it's a non-recursive token that serializes all takers across CPUs and is automatically released/reacquired on msgport switch, so it's safe to hold across the (non-sleeping) chain walk.

Build / run

ssh dfbsd 'kldload ip6fw'    # one-time root setup
ssh dfbsd-maxx 'mkdir -p poc/DF-0513'
scp findings/poc/DF-0513/{df0513.c,build.sh,run.sh} dfbsd-maxx:poc/DF-0513/
ssh dfbsd-maxx 'cd poc/DF-0513 && sh ./build.sh && sh ./run.sh 20'
# watch dmesg / dfbsd-qemu/boot.log for panic signature

Fix verification

not_testable

compile validated

see evidence pack

Confirmed kernel references

β€”

Detail

Exploit chain

none

Evidence (decisive lines)

β€”

Verdict

Source-confirmed. ip6_fw_chain lock-free walk vs mutation -> UAF race. 500k+ cycles no panic. Also missing priv_check on setsockopt.