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

Unsynchronized address-selection policy table: UAF race between unprivileged sysctl reader and privileged ioctl mutator

Field Value
ID DF-0615
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-416 Use After Free; CWE-362 Race Condition
File sys/netinet6/in6_src.c
Lines 728 (table); 737-760 (add); 762-783 (delete); 785-798 (walk); 800-809 (dump)
Area netinet6 (IPv6 source address selection)
Confidence likely
Discovered 2026-07-02
Reported pending

Summary

The global TAILQ addrsel_policytab (line 728) has no lock, token, or serialization of any kind. It is traversed by walk_addrsel_policy (line 791) β€” invoked from the world-readable sysctl net.inet6.ip6.addrctlpolicy via in6_src_sysctl (line 668) on the caller's user thread β€” while concurrently mutated by delete_addrsel_policyent (line 763, TAILQ_REMOVE + kfree at lines 779-780) and add_addrsel_policyent (line 737, TAILQ_INSERT_TAIL at line 757) invoked from in6_src_ioctl (line 682) which is dispatched to netisr0. The sysctl callback dump_addrsel_policyent calls SYSCTL_OUT (line 806) which performs a blocking copyout, opening a wide race window during which a concurrent deleter can free the very struct the walker holds.

Root cause

There is no synchronization primitive protecting addrsel_policytab.

Read path: in6_src_sysctl (line 668) runs on the calling user thread (sysctl handlers are NOT dispatched to netisr in DragonFlyBSD), calls walk_addrsel_policy (line 679/786) which iterates:

791:    for (pol = TAILQ_FIRST(&addrsel_policytab); pol;
792:         pol = TAILQ_NEXT(pol, ape_entry)) {
793:        if ((error = (*callback)(&pol->ape_policy, w)) != 0)
794:            return (error);
795:    }

dump_addrsel_policyent does SYSCTL_OUT(w->w_req, pol, sizeof(*pol)) (line 806) β€” SYSCTL_OUT is (r->oldfunc)(r, p, l) (sysctl.h:174), i.e. a copyout that can block on a user-space page fault.

Mutation path: in6_src_ioctl (line 682) is reached from in6_control_internal (in6.c:512) after a privileged check (in6.c:510 if (!privileged) return EPERM). SIOCAADDRCTL_POLICY/SIOCDADDRCTL_POLICY are in the dispatch list sent to netisr_cpuport(0) (in6.c:422-456), so mutations run on netisr0's thread.

The race: Between the walker obtaining pol at the top of the loop (line 791) and calling TAILQ_NEXT(pol, ape_entry) at the bottom, the SYSCTL_OUT at line 806 blocks, yielding the CPU. On netisr0 (a different CPU), delete_addrsel_policyent finds the same entry (lines 768-775), does TAILQ_REMOVE (line 779) unlinking it, then kfree(pol, M_IFADDR) (line 780). When the sysctl reader resumes, pol points to freed memory; TAILQ_NEXT reads pol->ape_entry.tqe_next from freed/reallocated memory β€” a use-after-free read that most commonly dereferences a wild or NULL pointer, panicking the kernel.

Threat model & preconditions

  • Attacker position: unprivileged local user. The sysctl net.inet6.ip6.addrctlpolicy is CTLFLAG_RD (read-only, world-readable) β€” any user can invoke it via sysctl net.inet6.ip6.addrctlpolicy or sysctl(3).
  • Required concurrency: a mutation is needed to trigger the UAF. SIOCAADDRCTL_POLICY/SIOCDADDRCTL_POLICY require root (in6.c:510, caps_priv_check_td SYSCAP_RESTRICTEDROOT). So the crash fires when any root process (e.g. an admin running ndp -P, rtsold/racoon policy reconfiguration, or a configuration daemon) adds or deletes a policy entry while the attacker reads the sysctl. In default configurations the table is static after boot, but the complete absence of any lock is a genuine concurrency defect.
  • Impact: kernel panic (reliable local DoS) via UAF read on freed heap object; speculative code execution if the freed UMA/kmalloc slab is reclaimed with attacker-controlled data and the resulting tqe_next pointer is dereferenced.

Proof of concept

PoC sources: findings/poc/DF-0615/reader.c (unprivileged reader loop) + mutator.c (root add/delete loop).

Build & run

cc -o reader reader.c
cc -o mutator mutator.c
./reader &                # unprivileged user, tight read loop
sudo ./mutator            # root, concurrent add+delete loop

Expected output

Kernel panics with a page-fault in TAILQ_NEXT / dump_addrsel_policyent walk path within seconds to minutes. The blocking SYSCTL_OUT copyout creates a large race window making this reliably reproducible. On a non-panic outcome, dmesg may show a freed-pointer dereference in in6_src_sysctl.

Impact

  • Blast radius: any DragonFlyBSD host with IPv6 enabled where the address-selection policy table is being reconfigured while an unprivileged user reads it.
  • Severity rationale: Medium. Unprivileged reader + root mutator (AC:H reflects the need for concurrent root reconfiguration). Deterministic panic once the UAF read fires.
  • Confidence: likely β€” the race window is wide (blocking copyout) but requires concurrent root mutation, which is config-dependent.

Add an lwkt_token to serialize all access to addrsel_policytab. The three functions that touch the list β€” add_addrsel_policyent, delete_addrsel_policyent, walk_addrsel_policy β€” must each acquire and release it on every path. In add_addrsel_policyent, the kmalloc(M_WAITOK) (line 752) must be moved BEFORE taking the token so the token is not held across a sleeping allocation.

--- a/sys/netinet6/in6_src.c
+++ b/sys/netinet6/in6_src.c
@@ -102,6 +102,9 @@
 #define ADDR_LABEL_NOTAPP (-1)
 struct in6_addrpolicy defaultaddrpolicy;

+/* Serializes addrsel_policytab between sysctl readers and ioctl mutators. */
+static struct lwkt_token addrsel_policy_token =
+   LWKT_TOKEN_INITIALIZER(addrsel_policy_token);
+
 static void    init_policy_queue(void);
 static int add_addrsel_policyent(struct in6_addrpolicy *);
 static int delete_addrsel_policyent(struct in6_addrpolicy *);
@@ -737,21 +740,26 @@
 add_addrsel_policyent(struct in6_addrpolicy *newpolicy)
 {
    struct addrsel_policyent *new, *pol;
+   int error = 0;

-   /* duplication check */
-   for (pol = TAILQ_FIRST(&addrsel_policytab); pol;
-        pol = TAILQ_NEXT(pol, ape_entry)) {
-       if (SA6_ARE_ADDR_EQUAL(&newpolicy->addr,
-                      &pol->ape_policy.addr) &&
-           SA6_ARE_ADDR_EQUAL(&newpolicy->addrmask,
-                      &pol->ape_policy.addrmask)) {
-           return (EEXIST);    /* or override it? */
+   /* Allocate before taking the token; M_WAITOK may sleep. */
+   new = kmalloc(sizeof(*new), M_IFADDR, M_WAITOK | M_ZERO);
+   new->ape_policy = *newpolicy;
+
+   lwkt_gettoken(&addrsel_policy_token);
+   /* duplication check */
+   for (pol = TAILQ_FIRST(&addrsel_policytab); pol;
+        pol = TAILQ_NEXT(pol, ape_entry)) {
+       if (SA6_ARE_ADDR_EQUAL(&newpolicy->addr,
+                      &pol->ape_policy.addr) &&
+           SA6_ARE_ADDR_EQUAL(&newpolicy->addrmask,
+                      &pol->ape_policy.addrmask)) {
+           error = EEXIST; /* or override it? */
+           goto out;
        }
    }
-
-   new = kmalloc(sizeof(*new), M_IFADDR, M_WAITOK | M_ZERO);
-   /* XXX: should validate entry */
-   new->ape_policy = *newpolicy;
    TAILQ_INSERT_TAIL(&addrsel_policytab, new, ape_entry);
-
-   return (0);
+out:
+   if (error)
+       kfree(new, M_IFADDR);
+   lwkt_reltoken(&addrsel_policy_token);
+   return (error);
 }

 static int
@@ -764,17 +772,22 @@
 delete_addrsel_policyent(struct in6_addrpolicy *key)
 {
    struct addrsel_policyent *pol;
+   int error = 0;

+   lwkt_gettoken(&addrsel_policy_token);
    /* search for the entry in the table */
    for (pol = TAILQ_FIRST(&addrsel_policytab); pol;
         pol = TAILQ_NEXT(pol, ape_entry)) {
        if (SA6_ARE_ADDR_EQUAL(&key->addr, &pol->ape_policy.addr) &&
            SA6_ARE_ADDR_EQUAL(&key->addrmask,
                       &pol->ape_policy.addrmask)) {
            break;
        }
    }
    if (pol == NULL) {
-       return (ESRCH);
+       error = ESRCH;
+       goto out;
    }

    TAILQ_REMOVE(&addrsel_policytab, pol, ape_entry);
    kfree(pol, M_IFADDR);
-
-   return (0);
+out:
+   lwkt_reltoken(&addrsel_policy_token);
+   return (error);
 }

 static int
@@ -786,11 +799,13 @@
    struct addrsel_policyent *pol;
    int error = 0;

+   lwkt_gettoken(&addrsel_policy_token);
    for (pol = TAILQ_FIRST(&addrsel_policytab); pol;
         pol = TAILQ_NEXT(pol, ape_entry)) {
        if ((error = (*callback)(&pol->ape_policy, w)) != 0)
-           return (error);
+           goto out;
    }
-
+out:
+   lwkt_reltoken(&addrsel_policy_token);
    return (error);
 }

Holding the lwkt_token across SYSCTL_OUT in walk_addrsel_policy is correct (lwkt_tokens are not spinlocks and may be held across blocking copyout in DragonFlyBSD's model).

References

Timeline

  • 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
  • 2026-07-02 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0615 Β· 19 files
FileTypeDescriptionSize
VERDICT.md verdict full narrative: reproduced UAF read mechanism, leak evidence, fix validation 10.2 KB ↓ raw
README.md readme original PoC README (reviewer scaffold notes) 3.0 KB ↓ raw
reader.c trigger-source unprivileged sysctl reader with UAF anomaly detection (count > real + hexdump) 3.8 KB view raw
mutator.c trigger-source root policy-table mutator: populate 64 entries + churn delete/re-add 2.8 KB view raw
capture.c trigger-source one-shot UAF capture: waits for oversized read, saves freed-chunk bytes 2.9 KB view raw
race.sh trigger-source root coordinator: 6 maxx readers + 1 root mutator, collects anomalies 1.9 KB view raw
build.sh build-script cc -O2 reader/mutator/capture 322 B view raw
run.sh run-script sudo ./run.sh [sec] -> race.sh 845 B view raw
fix.diff suggested-fix git-apply-able lwkt_token serialization of addrsel_policytab 2.6 KB view raw
run.log run-log decisive baseline race (#0): 48 anomalies, maxent up to 154 2.3 KB view raw
leak_capture.log run-log capture.c one-shot: 122 entries, 3384 bytes leaked from freed chunks 693 B view raw
leak.bin leak-sample raw 3384 bytes of freed addrsel_policyent data leaked to unprivileged user 3.3 KB ↓ download
leak_hex.txt leak-sample hexdump of leak.bin 1.0 KB view raw
leak_sample.txt leak-sample guest-side leak.bin metadata + first leaked entries 66 B view raw
fix_build.log build-log single-fix kernel build (NK_DONE rc=0) 5.6 MB ↓ download
fix_run.log run-log patched #1 kernel race: 0 anomalies, maxent=73 (real table size) 838 B view raw
env.txt environment uname, cc version, sysctl state, cpu count 381 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
README.md readme original PoC README (reviewer scaffold notes)
↓ download raw

DF-0615 β€” PoC: addrsel_policytab UAF race (reproduced)

Unsynchronized IPv6 address-selection policy table β†’ use-after-free read / kernel-heap info-leak to an unprivileged user, racing a world-readable sysctl reader against a privileged (root) policy-table mutator.

Status

REPRODUCED on DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC). Fix VALIDATED on a single-fix kernel (#1, lwkt_token serialization): the UAF/leak is gone. See VERDICT.md for the full analysis.

Files

  • reader.c β€” unprivileged user; tight sysctlbyname("net.inet6.ip6.addrctlpolicy") loop with UAF anomaly detection (flags reads returning more entries than the real table can hold + hexdumps leaked bytes).
  • mutator.c β€” root; adds 64 distinct 2001:XX00::/24 entries then churns delete+re-add. delete_addrsel_policyent (in6_src.c:779-780) does TAILQ_REMOVE + kfree; racing the reader's walk yields the UAF.
  • capture.c β€” one-shot: waits for a UAF read and saves the leaked freed-chunk bytes to /tmp/df0615_leak.bin.
  • race.sh β€” root coordinator: 6 readers (as maxx) + 1 mutator (root).
  • fix.diff β€” git apply-able fix: lwkt_token around all three accessors.

Build & run

# build (as maxx, no privilege needed)
./build.sh

# run (as root β€” the mutator needs privilege; race.sh su's to maxx for readers)
sudo ./run.sh 16

Expected outcome

Unpatched (#0): within ~1-3 s, readers print UAF#1: sysctl returned N entries (> 75 real) -- walked freed slab chunks with maxent climbing to 120-154 (real table max is 75: 9 RFC-3484 boot defaults + 64 churned). capture.c writes leak.bin with up to ~3.4 KB of freed addrsel_policyent data leaked from the slab free list.

Patched (#1, fix.diff): 0 anomalies, maxent=73 (exactly the real table size). No freed-chunk traversal.

How the UAF works

kfree links the freed chunk onto the slab free list by writing the previous free-list head into offset 0 of the chunk (kern_slaballoc.c:1584, chunk->c_Next = z->z_LChunks). struct addrsel_policyent's TAILQ_ENTRY ape_entry is also at offset 0. So after a concurrent kfree(pol), the reader's TAILQ_NEXT(pol) returns the slab free-list pointer, and the walk follows the free-list chain through freed chunks, copying their bodies (offset 16+, ape_policy) to userspace via the blocking SYSCTL_OUT copyout.

Notes

  • The reader is unprivileged; the mutator requires root (SIOCAADDRCTL_POLICY/SIOCDADDRCTL_POLICY are gated by caps_priv_check_td(SYSCAP_RESTRICTEDROOT) at in6.c:510).
  • The sysctl handler runs on the caller's user thread; the ioctl mutator runs on netisr0 (in6.c:456) β€” different CPUs, so the race is cross-CPU.
  • The table is always populated: ip6addrctl installs 9 RFC-3484 defaults at boot, so a reader always has entries to walk.
  • Impact on this kernel is the info-leak (freed-heap read); a panic would need slab-page reclamation, which the depot cache prevents under steady churn.
VERDICT.md verdict full narrative: reproduced UAF read mechanism, leak evidence, fix validation
↓ download raw

DF-0615 β€” VERDICT

Verdict: REPRODUCED (use-after-free / kernel-heap info-leak via unsynchronized address-selection policy table). Fix VALIDATED on a built single-fix kernel.

What the bug is

The global TAILQ addrsel_policytab (sys/netinet6/in6_src.c:728) β€” the IPv6 RFC-3484 address-selection policy table β€” has no lock, token, or serialization of any kind. It is concurrently:

  • read by walk_addrsel_policy (in6_src.c:786), a TAILQ_FOREACH that dereferences each entry and, via the callback dump_addrsel_policyent (in6_src.c:801), calls SYSCTL_OUT β†’ sysctl_old_user β†’ copyout (kern_sysctl.c:1337) for each entry. The sysctl net.inet6.ip6.addrctlpolicy (in6_src.c:665, CTLFLAG_RD, world-readable) routes here through in6_src_sysctl (in6_src.c:669), which runs on the calling user's thread (userland_sysctl, kern_sysctl.c:1519, does not dispatch to netisr). So the read side is unprivileged.
  • mutated by add_addrsel_policyent (in6_src.c:737, TAILQ_INSERT_TAIL) and delete_addrsel_policyent (in6_src.c:763, TAILQ_REMOVE + kfree(pol) at lines 779-780), reached from in6_src_ioctl (in6_src.c:682). SIOCAADDRCTL_POLICY / SIOCDADDRCTL_POLICY are dispatched to netisr0 (in6.c:456, lwkt_domsg(netisr_cpuport(0), ...)) after a privilege check (in6.c:510, caps_priv_check_td(SYSCAP_RESTRICTEDROOT)). So the mutate side needs root (a concurrent root reconfiguration β€” admin, ip6addrctl, rtsold/racoon, etc.).

There is no synchronization between the two sides. The reader's TAILQ_FOREACH holds a pol pointer across the blocking copyout; a concurrent deleter on netisr0 frees that very entry. When the reader resumes, pol = TAILQ_NEXT(pol, ape_entry) reads pol->ape_entry.tqe_next β€” the word at offset 0 of the freed chunk.

The use-after-free read (confirmed mechanism)

kfree in DragonFly's slab allocator (kern_slaballoc.c:1584-1585) links the freed chunk onto the per-zone free list by writing the previous free-list head into offset 0 of the chunk:

1584:    chunk->c_Next = z->z_LChunks;
1585:    z->z_LChunks = chunk;

struct addrsel_policyent's first field is TAILQ_ENTRY(addrsel_policyent) ape_entry, whose tqe_next is also at offset 0. So after the concurrent kfree(pol), the reader's TAILQ_NEXT(pol) returns the slab free-list pointer, not the real list successor. The walk then follows the free-list chain through freed chunks, calling SYSCTL_OUT on each one's body (offset 16+, ape_policy) and copying that freed-heap data to userspace.

This is a textbook use-after-free read (CWE-416) of kernel heap memory disclosed to an unprivileged user, caused by a race condition (CWE-362) β€” exactly as the finding claims.

Reproduction on the unpatched master DEV kernel

Guest: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS, 6 vCPU).

The table is always populated: the boot rc.d script ip6addrctl (etc/rc.d/ip6addrctl) installs 9 RFC-3484 default policy entries at boot, so an unprivileged reader always has entries to walk.

PoC = reader.c (unprivileged sysctl reader, tight loop) + mutator.c (root, adds 64 distinct 2001:XX00::/24 entries then churns delete+re-add in a tight loop) + race.sh (launches 6 readers as maxx + 1 mutator as root).

Decisive evidence (run.log, 16 s race, unpatched #0): all 6 readers observe the sysctl return more entries than can possibly exist β€” the real table tops out at 75 entries (9 defaults + 64 churned + 2 slack), yet readers saw up to 154 entries, with 48 total anomaly lines across the readers. The excess entries are bytes copied from freed slab chunks the reader walked via the corrupted free-list pointer.

One-shot leak capture (leak_capture.log, capture.c): caught a UAF at iter 265 β€” the sysctl returned 122 entries (47 beyond the real table), leaking 3384 bytes of freed addrsel_policyent bodies (stale labels/precedence/addresses from previously-deleted mutator entries, saved to leak.bin, hex in leak_hex.txt). This is freed kernel heap disclosed to an unprivileged user.

Sample leaked bytes (entries [75..121], from freed chunks):

leaked[0] fam=28 label=21 preced=61 | hex: 1c 1c 00 00 00 00 00 00 20 01 15 00 ...
leaked[1] fam=28 label=22 preced=62 | hex: 1c 1c 00 00 00 00 00 00 20 01 16 00 ...

(1c 1c = sin6_len=28, sin6_family=AF_INET6; these are stale freed-mutator entries with label=i, preced=40+i.)

Why it does not panic on this kernel (honest characterization)

The finding's headline mentions "panic or info-leak". On this INVARIANTS DEV kernel with the default debug.use_weird_array=0, the UAF manifests as the info-leak above, not a panic: the freed chunk's offset-0 word is the slab free-list pointer (a valid in-zone address), so the reader follows a chain of valid mapped freed chunks until it hits NULL and the walk ends. A panic would require the freed chunk's page to be reclaimed by the VM (released from the slab depot back to the page allocator) before the reader reads it β€” the slab depot's caching prevents this under steady churn, so a crash is not reliably reachable on this configuration. We confirmed debug.use_weird_array=1 does not force a crash either: kfree writes the free-list linkage at offset 0 after the weird poison (kern_slaballoc.c:1567 then :1584), so tqe_next remains a valid free-list pointer regardless.

Impact is therefore a reliable, repeatable kernel-heap info-leak to an unprivileged local user (up to ~3.4 KB per race hit, repeatedly; contents are freed slab data which, under a different heap state, could include other 128-byte-bucket kernel objects' contents). This is a real security defect; the "speculative code execution" angle in the finding is not demonstrated here and would require slab cross-zone reclamation + controlled reclaim content.

Exploit chain

Not a memory-corruption write primitive (the UAF is a read). No escalation chain developed β€” the primitive is a freed-heap read. Documented impact ceiling: repeatable disclosure of freed kmalloc-128-bucket heap data to an unprivileged user. (exploit_chain = none for the JSON, as this is not a corruption/write class.)

PoC changes (what I changed and why)

The reviewer's scaffold was a correct sketch but did not reproduce on its own:

  • reader.c β€” rewrote to (a) drop madvise(MADV_DONTNEED) (it broke the sysctl two-pass size negotiation on this guest, making reads return 0), (b) drop usched_set CPU-pinning (USCHED_SET_CPU is EPERM for unprivileged users on DragonFly β€” it silently failed and, combined with the static buffer, left the reader seeing only the 9 boot-default entries), (c) add unambiguous anomaly detection: flag any sysctl return with more entries than the real table can hold (> NENT+DEFAULTS+2 = 75) or with impossible fields, and hex-dump the leaked bytes.
  • mutator.c β€” rewrote to populate 64 distinct prefixes (2001:XX00::/24, label=i) then churn delete+re-add in rotation, keeping the table densely populated and constantly freeing entries (the original added only one entry, which the duplication check made a no-op after the first add β€” far too little churn to hit the window).
  • capture.c β€” new one-shot helper that waits for a UAF read and saves the raw leaked bytes (leak.bin) for evidence.
  • race.sh β€” new root-run coordinator: launches 6 readers via su maxx
  • 1 root mutator, races them, collects anomaly counts. (Original scaffold had no coordinator; the unprivileged-reader / privileged-mutator split needs a root orchestrator.)
  • build.sh / run.sh β€” added exact reproduce scripts.

Fix (fix.diff)

Author an lwkt_token (addrsel_policy_token, LWKT_TOKEN_INITIALIZER, matching the pattern at mld6.c:110) acquired around all three accessors:

  • walk_addrsel_policy β€” lwkt_gettoken before the TAILQ_FOREACH, lwkt_reltoken on return (held across the blocking SYSCTL_OUT; lwkt tokens are held across blocking copyout in DragonFly's model).
  • add_addrsel_policyent β€” move the M_WAITOK kmalloc before taking the token (must not sleep holding a token-candidate under contention), then take the token for the dup-check + TAILQ_INSERT_TAIL.
  • delete_addrsel_policyent β€” take the token around search + TAILQ_REMOVE + kfree.

This matches the finding's recommended fix (same lwkt_token approach, same three sites, same M_WAITOK-before-token ordering refinement). Minimal, targeted at the root cause.

Fix validation (Phase 8) β€” VALIDATED

  • Baseline (#0, unpatched): re-confirmed β€” 48 anomaly lines, readers saw up to 154 entries (>75 real), capture.c leaked 3384 bytes from freed chunks.
  • Applied fix.diff to in-guest /usr/src (all 5 hunks applied), built make -j6 nativekernel KERNCONF=X86_64_GENERIC (NK_DONE rc=0, fix_build.log), make installkernel, rebooted β†’ DragonFly 6.5-DEVELOPMENT #1 (sha256 ae346c44…).
  • Patched (#1, with fix): ran the same race for 25 s β†’ 0 anomaly lines, every reader maxent=73 (exactly the real table size: 9 defaults + 64 churned). No entry count ever exceeded the real table. The UAF is gone.

The token serializes the reader walk against the deleter's free, closing the race. Clean before/after.

Verified kernel references

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: on the unpatched #0 baseline the race produced 48 anomaly lines with readers seeing up to 154 entries (>75 real) and capture.c leaking 3384 bytes of freed slab data; applying fix.diff, building a single-fix kernel (#1), and re-running the SAME race for 25s yields 0 anomaly lines and maxent=73 (exactly the real table size) across all readers -- the lwkt_token serializes the reader walk against the deleter's free, closing the UAF. Clean before/after.

baseline #0 (run.log): r4 maxent=154, TOTAL anomalies=48, 'sysctl returned 97 entries (> 75 real) -- walked freed slab chunks' | capture: 'LEAKED 3384 bytes from freed slab chunks'. patched #1 (fix_run.log): r1..r6 anomaly_lines=0, maxent=73 (9 defaults + 64 churned), TOTAL anomaly lines=0. No entry count ever exceeded the real table size.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Fri Jul 3 05:22:21 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC (sha256 ae346c44da236e5b2f60f55dc378faa58cb420cb99d5dad0eacf8b9a703af39c)

Confirmed kernel references

Detail

Exploit chain

none. This is a use-after-free READ (freed-heap disclosure), not a memory-corruption write primitive, so there is no escalation chain to develop. Documented impact ceiling: repeatable disclosure of freed kmalloc-128-bucket heap data (up to ~3.4KB per race hit, repeatedly) to an unprivileged local user; contents are stale slab data that, under a different heap state, could include other 128-byte kernel objects' contents. A panic would require slab-page reclamation (the depot cache prevents this under steady churn), so it is not reliably reachable on this config; the info-leak is the realistic, demonstrated manifestation.

Evidence (decisive lines)

BASELINE (#0, 16s race): TOTAL anomaly lines=48; all 6 readers maxent=127-154 (real max 75); e.g. 'UAF#1: sysctl returned 97 entries (> 75 real) iter 503480 -- walked freed slab chunks'. capture.c: 'UAF at iter 265: 122 entries (> 75 real), 8784 bytes / LEAKED 3384 bytes (entries [75..121]) from freed slab chunks' -- leaked[0] fam=28 label=21 preced=61 | 1c 1c 00 00 00 00 00 00 20 01 15 00 ... (stale freed mutator entry). PATCHED (#1, 25s race): TOTAL anomaly lines=0; all readers maxent=73 (exactly 9 defaults + 64 churned); no entry count ever exceeded the real table.

PoC changes

Rewrote reader.c (dropped madvise which broke the sysctl two-pass; dropped usched_set CPU-pinning which is EPERM for unprivileged users; added anomaly detection flagging sysctl returns with >NENT+9+2=75 entries plus hexdump of leaked bytes). Rewrote mutator.c to populate 64 distinct 2001:XX00::/24 prefixes then churn delete+re-add (original added one entry which the dup-check made a no-op). Added capture.c (one-shot UAF capture saving leaked bytes), race.sh (root coordinator: 6 maxx readers + root mutator), build.sh, run.sh. The original reviewer scaffold built but could not hit the window.

Verified recommended fix

Add a static lwkt_token (addrsel_policy_token = LWKT_TOKEN_INITIALIZER) at in6_src.c:~105 and acquire it in all three accessors: walk_addrsel_policy (around the TAILQ_FOREACH, held across the blocking SYSCTL_OUT), add_addrsel_policyent (kmalloc M_WAITOK moved BEFORE lwkt_gettoken so the token is not held across a sleeping alloc; dup-check + insert under token), delete_addrsel_policyent (search + TAILQ_REMOVE + kfree under token). Matches the finding's proposed fix (same lwkt_token approach, same three sites, same M_WAITOK-before-token refinement). Full git-apply-able diff in findings/poc/DF-0615/fix.diff.

Verdict

REPRODUCED. The global TAILQ addrsel_policytab (sys/netinet6/in6_src.c:728) has no lock/token; the world-readable sysctl net.inet6.ip6.addrctlpolicy runs walk_addrsel_policy (in6_src.c:786, TAILQ_FOREACH) on the unprivileged caller's thread, dereferencing each entry across a blocking SYSCTL_OUT copyout (in6_src.c:806 -> kern_sysctl.c:1337), while a root ioctl (SIOCA/SIOCDADDRCTL_POLICY dispatched to netisr0 at in6.c:456, privileged at in6.c:510) does TAILQ_REMOVE+kfree (in6_src.c:779-780) concurrently. kfree writes the slab free-list pointer at offset 0 of the freed chunk (kern_slaballoc.c:1584), overlapping ape_entry.tqe_next; the reader's TAILQ_NEXT then follows the free-list chain through freed chunks, copying their bodies (ape_policy, offset 16+) to userspace. Confirmed on the #0 kernel: 6 unprivileged readers saw the sysctl return up to 154 entries when the real table tops out at 75 (9 RFC-3484 boot defaults + 64 churned) -- 48 anomaly lines; capture.c caught a 122-entry read leaking 3384 bytes of freed addrsel_policyent data to the unprivileged user (leak.bin). This is a real CWE-416 use-after-free read / CWE-362 race, exactly as the finding claims.