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

connect(2)/listen(2) TOCTOU on one TCP socket: handler-side state guards absent, same inpcb linked into both wildcard and connection hash β€” reliable unprivileged panic (INVARIANTS) / dangling connhash entry + UAF on 4-tuple lookup (production)

Summary

solisten and soconnect guard connect-vs-listen with plain check-then-act flag reads. With the default kern.ipc.soconnect_async=1, SS_ISCONNECTING is stored only inside tcp_usr_preconnect - after soconnect's checks and after a kmalloc - so a concurrent listen(2) passes its check in that window and both serialized pru messages are then processed without re-validation: tcp_usr_listen's only guards are TF_LISTEN and inp_lport==0, and tcp_connect's is inp_faddr!=ANY. One inpcb ends up linked into BOTH the wildcard hash and the connection hash. VERIFIED twice from clean snapshots: any unprivileged user, ~10 lines of pthreads, panics an INVARIANTS kernel within <=45s ('panic: already linked' in in_pcbunlink_flags via tcp_connect). On production kernels the dual-membership pcb is torn down by in_pcb_rmhash which removes only ONE hash membership, leaving a dangling connhash entry to the freed inpcb; subsequent in_pcblookup_hash on the attacker-chosen 4-tuple walks freed memory (UAF), plus the hybrid socket matches both listener and client lookups. user->root route: on non-INVARIANTS kernels groom hash bucket + spray replacement objects over the freed inpcb so a crafted 4-tuple packet traverses a fake inpcb/tcpcb in tcp_input - plausible slab-groom->victim->forge chain (blocked on this INVARIANTS guest where every dual-membership path asserts before corruption; documented). Fix validated in-guest (race-free handler-side guards + listener connect reject + premature SO_ACCEPTCONN undo): identical hammer survived 1.02M iterations/120s on the patched kernel vs <=45s-to-panic baseline.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3373 Β· 13 files
FileTypeDescriptionSize
listen_connect_race.c β€” 3.4 KB view raw
run.log β€” 0 B ↓ download
run_fixed.log β€” 167 B view raw
run_fixed.2.log β€” 168 B view raw
panic.txt β€” 13.3 KB view raw
boot_panic.log β€” 13.3 KB view raw
fix.diff β€” 4.0 KB view raw
env.txt β€” 412 B view raw
VERDICT.md β€” 4.9 KB ↓ raw
README.md β€” 3.7 KB ↓ raw
run.sh β€” 105 B view raw
verdict.json β€” 4.9 KB view raw
manifest.json β€” 1.1 KB view raw

DF-3373 β€” connect(2)/listen(2) TOCTOU links one inpcb into two TCP hashes

Root cause

The socket-layer guards are check-then-act with no token and no handler-side re-check:

  • soconnect() reads SO_ACCEPTCONN (uipc_socket.c:690) and SS_ISCONNECTED|SS_ISCONNECTING (:698) on the caller's thread.
  • solisten() reads SS_ISCONNECTED|SS_ISCONNECTING (uipc_socket.c:332-333) and only then stores SO_ACCEPTCONN (:337) and queues the pru_listen message.
  • With the default kern.ipc.soconnect_async=1, SS_ISCONNECTING is stored by tcp_usr_preconnect (tcp_usrreq.c:970), i.e. after soconnect's checks and after a kmalloc β€” a real window on the connector thread.

Neither tcp_usr_listen (guards only TF_LISTEN and inp_lport == 0) nor tcp_usr_connect/tcp_connect (guards only inp_faddr != ANY) re-checks the opposite state. Both orders of the two serialized pru messages therefore complete and the same inpcb gets linked into both the wildcard hash and the connection hash:

  • listen-msg first: pcb becomes listener (INP_WILDCARD, in_pcb.c:2283); tcp_connect then proceeds β€” reproduced panic at in_pcbunlink_flags ("already linked", in_pcb.c:311) via tcp_connect's migration unlink (tcp_usrreq.c:1235); without INVARIANTS it would reach in_pcbinsconnhash (in_pcb.c:1905) and gain INP_CONNECTED while INP_WILDCARD.
  • connect-msg first: pcb enters SYN_SENT (INP_CONNECTED, in_pcb.c:1906); tcp_usr_listen then migrates/inserts into the wildcard hash (tcp_usrreq.c:461/513, asserts :507 or in_pcb.c:2289).

Production-kernel impact (why this is memory corruption, not just DoS)

in_pcb_rmhash (in_pcb.c:2381-2383) removes only one membership (if (INP_WILDCARD) ... else if (INP_CONNECTED) ...). Teardown of the dual-member pcb leaves the other hash bucket pointing at freed memory; subsequent in_pcblookup_hash walks dereference the freed inpcb (use-after-free on a 4-tuple the attacker controls). Additionally the listening socket is simultaneously a connected 4-tuple, so incoming connection attempts can be matched/accepted against the wrong state.

Reproduction (guest: DragonFly 6.5-DEVELOPMENT #0, INVARIANTS)

listen_connect_race.c: thread A loops {socket(); publish fd; connect(10.0.2.2:1) (nonblocking)}, thread B spins listen(fd, 1). Unprivileged (maxx, uid 1001).

Reproduced twice from clean with-src snapshots, both within ≀ 45 s:

panic: already linked
cpuid = 0
in_pcbunlink_flags() at in_pcbunlink_flags+0x5e
in_pcbunlink_flags() at in_pcbunlink_flags+0x5e
tcp_connect() at tcp_connect+0x56c
netmsg_service_loop() at netmsg_service_loop+0xe9
Debugger("panic")

(panic.txt / boot_panic.log; ssh session dies, vm.sh status β†’ down.)

Fix

fix.diff β€” handler-side, race-free state guards (pru messages are serialized per socket):

  • tcp_usr_listen/tcp6_usr_listen: reject when tp->t_state != TCPS_CLOSED || (so->so_state & (SS_ISCONNECTED|SS_ISCONNECTING)) || inp->inp_fport != 0 || inp->inp_pcbinfo == NULL (last term catches a connect message that is mid-migration between netisrs).
  • tcp_connect/tcp6_connect: reject when TF_LISTEN (covers both the first entry and the PRUC_RECONNECT re-entry after forwarding).

Fix validation

Baseline panic reproduced twice on stock; after applying fix.diff to the guest /usr/src, make nativekernel KERNCONF=X86_64_GENERIC, reboot, and re-running the identical hammer (same duration, longer second run) the guest survived with clean EOPNOTSUPP/EINVAL returns β€” see run_fixed.log, fix_results in verdict.json.

Build / run

cc -O2 -pthread -o listen_connect_race listen_connect_race.c
./listen_connect_race 45        # stock kernel: panics within seconds
VERDICT.md
↓ download raw

DF-3373 VERDICT β€” connect(2)/listen(2) TOCTOU β†’ single inpcb in two TCP hashes

Reproduced? YES β€” kernel panic, twice, unprivileged, ≀45 s per attempt

  • Guest: DragonFly 6.5-DEVELOPMENT #0 (stock INVARIANTS X86_64_GENERIC, Jul 2 2026 build), 6 vCPUs, user maxx (uid 1001).
  • PoC: listen_connect_race.c β€” thread A: fresh socket() + nonblocking connect(10.0.2.2:1) in a loop; thread B: listen(fd,1) spin.
  • Baseline run 1: panic at ~≀45 s (ssh session died; vm.sh status β†’ down). Baseline run 2 (after vm.sh reset with-src, fresh disk): identical panic. Signature both times:
panic: already linked
cpuid = 0
in_pcbunlink_flags() at in_pcbunlink_flags+0x5e
tcp_connect() at tcp_connect+0x56c
netmsg_service_loop() at netmsg_service_loop+0xe9
Debugger("panic")

(panic.txt, boot_panic.log; serial console captured via vm.sh log.)

Mechanism (proven on-guest, matches source)

solisten (uipc_socket.c:332) and soconnect (uipc_socket.c:690,698) guard with plain check-then-act flag reads; with kern.ipc.soconnect_async=1 (default, verified) SS_ISCONNECTING is only stored by tcp_usr_preconnect (tcp_usrreq.c:970) β€” after soconnect's checks and after a kmalloc in so_pru_connect_async (uipc_msg.c:214). A listen(2) whose flag read slips into that window queues a pru_listen message that tcp_usr_listen (guards: only TF_LISTEN and inp_lport == 0, tcp_usrreq.c:481-488) happily processes against the racing connect.

Reproduced interleaving: listen message first β†’ inpcb wildcard-hashed (INP_WILDCARD, in_pcb.c:2290) β†’ tcp_connect's hashport migration in_pcbunlink (tcp_usrreq.c:1235) β†’ KASSERT (inp_flags & (INP_WILDCARD|INP_CONNECTED)) == 0 (in_pcb.c:311) β†’ panic: already linked.

The mirrored interleaving (connect message first) is equally fatal: tcp_usr_listen would hit ASSERT_INP_NOTINHASH (tcp_usrreq.c:507 / in_pcb.c:2289) or in_pcbunlink again.

Impact

  • This INVARIANTS kernel: reliable local unprivileged panic (DoS) β€” hit within ≀45 s of hammering, twice from clean snapshots.
  • Production (non-INVARIANTS) kernels: kernel memory corruption. The same interleaving links one inpcb into BOTH the wildcard hash (via inpcontainers, in_pcb.c:2272-2274) and the connection hash (inp_hash, in_pcb.c:1913), setting INP_WILDCARD|INP_CONNECTED. in_pcb_rmhash (in_pcb.c:2381-2383) removes only ONE membership (wildcard wins), so teardown leaves the connhash bucket holding a pointer to the freed inpcb; any later packet matching that 4-tuple walks it (in_pcblookup_hash) β†’ use-after-free with attacker-chosen tuple. Additionally the hybrid socket serves both listener and client lookups simultaneously. (Not demoable on this INVARIANTS guest: every dual-membership path asserts first β€” documented from source.)

Exploit chain

unpriv user β†’ 2-thread connect/listen race (≀45 s) β†’ inpcb linked into wildcard+conn hashes β†’ INVARIANTS: panic / production: one-sided hash removal at close β†’ dangling connhash entry β†’ in_pcblookup_hash UAF on attacker-chosen 4-tuple (memory-corruption ceiling; not pursued to uid=0 on this INVARIANTS guest).

Fix validation (mandatory for reproduced memcorrupt) β€” FIXED

fix.diff (v3) adds race-free handler-side guards:

  • tcp_usr_listen/tcp6_usr_listen: TF_LISTEN early-out (backlog update) first; then reject when t_state != TCPS_CLOSED || SS_ISCONNECTED|SS_ISCONNECTING || inp_fport != 0 || inp_pcbinfo == NULL (PRUL_RELINK re-entry exempt), and undo solisten's SO_ACCEPTCONN under so_rcv.ssb_token so tcp_input (tcp_input.c:950) and soclose never see a "listener" that isn't one.
  • tcp_connect/tcp6_connect: reject TF_LISTEN with EOPNOTSUPP (covers first entry and PRUC_RECONNECT re-entry) without soisdisconnected() on the listener (children inherit parent state, uipc_socket2.c:383).

Two earlier fix revisions crashed under the same hammer and were diagnosed and corrected (v1: guards but no SO_ACCEPTCONN undo β†’ NULL deref in in_pcbdetach via tcp_input's listener branch; v2: guard ordered before the TF_LISTEN early-out β†’ re-listen cleared SO_ACCEPTCONN on a live listener). v3 survived.

  • Baseline (stock): panic ≀ 45 s, twice β€” panic.txt.
  • Patched kernel #1: Fri Sep 11 17:25:03 UTC 2026 (make nativekernel/installkernel in guest, fix.diff applied to /usr/src): run_fixed.log β€” 377,194 connect iters / 45 s, 140,024 EOPNOTSUPP rejections, 162,885 successful listens, SURVIVED; run_fixed.2.log β€” 1,024,176 iters / 120 s, SURVIVED. ssh (TCP) fully functional throughout.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff v3 applied to guest /usr/src, kernel rebuilt (make nativekernel) and installed: the identical hammer that panicked the stock kernel <=45s survived 377k iterations/45s and 1.02M iterations/120s with clean EOPNOTSUPP/EINVAL returns and functional TCP (ssh). Baseline panic reproduced twice before the patch.

run_fixed.log, run_fixed.2.log (SURVIVED counters), panic.txt (baseline), fix.diff, VERDICT.md fix-validation section
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Sep 11 17:25:03 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unpriv 2-thread connect/listen race (<=45s) -> inpcb linked into wildcard+conn hashes -> INVARIANTS: panic 'already linked' (reproduced); production: in_pcb_rmhash one-sided removal at close -> dangling connhash bucket entry -> in_pcblookup_hash traverses freed inpcb on attacker-chosen 4-tuple -> UAF memory-corruption ceiling (uid=0 chain plausible but blocked on this INVARIANTS guest by the asserts; not pursued further)

Evidence (decisive lines)

["panic.txt / boot_panic.log: 'panic: already linked' backtrace in_pcbunlink_flags <- tcp_connect (both baseline runs)", 'run_fixed.log: 377194 connect iters / 45s on fix-v3 kernel, 140024 EOPNOTSUPP rejections, SURVIVED', 'run_fixed.2.log: 1024176 iters / 120s, SURVIVED', 'fix.diff: 4 handler-side guards (listen v4/v6, connect v4/v6)', 'env.txt: guest config (soconnect_async=1, 6 cpus, INVARIANTS)']

PoC changes

PoC authored for this run (no usable seed): two threads sharing an fd published via atomics; connector loops socket()+nonblocking connect(10.0.2.2:1) with 30us drain; listener spins listen(fd,1); fresh socket per iteration so each attempt gets a clean race; counters distinguish EOPNOTSUPP/EINPROGRESS/listen-ok.

Verified recommended fix

Handler-side race-free guards in tcp_usr_listen/tcp6_usr_listen (reject non-TCPS_CLOSED/connecting/mid-migration and undo solisten's SO_ACCEPTCONN) and tcp_connect/tcp6_connect (reject TF_LISTEN with EOPNOTSUPP) -- see fix.diff.

Verdict

Unprivileged local kernel panic reproduced twice from clean snapshots (<=45s hammer, identical signature: 'panic: already linked' in in_pcbunlink_flags via tcp_connect) caused by a check-then-act TOCTOU between soconnect/solisten socket-layer flag reads and handler-side state: with the default async connect path SS_ISCONNECTING is stored only in tcp_usr_preconnect after soconnect's checks, so a concurrent listen(2) slips through and tcp_usr_listen/tcp_connect process both messages, linking the same inpcb into the wildcard AND connection hashes. On production (non-INVARIANTS) kernels this is memory corruption: in_pcb_rmhash removes only one membership, leaving a dangling connhash entry to the freed inpcb (UAF on attacker-chosen 4-tuple lookups) -- documented from source, not demoable on this INVARIANTS guest since every dual-membership path asserts first. Handler-side fix (fix.diff v3: TF_LISTEN early-out before reject-guard, state guards incl. mid-migration inp_pcbinfo==NULL, SO_ACCEPTCONN undo under so_rcv token, EOPNOTSUPP for connect-on-listener without soisdisconnected) built in-guest (make nativekernel) and validated: 377k iters/45s and 1.02M iters/120s of the identical hammer SURVIVED with ssh/TCP fully functional (vs <=45s-to-panic baseline).