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

tcp_pcblist marker-dance walks per-cpu inpcb lists from a preemptible user thread with no synchronization β€” DF-3315's non-atomic advance race, here with fully unprivileged churn (UAF read/write on struct inpcb)

Summary

tcp_pcblist advances its list marker with the same non-atomic two-step dance already filed as DF-3315: read-next, LIST_REMOVE(marker), LIST_INSERT_AFTER(inp, marker), executed by a user sysctl thread only migrated onto the pcbinfo's cpu - the comment 'depend on being on the correct cpu to avoid races' is sound only inside the owning netisr. The mutator (tcp_close -> in_pcbdetach -> LIST_REMOVE + kfree) runs in netisr. Unlike DF-3315 (ripcbinfo, privileged raw-socket churn), both sides are FULLY unprivileged here: net.inet.tcp.pcblist is world-readable and every unpriv TCP connect/close on lo0 tears an inpcb out of the walked list. If a teardown preempts the walker mid-dance, LIST_INSERT_AFTER writes the marker pointer into freed M_PCB memory and the walk then reads freed/recycled inpcbs, exporting recycled heap contents to the unprivileged reader. Filed as a distinct instance (different file/function/sysctl/list; an in_pcb.c fix does not fix tcp_subr.c), cross-referenced to DF-3315. NOT triggerable on the current scheduler (3 stress runs up to 3.68M unpriv teardowns at 12k/s intersecting 685k full walks - no fault, matching DF-3315's 6e9-window result): safety rests on scheduler behavior, not synchronization; hardening (Low/speculative). Fix: crit_enter/crit_exit around advance+snapshot (same as DF-3315).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3316 Β· 10 files
FileTypeDescriptionSize
tcp_pcb_race.c β€” 6.3 KB view raw
build.sh β€” 98 B view raw
run.sh β€” 192 B view raw
run.log β€” 661 B view raw
run.2.log β€” 623 B view raw
fix.diff β€” 1.9 KB view raw
env.txt β€” 995 B view raw
VERDICT.md β€” 5.7 KB ↓ raw
verdict.json β€” 5.0 KB view raw
manifest.json β€” 883 B view raw
VERDICT.md
↓ download raw

DF-3316 VERDICT β€” not_reproduced (structural race confirmed by source; practical trigger not achieved, mirroring DF-3315)

Status: not_reproduced. Impact observed: none (guest survived all runs). Confidence: likely (the structural defect is certain from source; triggerability depends on lwkt scheduler preemption behavior).

Source-level proof of the missing synchronization

  1. tcp_pcblist() runs on a user sysctl thread, migrated per-cpu (lwkt_migratecpu, tcp_subr.c:1265). Its own comment (1253-1259) says it "depend[s] on being on the correct cpu to avoid races" β€” sound only for code running inside the owning netisr, not for a preemptible user thread parked on it.
  2. The marker advance is a non-atomic three-step sequence with no crit section: read-next (1271), unlink (1276), relink-after (1277).
  3. The mutator runs in the owning netisr: tcp_close() β†’ in_pcbdetach() (in_pcb.c:1210-1226) β†’ in_pcbremlists() (LIST_REMOVE(inp, inp_list), in_pcb.c:265) β†’ kfree(inp, M_PCB) (in_pcb.c:1225). For TCP this is driven by fully unprivileged churn: every connect/close on lo0 (or any peer) tears an inpcb out of the walked list.
  4. The reader itself is unprivileged: net.inet.tcp.pcblist has no privilege gate (only prison_xinpcb filtering), confirmed by the PoC readers running as nobody.
  5. If a teardown preempts the walker between 1276 and 1277 and removes + frees inp, LIST_INSERT_AFTER writes the marker pointer into freed M_PCB memory; succ->le_prev points into the freed chunk; the walk then reads freed/recycled inpcbs (inp_flags 1279, prison_xinpcb 1281 β†’ derefs inp_socket->so_cred, bcopy(inp), bcopy(inp_ppcb), sotoxsocket 1285-1292) and exports the recycled contents to the unprivileged reader.

This is the same defect pattern already filed as DF-3315 for in_pcblist_range() on ripcbinfo; it is a distinct instance (different file, function, sysctl OID, pcbinfo; a fix in in_pcb.c does not fix tcp_subr.c), with strictly better reachability (no raw sockets needed for churn β€” TCP connect/close suffices, at higher rates).

Attempts (stock INVARIANTS kernel #0, DragonFly 6.5-DEVELOPMENT, 6 cpus)

# setup duration exposure result
1 4 unpriv readers + churner (broken rev: accept queue not drained) 300 s 7.47M dumps (~2.5x10^8 advances), ~260 teardowns no fault
2 4 unpriv readers + churner (128 conns) 300 s 2.05M dumps (~7x10^7 advances), 44,659 teardowns (149/s) no fault
3 2 unpriv readers + churner (16 conns, accept-drain) 300 s 685,481 dumps (~2x10^8 advances incl. per-record steps), 3,684,422 teardowns (12,281/s) no fault

Cumulative: ~9.3M full pcblist walks, ~3.7M inpcb teardowns, all unprivileged. Zero panics, zero dmesg anomalies, all dumped records well-formed, guests healthy after every run.

Why it does not fire (analysis, same as DF-3315)

The teardown work executes in the owning netisr thread and is processed while the walker is blocked or yielding β€” SYSCTL_OUT copyout (2.1 TB moved across the runs) provides constant blocking points, and at those points the marker is linked and the protocol works exactly as designed (removal of the element before the marker correctly re-links the marker). The lwkt scheduler did not preempt the walker user thread inside the ~10 ns advance window often enough (or at all) to intersect one of the 12,281/s removals of the exact element being stepped to. Safety therefore rests on scheduler serialization behavior, not on any synchronization in the code β€” a fragility (any future preemption change, or a pcb teardown path that runs in true interrupt context, re-opens the window), which is why this is filed as a hardening (Low/memcorrupt) finding rather than a demonstrable crash, exactly like DF-3315.

Exploit chain (if it ever fired)

unpriv sysctl net.inet.tcp.pcblist loop + unpriv TCP connect/close storm β†’ marker relinked after freed inpcb β†’ UAF write (marker pointer into freed M_PCB chunk) + UAF read (recycled heap contents exported to user via xt_inp/xt_tp) β†’ list corruption β†’ panic; recycled-chunk contents could include other pcbs' data (info leak). Not demonstrated.

Fix validation

not_testable β€” the baseline never reproduced the fault, so "patched kernel no longer faults" is vacuous (same as DF-3315's fix validation). fix.diff (crit_enter/crit_exit around advance + snapshot; applies cleanly, patch --dry-run -p0 verified) makes the walk atomic by construction instead of by scheduler behavior. Recommend fixing both instances (in_pcb.c via DF-3315's diff, tcp_subr.c via this one).

Known-findings fix status checked during this pass (tcp_subr.c)

  • DF-0336 (tcp6_getcred live so_cred pointer to blocking copyout): unfixed β€” tcp_subr.c:1387 still SYSCTL_OUT(req, inp->inp_socket->so_cred, ...).
  • DF-0337 (tcp_pcblist raw inpcb/tcpcb copyout): unfixed β€” bcopy at 1285/1288 intact.
  • DF-0338 (tcp_mtudisc forged-MTU β†’ corrupted t_maxseg): unfixed β€” no lower bound at 1802-1871; note the v6 blind path (DF-3317) makes it trivially reachable without a seq guess, and mtu=60+TCP_NOOPT yields t_maxseg==0 β†’ the tcp_close:1013 divide-by-zero panic.
  • DF-0339 (tcp_new_isn signed last_offset wrap): unfixed β€” tcp_subr.c:1674/1707/1742 intact.

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

not_testable
baseline no→ patch + rebuild →patched clean

Baseline never faulted (3 runs, best exposure 3.7M teardowns x 685k walks), so 'patched kernel no longer faults' is vacuous - fix_status not_testable, mirroring DF-3315's fix-validation outcome. fix.diff is defense-in-depth making the advance atomic by construction; 'patch --dry-run -p0' verified it applies cleanly to sys/netinet/tcp_subr.c.

fix.diff (applies cleanly; not boot-tested for the above reason)
↓ fix.diffper-fix-DF-3316

Confirmed kernel references

Detail

Exploit chain

(not demonstrated) unpriv 'sysctl net.inet.tcp.pcblist' loop + unpriv TCP connect/close storm -> netisr preempts walker between LIST_REMOVE(marker) and LIST_INSERT_AFTER(inp, marker) -> teardown of inp (in_pcbdetach kfree) -> marker relinked into freed M_PCB chunk (UAF write) -> walk continues over freed/recycled inpcb (bcopy inp/inp_ppcb/sotoxsocket into xtcpcb exported to the unpriv reader = heap info leak; list corruption -> panic).

Evidence (decisive lines)

['run.log (best-exposure run 3): churn 3,684,422 close cycles (12,281/s) as nobody; 2 readers 685k dumps; guest healthy, dmesg clean', 'run.2.log (run 2): 4 readers + 128-conn churn, 44,659 teardowns, 2.05M walks, healthy', 'tcp_pcb_race.c: unprivileged reader (-r) and unprivileged TCP churner (-c)', 'VERDICT.md: structural proof + empirical scheduler analysis (mirrors DF-3315)', 'fix.diff: crit_enter()/crit_exit() around advance + snapshot (same treatment as DF-3315)']

PoC changes

adapted from DF-3315's pcb_race.c: reader switched to net.inet.tcp.pcblist; churner rewritten for unprivileged TCP churn (lo0 listener with nonblocking accept-drain + rotating connect/close, each accepted+closed conn being another netisr inpcb teardown). Initial churner revision stalled on a never-drained accept queue (run 2, 149 teardowns/s); fixed revision reached 12,281 teardowns/s.

Verified recommended fix

wrap the tcp_pcblist marker advance + pcb/tcpcb snapshot in crit_enter()/crit_exit() (fix.diff), making the walk atomic against same-cpu netisr preemption instead of relying on scheduler behavior

Verdict

The tcp_pcblist() marker-dance race exists by construction (source-proven): the sysctl user thread migrated per-cpu advances its marker with a non-atomic remove/relink pair (tcp_subr.c:1276-1277) with no synchronization against same-cpu netisr inpcb teardown (tcp_close -> in_pcbdetach -> LIST_REMOVE + kfree), and unlike DF-3315 (raw pcbinfo) BOTH sides here are fully unprivileged (world-readable sysctl + TCP connect/close churn on lo0). If a teardown preempts the walker inside the window, LIST_INSERT_AFTER relinks the marker after a freed inpcb (UAF write into freed M_PCB memory) and the walk then reads freed/recycled inpcbs and exports them to the unprivileged reader (heap info leak). Three stress runs on the stock INVARIANTS guest (best exposure: 3,684,422 unprivileged inpcb teardowns at 12,281/s intersecting 685,481 full pcblist walks, ~2x10^8 advance windows; cumulative ~3.7M teardowns / ~9.3M walks) produced no panic, no corruption, and no anomalous records - empirically matching DF-3315's finding that the lwkt scheduler does not preempt the walker inside the ~10 ns window; safety rests on scheduler behavior rather than any synchronization in the code. Filed as a hardening (Low/memcorrupt) with the same crit_enter()/crit_exit() treatment DF-3315 recommended for in_pcblist_range; fix.diff applies cleanly to sys/netinet/tcp_subr.c.