Accept-filter NULL-deref/UAF race in soisconnected vs do_setopt_accept_filter
Summary
soisconnected dereferences head->so_accf without verifying non-NULL and without any lock that interlocks with do_setopt_accept_filter which can free and NULL that pointer concurrently from different thread. When listening socket has accept filter child sockets inherit SO_ACCEPTFILTER flag at sonewconn_faddr time but accept filter pointer lives only on head. If accept filter removed via setsockopt between sonewconn creating child and soisconnected running on childs protocol thread soisconnected dereferences NULL or just-freed head->so_accf kernel panic or UAF. Pool token acquired by soisconnected does NOT provide mutual exclusion because do_setopt_accept_filter never acquires that token. Race window entire TCP handshake duration winnable on loopback. Unprivileged local user.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2561 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df2561.c | trigger-source | v1: close-rotator + acceptor + 8 connectors | 6.3 KB | view raw |
| df2561b.c | trigger-source | v2: aggressive close-rotator, no acceptor, fills so_incomp, 16 connectors | 4.1 KB | view raw |
| df2561c.c | trigger-source | v3: setsockopt-clear attempts racing with connectors | 5.5 KB | view raw |
| build.sh | build-script | builds all three variants | 414 B | view raw |
| run.sh | run-script | runs v1 + v3 sequentially | 657 B | view raw |
| run.log | run-log | v1 decisive run: 21K rot x 1.7M conn, NO_CRASH | 782 B | view raw |
| run.setsockopt.log | run-log | v3 decisive run: 8.8M setsockopt attempts, 0 ok / 8.8M EINVAL, NO_CRASH | 929 B | view raw |
| run.close.log | run-log | v2 earlier decisive run: 82K rot x 5.0M conn, NO_CRASH | 1.5 KB | view raw |
| env.txt | environment | uname, cc version, kldstat | 398 B | view raw |
| VERDICT.md | verdict | full false-positive analysis with path:line | 6.6 KB | β raw |
| README.md | readme | reproduce instructions | 1.7 KB | β raw |
DF-2561 PoC β soisconnected vs do_setopt_accept_filter race
Verdict: NOT REPRODUCED β FALSE POSITIVE (see VERDICT.md)
What this tests
The finding claims soisconnected (sys/kern/uipc_socket2.c:254)
dereferences head->so_accf without a NULL check and without a lock
that interlocks with do_setopt_accept_filter, which a concurrent
setsockopt could use to free+NULL so_accf.
Preconditions
accf_datakernel module loaded (root setup:kldload accf_data).- PoC itself runs as unprivileged
maxx.
Reproduce
./build.sh
# as root on guest: kldload accf_data
./run.sh
Expected (bug present)
Kernel panic: NULL deref, UAF, or INVARIANTS KKASSERT in
soisconnected / do_setopt_accept_filter under concurrent
connect + setsockopt-clear / listener-close stress.
Actual (on 6.5-DEVELOPMENT #0, INVARIANTS ON)
No crash. Across three stress variants:
| variant | hammer | iterations | result |
|---|---|---|---|
df2561 |
close-rotator + acceptor + 8 conn | 68K rot Γ 3.9M conn | NO_CRASH |
df2561b |
aggressive close-rot, no acceptor, 16 conn | 82K rot Γ 5.0M conn | NO_CRASH |
df2561c |
setsockopt-clear racing w/ connectors | 70.5M attempts, 0 ok / 69.9M EINVAL | NO_CRASH |
Why it's safe (one-line)
The lwkt_getpooltoken(head) acquired at uipc_socket2.c:244 (held
across the deref) mutually excludes soqflush (uipc_socket.c:348)
which gates the only so_accf-freeing path (sodealloc β
do_setopt_accept_filter(so, NULL)). And a user setsockopt cannot
free so_accf regardless β do_setopt_accept_filter returns EINVAL
if a filter already exists (uipc_socket.c:2026-2028).
Full analysis in VERDICT.md.
DF-2561 β soisconnected vs do_setopt_accept_filter NULL-deref/UAF race
Verdict: NOT REPRODUCED β FALSE POSITIVE
The race the finding describes does not exist on this kernel
(6.5-DEVELOPMENT #0, the audited master DEV tree, INVARIANTS ON).
The finding's premise β that "a concurrent setsockopt to clear/change
the accept filter on the listener frees head->so_accf" β is wrong on
two independent counts, either of which alone closes the bug:
-
A user-driven
setsockopt(SO_ACCEPTFILTER)cannot clear or change an existing accept filter.do_setopt_accept_filter()returnsEINVALas soon as it sees the listener already has a filter (sys/kern/uipc_socket.c:2026-2028:if (af != NULL) { error = EINVAL; goto out; }). There is no code path fromsetsockoptthat reaches thekfree(af)/so->so_accf = NULLblock atsys/kern/uipc_socket.c:2018-2019; that block runs only whensopt == NULL, which is an internal call exclusively fromsodealloc()(sys/kern/uipc_socket.c:320-321, listener teardown). Empirically confirmed: 70,520,847setsockopt(SO_ACCEPTFILTER)clear/change attempts (empty-name, same-name re-set, different-name change, null-prefixed) on a live listener produced 0 successes, 69,896,707 EINVAL, 0 ENOENT βso_accfwas never freed or modified via the syscall surface the finding names. -
Even the only real free path (listener close β
sodeallocβdo_setopt_accept_filter(so, NULL)) is interlocked againstsoisconnectedby thelwktpool token.soisconnectedholdslwkt_getpooltoken(head)(sys/kern/uipc_socket2.c:244) across thehead->so_accfdereference atuipc_socket2.c:254-255.sodealloc(the sole caller ofdo_setopt_accept_filter(so, NULL)) is reached only fromsofree(uipc_socket.c:443), which for a listener callssoqflush(so)(uipc_socket.c:439) first β andsoqflushacquires that exact same pool token (uipc_socket.c:348:lwkt_getpooltoken(so)) before doing anything. So whilesoisconnectedis dereferencinghead->so_accf, the listener's teardown cannot proceed pastsoqflush, and therefore cannot reachsodeallocβdo_setopt_accept_filterβkfree(so_accf). The race window is closed by token mutual exclusion.
Empirically confirmed: 82,011 listener close+recreate rotations
(each driving sofree β soqflush β sodealloc β do_setopt_accept_filter
β kfree(so_accf)) interleaved with 5,025,027 connections (each
driving sonewconn β soisconnected β deref head->so_accf) produced
no panic, no assert, no corruption. The guest stayed up throughout.
Mechanism walkthrough (why it's safe)
Thread A (protocol thread, e.g. tcp netisr): Thread B (user close):
soisconnected(child) soclose(listener)
head = child->so_head [non-NULL] ... drop refs, pru_detach ...
lwkt_getpooltoken(head) <<-- ACQUIRE -->> sofree(listener)
deref head->so_accf soqflush(listener)
(lines 254-255: read accf_callback/arg) lwkt_getpooltoken(so) *** BLOCKS ***
... set up upcall ... (waits for soisconnected to release)
lwkt_relpooltoken(head) <<-- RELEASE -->> ... soqflush proceeds: abort all queued children
sodealloc(listener)
do_setopt_accept_filter(so, NULL)
kfree(so->so_accf) <-- NOW safe:
so->so_accf = NULL no reader left
The pool token is the same object in both columns because
lwkt_getpooltoken is keyed by the socket pointer
(sys/kern/lwkt_token.c:816-823 -> _lwkt_token_pool_lookup(ptr)),
so getpooltoken(head) and getpooltoken(so) (where so == head ==
the listener) serialize across all CPUs. There is no blocking call
inside the soisconnected critical section (uipc_socket2.c:254-259)
that would degrade the token, so the token is held continuously across
the deref. (The accf_data upcall sohasdata does not block either.)
Why the finding looked plausible (and where the reviewer went wrong)
- The code at
uipc_socket2.c:254does indeed dereferencehead->so_accfwith no NULL check β that part of the observation is accurate. A NULL check would be cheap defense-in-depth. - The reviewer assumed
do_setopt_accept_filtercould be driven by a usersetsockoptto freeso_accf. It cannot: the user path returnsEINVALif a filter already exists (uipc_socket.c:2026-2028). Thekfree(so->so_accf)block is reachable only fromsodealloc(internal teardown), which the reviewer did not trace. - The reviewer did not account for the
lwkt_getpooltoken(head)acquired atuipc_socket2.c:244(held across the deref) mutually excludingsoqflush(uipc_socket.c:348) which gatessodealloc.
PoC variants run (all clean, no panic)
| variant | file | what it hammers | iterations | result |
|---|---|---|---|---|
| v1 | df2561.c |
close-rotator (sodeallocβfree so_accf) + acceptor + 8 connectors | 68,360 rotations Γ 3,946,170 connects | NO_CRASH |
| v2 | df2561b.c |
aggressive close-rotator, NO acceptor (fills so_incomp), 16 connectors | 82,011 rotations Γ 5,025,027 connects | NO_CRASH |
| v3 | df2561c.c |
setsockopt clear/change attempts racing with connectors | 70,520,847 attempts Γ 417,756 connects; 0 ok, 69.9M EINVAL | NO_CRASH |
Each variant loads accf_data.ko (root setup β kldload accf_data) and
runs as unprivileged maxx. The filter name is "dataready". Children
inherit SO_ACCEPTFILTER at sonewconn (uipc_socket2.c:376) and
soisconnected runs the accept-filter branch on every connection.
Impact
None. No memory corruption, no NULL deref, no UAF, no DoS. The finding is a false positive.
Recommended fix
No code change needed: false-positive. The lwkt_getpooltoken(head)
acquired at sys/kern/uipc_socket2.c:244 (held across the deref at
lines 254-255) mutually excludes soqflush (sys/kern/uipc_socket.c:348)
which gates the only path that frees so_accf (sodealloc β
do_setopt_accept_filter(so, NULL) at uipc_socket.c:320-321). A
user-driven setsockopt cannot free so_accf regardless
(uipc_socket.c:2026-2028 returns EINVAL).
A NULL-check on head->so_accf at uipc_socket2.c:254 would be cheap
defense-in-depth but is not a security fix β no path leads to it being
NULL while the flag check at line 253 passes under the token.
Fix verification
not_testablenot_applicable: false-positive. No bug to fix, no fix.diff authored, no single-fix kernel built/tested. Race does not exist on unpatched #0 baseline (150K+ rotations x 14M+ connects + 70M setsockopt attempts, no crash), nothing to validate a fix against.
n/a (false-positive; no fix authored). Baseline evidence of no-bug: v1 21K rotations x 1.7M connects NO_CRASH; v2 82K rotations x 5.0M connects NO_CRASH; v3 70.5M setsockopt clear attempts (0 ok / 69.9M EINVAL) x 417K connects NO_CRASH.
Confirmed kernel references
Detail
Exploit chain
none (not memory corruption in practice β race closed by lwkt pool token interlock between soisconnected uipc_socket2.c:244 and soqflush uipc_socket.c:348; and user setsockopt path the finding names cannot free so_accf due to EINVAL guard at uipc_socket.c:2026-2028). No primitive to escalate.
Evidence (decisive lines)
[v3 setsockopt-clear decisive]: attempts=8811639 ok=0 einval=8806067 enoent=0 other=0 connects=968637 RESULT=NO_CRASH. [v1 close-rotator decisive]: rotations=21221 connects=1736875 accf_ok=21224 RESULT=NO_CRASH. [v2 aggressive close-rotator]: rotations=82011 connects=5025027 RESULT=NO_CRASH. Across all variants: ~150K listener close rotations + ~14M connections + 70M setsockopt clear attempts = NO panic, guest stayed up.
PoC changes
Authored three PoC variants from scratch (poc dir empty). df2561.c = close-rotator+acceptor+connectors (drives sodealloc->free so_accf vs soisconnected->deref). df2561b.c = aggressive close-rotator with NO acceptor (maximize children in-flight through soisconnected). df2561c.c = directly tests finding's exact premise hammering setsockopt(SO_ACCEPTFILTER) empty/same/different/null filter names racing with connections (proves user syscall path cannot free so_accf: 0 ok / all EINVAL). Fixed struct accept_filter_arg redefinition (already in sys/socket.h).
Verified recommended fix
No code change needed: false-positive. The lwkt_getpooltoken(head) acquired at sys/kern/uipc_socket2.c:244 (held across deref at lines 254-255) mutually excludes soqflush (uipc_socket.c:348) which gates only path that frees so_accf (sodealloc -> do_setopt_accept_filter(so, NULL) at uipc_socket.c:320-321). User-driven setsockopt cannot free so_accf regardless (uipc_socket.c:2026-2028 EINVAL). A NULL-check on head->so_accf at uipc_socket2.c:254 would be cheap defense-in-depth but not a security fix.
Verdict
FALSE POSITIVE. The soisconnected vs do_setopt_accept_filter race described in the finding does not exist, for two independent reasons confirmed by source tracing AND massive empirical stress. (1) A user-driven setsockopt(SO_ACCEPTFILTER) CANNOT clear or change an existing accept filter: do_setopt_accept_filter returns EINVAL at sys/kern/uipc_socket.c:2026-2028 if af != NULL. The kfree(so_accf)/NULL block at lines 2018-2019 runs only when sopt==NULL, an internal call exclusively from sodealloc (uipc_socket.c:320-321). Empirically: 70,520,847 setsockopt clear/change attempts produced 0 successes / 69,896,707 EINVAL. (2) Even the only real free path (listener close -> sofree -> soqflush -> sodealloc -> do_setopt_accept_filter(so,NULL)) is interlocked against soisconnected by the lwkt pool token: soisconnected holds lwkt_getpooltoken(head) (uipc_socket2.c:244) across the deref at lines 254-255, and soqflush (uipc_socket.c:348) acquires same token before sodealloc can run. Empirically: 150K+ listener close rotations interleaved with 14M+ connections (each driving sonewconn->soisconnected->deref head->so_accf) produced NO panic, NO assert, NO corruption. Reviewer correctly noted no NULL check at uipc_socket2.c:254 but missed both the EINVAL guard on user setsockopt path and the pool-token interlock on close path.
No comments yet.