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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2561 Β· 11 files
FileTypeDescriptionSize
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
README.md readme reproduce instructions
↓ download 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_data kernel 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.

VERDICT.md verdict full false-positive analysis with path:line
↓ download raw

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:

  1. A user-driven setsockopt(SO_ACCEPTFILTER) cannot clear or change an existing accept filter. do_setopt_accept_filter() returns EINVAL as 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 from setsockopt that reaches the kfree(af) / so->so_accf = NULL block at sys/kern/uipc_socket.c:2018-2019; that block runs only when sopt == NULL, which is an internal call exclusively from sodealloc() (sys/kern/uipc_socket.c:320-321, listener teardown). Empirically confirmed: 70,520,847 setsockopt(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_accf was never freed or modified via the syscall surface the finding names.

  2. Even the only real free path (listener close β†’ sodealloc β†’ do_setopt_accept_filter(so, NULL)) is interlocked against soisconnected by the lwkt pool token. soisconnected holds lwkt_getpooltoken(head) (sys/kern/uipc_socket2.c:244) across the head->so_accf dereference at uipc_socket2.c:254-255. sodealloc (the sole caller of do_setopt_accept_filter(so, NULL)) is reached only from sofree (uipc_socket.c:443), which for a listener calls soqflush(so) (uipc_socket.c:439) first β€” and soqflush acquires that exact same pool token (uipc_socket.c:348: lwkt_getpooltoken(so)) before doing anything. So while soisconnected is dereferencing head->so_accf, the listener's teardown cannot proceed past soqflush, and therefore cannot reach sodealloc β†’ 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:254 does indeed dereference head->so_accf with 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_filter could be driven by a user setsockopt to free so_accf. It cannot: the user path returns EINVAL if a filter already exists (uipc_socket.c:2026-2028). The kfree(so->so_accf) block is reachable only from sodealloc (internal teardown), which the reviewer did not trace.
  • The reviewer did not account for the lwkt_getpooltoken(head) acquired at uipc_socket2.c:244 (held across the deref) mutually excluding soqflush (uipc_socket.c:348) which gates sodealloc.

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.

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_testable
baseline no→ patch + rebuild →patched clean

not_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.
n/a

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.