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)
PoC verification
Evidence pack
findings/poc/DF-3373 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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()readsSO_ACCEPTCONN(uipc_socket.c:690) andSS_ISCONNECTED|SS_ISCONNECTING(:698) on the caller's thread.solisten()readsSS_ISCONNECTED|SS_ISCONNECTING(uipc_socket.c:332-333) and only then storesSO_ACCEPTCONN(:337) and queues the pru_listen message.- With the default
kern.ipc.soconnect_async=1,SS_ISCONNECTINGis stored bytcp_usr_preconnect(tcp_usrreq.c:970), i.e. aftersoconnect's checks and after akmallocβ 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_connectthen proceeds β reproduced panic atin_pcbunlink_flags("already linked", in_pcb.c:311) viatcp_connect's migration unlink (tcp_usrreq.c:1235); without INVARIANTS it would reachin_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_listenthen 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 whentp->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 whenTF_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
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, usermaxx(uid 1001). - PoC:
listen_connect_race.cβ thread A: freshsocket()+ nonblockingconnect(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 (aftervm.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_LISTENearly-out (backlog update) first; then reject whent_state != TCPS_CLOSED || SS_ISCONNECTED|SS_ISCONNECTING || inp_fport != 0 || inp_pcbinfo == NULL(PRUL_RELINK re-entry exempt), and undosolisten's SO_ACCEPTCONN underso_rcv.ssb_tokensotcp_input(tcp_input.c:950) andsoclosenever see a "listener" that isn't one.tcp_connect/tcp6_connect: rejectTF_LISTENwithEOPNOTSUPP(covers first entry and PRUC_RECONNECT re-entry) withoutsoisdisconnected()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/installkernelin 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
fixedfix.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
Confirmed kernel references
- sys/netinet/tcp_usrreq.c:481-513
- sys/netinet/tcp_usrreq.c:1146-1236
- sys/netinet/tcp_usrreq.c:960-972
- sys/kern/uipc_socket.c:330-343
- sys/kern/uipc_socket.c:684-713
- sys/kern/uipc_msg.c:204-249
- sys/netinet/in_pcb.c:307-321
- sys/netinet/in_pcb.c:1885-1914
- sys/netinet/in_pcb.c:2283-2293
- sys/netinet/in_pcb.c:2367-2395
- sys/netinet/tcp_input.c:950
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).
No comments yet.