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.addrctlpolicyisCTLFLAG_RD(read-only, world-readable) β any user can invoke it viasysctl net.inet6.ip6.addrctlpolicyorsysctl(3). - Required concurrency: a mutation is needed to trigger the UAF.
SIOCAADDRCTL_POLICY/SIOCDADDRCTL_POLICYrequire root (in6.c:510,caps_priv_check_td SYSCAP_RESTRICTEDROOT). So the crash fires when any root process (e.g. an admin runningndp -P,rtsold/racoonpolicy 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_nextpointer 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:Hreflects 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.
Recommended fix
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
sys/netinet6/in6_src.c:728βstruct addrsel_policyhead addrsel_policytab;, the unlocked global list.sys/netinet6/in6_src.c:791-795βwalk_addrsel_policytraversal withTAILQ_NEXT.sys/netinet6/in6_src.c:806βSYSCTL_OUT, the blocking copyout.sys/netinet6/in6_src.c:779-780βTAILQ_REMOVE+kfreeindelete_addrsel_policyent.sys/netinet6/in6_src.c:665-666β theCTLFLAG_RDsysctl node.sys/netinet6/in6.c:510,422-456β privileged ioctl dispatch to netisr0.
Timeline
- 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
- 2026-07-02 Reported to DragonFlyBSD security contact (pending).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0615 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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; tightsysctlbyname("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 distinct2001:XX00::/24entries then churns delete+re-add.delete_addrsel_policyent(in6_src.c:779-780) doesTAILQ_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 (asmaxx) + 1 mutator (root).fix.diffβgit apply-able fix:lwkt_tokenaround 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_POLICYare gated bycaps_priv_check_td(SYSCAP_RESTRICTEDROOT)atin6.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:
ip6addrctlinstalls 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.
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), aTAILQ_FOREACHthat dereferences each entry and, via the callbackdump_addrsel_policyent(in6_src.c:801), callsSYSCTL_OUTβsysctl_old_userβcopyout(kern_sysctl.c:1337) for each entry. The sysctlnet.inet6.ip6.addrctlpolicy(in6_src.c:665,CTLFLAG_RD, world-readable) routes here throughin6_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) anddelete_addrsel_policyent(in6_src.c:763,TAILQ_REMOVE+kfree(pol)at lines 779-780), reached fromin6_src_ioctl(in6_src.c:682).SIOCAADDRCTL_POLICY/SIOCDADDRCTL_POLICYare 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) dropmadvise(MADV_DONTNEED)(it broke the sysctl two-pass size negotiation on this guest, making reads return 0), (b) dropusched_setCPU-pinning (USCHED_SET_CPUisEPERMfor 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 viasu 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_gettokenbefore theTAILQ_FOREACH,lwkt_reltokenon return (held across the blockingSYSCTL_OUT; lwkt tokens are held across blocking copyout in DragonFly's model).add_addrsel_policyentβ move theM_WAITOKkmallocbefore 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.cleaked 3384 bytes from freed chunks. - Applied
fix.diffto in-guest/usr/src(all 5 hunks applied), builtmake -j6 nativekernel KERNCONF=X86_64_GENERIC(NK_DONE rc=0,fix_build.log),make installkernel, rebooted βDragonFly 6.5-DEVELOPMENT #1(sha256ae346c44β¦). - Patched (
#1, with fix): ran the same race for 25 s β 0 anomaly lines, every readermaxent=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
sys/netinet6/in6_src.c:728β unlocked globaladdrsel_policytab.sys/netinet6/in6_src.c:737/:757βadd_addrsel_policyent(insert, no lock).sys/netinet6/in6_src.c:763/:779-780βdelete_addrsel_policyent(TAILQ_REMOVE+kfree).sys/netinet6/in6_src.c:786/:791-795βwalk_addrsel_policy(TAILQ_FOREACH, no lock).sys/netinet6/in6_src.c:806βSYSCTL_OUTblocking copyout in the callback.sys/netinet6/in6_src.c:665-669βCTLFLAG_RDworld-readable sysctl node.sys/netinet6/in6.c:422-456β ioctl dispatch to netisr0.sys/netinet6/in6.c:510-512β privilege check +in6_src_ioctlcall.sys/kern/kern_sysctl.c:1337βsysctl_old_userβcopyout(blocking read path).sys/kern/kern_slaballoc.c:1584-1585βkfreefree-list linkage at offset 0 (the UAF sink).
Fix verification
fixedVALIDATED 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.
Confirmed kernel references
- sys/netinet6/in6_src.c:728
- sys/netinet6/in6_src.c:737
- sys/netinet6/in6_src.c:757
- sys/netinet6/in6_src.c:763
- sys/netinet6/in6_src.c:779
- sys/netinet6/in6_src.c:780
- sys/netinet6/in6_src.c:786
- sys/netinet6/in6_src.c:791
- sys/netinet6/in6_src.c:806
- sys/netinet6/in6_src.c:665
- sys/netinet6/in6.c:456
- sys/netinet6/in6.c:510
- sys/kern/kern_sysctl.c:1337
- sys/kern/kern_slaballoc.c:1584
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.
No comments yet.