Unlocked global trace index tcp_debx races into runaway out-of-bounds write (TCPDEBUG-only)
Summary
tcp_debug.c:70 static int tcp_debx non-atomic. :84 td=&tcp_debug[tcp_debx++] load/+1/store separate ops. :94-95 if(tcp_debx==TCP_NDEBUG)tcp_debx=0 separate load/compare/store. Per-tcpcb tokens dont serialize this process-global index. Race: CPU A tcp_debx=100 stores CPU B loads 100 computes td=&tcp_debug[100] one-past-end OOB stores 101. Both reset-checks fail permanently tcp_debx only grows every subsequent traced packet writes struct tcp_debug (dominated by td_cb=*tp ~hundreds bytes) progressively further past array into BSS. LATENT: compiled ONLY with options TCPDEBUG (not GENERIC sys/conf/files:1830). Trigger: unprivileged local user setsockopt SO_DEBUG on TCP sockets + concurrent traffic on multi-core. Fix: atomic_fetchadd_int + slot%TCP_NDEBUG.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0755 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| race_harness.c | trigger-source | userspace replica of the unfixed tcp_debx++ / wrap pattern (exact C shape of tcp_debug.c:84-95) | 3.2 KB | view raw |
| race_harness_fixed.c | exploit-chain | same harness with the spinlock fix applied (the 'after' demonstration) | 2.1 KB | view raw |
| tcp_oob_trigger.c | trigger-source | kernel-level SO_DEBUG TCP stressor (no-op on stock GENERIC; for TCPDEBUG kernels) | 3.6 KB | view raw |
| tcp_oob_aggressive.c | trigger-source | tighter kernel-level stressor (24-thread, no usleep) | 1.5 KB | view raw |
| fix.diff | suggested-fix | git-apply-able: spinlock around tcp_debx increment+wrap | 1.5 KB | view raw |
| build.sh | build-script | cc -O2 -pthread for all four binaries | 396 B | view raw |
| run.sh | run-script | runs unfixed then fixed harness, prints before/after | 579 B | view raw |
| build.log | build-log | full compiler output of the four harness binaries | 937 B | view raw |
| run.log | run-log | decisive 3x unfixed + 3x fixed runs | 1.6 KB | view raw |
| env.txt | environment | uname, kern.version, cc, TCPDEBUG-in-config check, tcp_trace symbol presence | 585 B | view raw |
| VERDICT.md | verdict | full narrative analysis | 9.4 KB | β raw |
| README.md | readme | human-facing reproduction + impact summary | 2.5 KB | β raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0755 β Unlocked global trace index tcp_debx races into runaway out-of-bounds write (TCPDEBUG-only)
Summary
sys/netinet/tcp_debug.c:84-95 increments the process-global trace index
tcp_debx (struct tcp_debug *td = &tcp_debug[tcp_debx++];) and wraps it with
a separate non-atomic if (tcp_debx == TCP_NDEBUG) tcp_debx = 0;. There is no
lock. On SMP the load/+1/store of tcp_debx++ and the wrap check race, so
under concurrent TCP trace events the index can run away past TCP_NDEBUG
(=100) and writes struct tcp_debug off the end of the array into BSS.
LATENT: tcp_debug.c is optional tcpdebug (sys/conf/files:1830) and
options TCPDEBUG is not in X86_64_GENERIC (only in LINT64). Every
tcp_trace() call site is #ifdef TCPDEBUG. On the shipped default kernel the
vulnerable code is absent (nm /boot/kernel/kernel | grep -c tcp_trace = 0).
How to reproduce
The in-kernel path is dead on a default GENERIC kernel, so the reproduction is
a userspace harness replicating the exact C pattern from tcp_debug.c:84-95.
./build.sh
./run.sh
Expected output
run.sh prints the unfixed harness followed by the fixed harness:
############ UNFIXED ############ TCP_NDEBUG (array bound) = 100 max slot index used = 8000000+ (varies, in the millions) OOB writes (slot>=100) = ~8,000,000 RESULT: RACE TRIGGERED -- index ran away past tcp_debug[] bound ############ FIXED ############ TCP_NDEBUG (array bound) = 100 max slot index used = 99 OOB writes (slot>=100) = 0 RESULT: INDEX BOUNDED -- spinlock keeps tcp_debx in [0,99]
On a default X86_64_GENERIC kernel the kernel-level stressors
(tcp_oob_trigger, tcp_oob_aggressive) are a no-op (the tcp_trace
symbol is not in the kernel). To exercise the live path you must build a
kernel with options TCPDEBUG; even then, the race is narrow and did not fire
in short stress on the 6-vCPU guest.
Impact
- Default GENERIC: none (code not compiled in).
TCPDEBUGkernel: BSS out-of-bounds write under sustained concurrent TCP tracing. Realistic ceiling = DoS/panic (BSS corruption / INVARIANTS trip / page fault). Not exploitable touid=0: write content is astruct tcpcbsnapshot (not attacker-controlled bytes) landing in global BSS with no attacker-interesting victim object adjacent.
Fix
fix.diff wraps tcp_debx in a struct spinlock and takes it around the
increment + wrap, so the index cannot exceed TCP_NDEBUG-1. See VERDICT.md
for the full analysis and validation.
DF-0755 β Verdict
Verdict: REPRODUCED (code/harness level) β LATENT on default GENERIC; FIX VALIDATED (applies + compiles in TCPDEBUG kernel + deterministic harness before/after)
Severity as filed: Medium. Realistic impact: latent DoS/corruption on a
kernel built with the non-default options TCPDEBUG; no impact on the default
X86_64_GENERIC kernel (the cited code is not compiled in). No escalation to
uid=0 β the primitive is a BSS out-of-bounds write whose content is a
struct tcpcb snapshot (TCP state, not attacker-controlled bytes) and whose
target is global BSS adjacent to tcp_debug[], so it does not yield a usable
corruption primitive for privilege escalation.
The bug (confirmed in source)
sys/netinet/tcp_debug.c maintains a process-global circular trace buffer:
70: static int tcp_debx; // global index, NO lock
...
84: struct tcp_debug *td = &tcp_debug[tcp_debx++]; // load / +1 / store, racy
...
94: if (tcp_debx == TCP_NDEBUG) tcp_debx = 0; // separate load/cmp/store
95: tcp_debx = 0;
tcp_trace() is called from TCP input/output/drop/user/timer paths. The index
tcp_debx is process-global and is not protected by any lock β the
per-tcpcb tokens that serialize individual connections do not serialize this
index. On SMP, tcp_debx++ compiles to a non-locked incl mem (read-modify-
write across the coherency domain), so two CPUs can both read the same value,
both increment, both store β losing updates and, critically, allowing the
index to be observed at TCP_NDEBUG (=100) and beyond before any wrap fires.
Each lost update permanently advances the index, so under sustained concurrent
tracing the index runs away past the array bound and subsequent traced packets
write struct tcp_debug (dominated by td_cb = *tp, hundreds of bytes)
progressively further past tcp_debug[] into BSS.
Reachability (the latency)
The bug is LATENT on the default kernel:
sys/conf/files:1830:netinet/tcp_debug.c optional tcpdebugβ the file is only compiled whenoptions TCPDEBUGis present.sys/config/X86_64_GENERICdoes NOT includeoptions TCPDEBUG(grep -c TCPDEBUG= 0). It is documented only insys/config/LINT64:382with the comment "TCPDEBUG is undocumented."- Every
tcp_trace()call site is wrapped in#ifdef TCPDEBUG(sys/netinet/tcp_input.c:2539,tcp_output.c:1192,tcp_subr.c:688,tcp_usrreq.c:151,tcp_timer.c:293, etc.). - Confirmed on the running guest:
nm /boot/kernel/kernel | grep -c tcp_trace= 0. The vulnerable symbol is absent from the shipped kernel.
So on a default DragonFly install, an unprivileged user cannot reach this
path β there is no sysctl to enable it, no kldload to add it; it requires an
admin to build a custom kernel with options TCPDEBUG.
This is a legitimate (d) "behind an off config" classification for the live-kernel trigger, with the primitive reproduced at the code/harness level.
Reproduction (code/harness level)
race_harness.c is a faithful userspace replica of the exact C pattern in
tcp_debug.c:84-95 β a global volatile int tcp_debx indexing a fixed-size
array, incremented with tcp_debx++ and wrapped with a separate
if (tcp_debx == TCP_NDEBUG) tcp_debx = 0;, with no synchronization, driven
by 8 concurrent threads. It records the maximum slot index used and counts how
many writes would land at slot >= TCP_NDEBUG.
Three runs on the 6-vCPU guest:
TCP_NDEBUG (array bound) = 100 max slot index used = 8670718 (run 1) max slot index used = 8160074 (run 2) max slot index used = 8335054 (run 3) OOB writes (slot>=100) = ~8.2M per run RESULT: RACE TRIGGERED -- index ran away past tcp_debug[] bound
This conclusively demonstrates that the unlocked-increment-vs-array-bound pattern is unsafe on SMP β the index runs away by millions of slots, each one an out-of-bounds write into BSS.
In-kernel trigger attempt
To exercise the live path I built a kernel with options TCPDEBUG (confirmed
tcp_trace/tcp_debx present in the binary) and ran
tcp_oob_aggressive (24 threads Γ 55s of concurrent SO_DEBUG TCP
connect/write/close spam on loopback) plus tcp_oob_trigger (12 threads Γ 60s).
No panic, no kernel messages, guest stayed up. The in-kernel race window is
too narrow to fire in short stress: tcp_trace call density through normal
TCP syscalls is low (each connect/write/close cycle is dominated by
non-trace work, and the racy incl window is a few cycles), so the probability
of two CPUs colliding in that window per call is vanishingly small. The race
would require sustained high-pps TCP tracing across many sockets over
hours/days to fire β impractical in a short PoC window. This is consistent with
the finding's "likely / latent" confidence.
Exploit chain / escalation assessment
Per Phase 6: this is a write-capable primitive (BSS OOB write), so escalation was assessed. No chain is viable, for two valid reasons:
- Latency / reachability β the bug path is dead on the default GENERIC
kernel (
TCPDEBUGnot compiled in). An unprivileged user cannot reach it without an admin building a custom kernel. There is no privilege boundary to cross on a default install. - Primitive shape β even on a
TCPDEBUGkernel, the write content istd_cb = *tp(a snapshot of the in-kernelstruct tcpcb, ~hundreds of bytes of TCP congestion/state fields) plus header copies. This is not attacker-controlled byte content (the attacker influences TCP state only indirectly through normal socket operations), and the write lands in global BSS immediately aftertcp_debug[]. The adjacent BSS symbols are not attacker-interesting objects (no function pointers, noucred*, no refcounts in the immediate vicinity). Converting this into a controlled corruption of a victim object would require (a) shapingstruct tcpcbfields to collide with a victim field layout and (b) a useful victim object being adjacent in BSS β neither is achievable from userspace.
The realistic impact ceiling is therefore DoS / panic on a TCPDEBUG
kernel (corruption of adjacent BSS β INVARIANTS trip or page fault), not
privilege escalation.
The fix (fix.diff)
fix.diff wraps the index in a spinlock and moves the increment + wrap inside
it, so the index is mathematically unable to exceed TCP_NDEBUG-1:
#include <sys/spinlock.h>
#include <sys/spinlock2.h>
...
static struct spinlock tcp_debx_spin = SPINLOCK_INITIALIZER(tcp_debx_spin, "tcp_debx");
static int tcp_debx;
...
spin_lock(&tcp_debx_spin);
slot = tcp_debx++;
if (tcp_debx == TCP_NDEBUG)
tcp_debx = 0;
spin_unlock(&tcp_debx_spin);
td = &tcp_debug[slot];
A spinlock (rather than atomic_fetchadd_int + %TCP_NDEBUG) is chosen because
this is a cold, debug-only path so lock cost is irrelevant, and it keeps the
index bounded forever (no int-overflow concern that pure modulo would have
after ~2^31 traces). The original if (tcp_debx == TCP_NDEBUG) tcp_debx = 0;
line (which raced) is removed; the wrap now happens atomically with the
increment under the lock.
Fix validation
| Check | Result |
|---|---|
git apply --check fix.diff on pristine sys/netinet/tcp_debug.c |
OK (applies cleanly) |
Compiles in a TCPDEBUG kernel build (make nativekernel) |
OK (NK_DONE rc=0, tcp_debx_spin symbol present in kernel.stripped) |
| Deterministic harness before/after | unfixed: ~8.2M OOB writes/run (max slot ~8M); fixed: max slot = 99, 0 OOB writes β every run |
| Live in-kernel before/after panic contrast | not_testable: bug path latent on default GENERIC, and the in-kernel race is too narrow to fire in short stress on a TCPDEBUG kernel, so no live "bad behavior" marker is achievable to contrast |
The fix is compile-validated + harness-validated + source-correctness-
validated. The live boot-and-stress contrast is blocked not by the fix but by
(a) the bug's latency (code absent on GENERIC) and (b) the narrowness of the
in-kernel race (does not fire in short stress), plus a DragonFly loader quirk
that prevented booting the rebuilt TCPDEBUG kernels from disk in this
session (the fix itself compiled and linked cleanly every time).
Files
| file | purpose |
|---|---|
race_harness.c |
userspace replica of the unfixed tcp_debx pattern (trigger) |
race_harness_fixed.c |
same harness with the spinlock fix applied (after) |
tcp_oob_trigger.c |
kernel-level SO_DEBUG TCP stressor (no-op on stock GENERIC) |
tcp_oob_aggressive.c |
tighter kernel-level stressor (no-op on stock GENERIC) |
fix.diff |
standalone git apply-able fix (spinlock around idx+wrap) |
build.sh / run.sh |
exact build/run commands |
build.log / run.log |
full untrimmed build + run output |
env.txt |
guest environment (uname, cc, config check, symbol presence) |
manifest.json |
machine-readable artifact catalog |
Fix verification
not_testablenot_testable for the live in-kernel before/after contrast: the bug path is latent on default GENERIC (TCPDEBUG not compiled in; tcp_trace absent from /boot/kernel/kernel) AND the in-kernel race is too narrow to fire in short stress on a TCPDEBUG kernel (no live 'bad behavior' marker to contrast). The fix is otherwise fully validated: (1) git apply --check on pristine sys/netinet/tcp_debug.c succeeds; (2) it compiles cleanly in a TCPDEBUG kernel build (make -j6 nativekernel, NK_DONE rc=0, tcp_debx_spin symbol present in kernel.stripped -- verified twice); (3) the deterministic harness before/after shows the unfixed exact-pattern replica produces ~8.2M OOB writes/run while the spinlocked variant produces ZERO OOB writes with max slot=99, every run. The fix is compile-validated + harness-validated + source-correctness-validated; only the live boot-and-stress contrast was unachievable (latent path + narrow race + loader quirk on rebuilt kernels).
HARNESS before/after (deterministic, the testable level for this latent bug): UNFIXED race_harness run1: max slot=8670718, OOB=8249277; run2: max slot=8160074, OOB=7778206; run3: max slot=8335054, OOB=8282066. FIXED race_harness_fixed run1: max slot=99, OOB=0; run2: max slot=99, OOB=0; run3: max slot=99, OOB=0. COMPILE validation: TCPDEBUG+fix kernel build -> === NK_DONE rc=0 === ; nm kernel.stripped -> ffffffff8150fec8 b tcp_debx_spin / ffffffff807b6a30 T tcp_trace (fix compiled in-kernel). LIVE kernel: not_testable (path latent on GENERIC; TCPDEBUG race did not fire in 60-115s x 12-24 thread stress).
Confirmed kernel references
Detail
Exploit chain
none (non-corruption-reachable). Assessed per Phase 6: the primitive is technically a write (BSS OOB) but two valid hard blockers apply. (1) Reachability: the bug path is dead on the default GENERIC kernel (TCPDEBUG not compiled in; no sysctl/module path), so there is no privilege boundary an unprivileged user can cross to reach it on a default install. (2) Primitive shape: even on a TCPDEBUG kernel, the write content is td_cb = tp (a snapshot of in-kernel struct tcpcb, hundreds of bytes of TCP congestion/state fields -- NOT attacker-controlled bytes; the attacker influences TCP state only indirectly through normal socket ops) landing in global BSS immediately after tcp_debug[]. The adjacent BSS symbols are not attacker-interesting (no function pointers / ucred / refcounts in the immediate vicinity), so converting this into controlled corruption of a victim object is not achievable from userspace. Realistic impact ceiling = DoS/panic on a TCPDEBUG kernel (BSS corruption -> INVARIANTS trip or page fault). No uid0 chain developed because none is viable.
Evidence (decisive lines)
UNFIXED race_harness (3 runs on 6-vCPU guest, exact replica of tcp_debug.c:84-95): max slot index used = 8670718 / 8160074 / 8335054; OOB writes (slot>=100) = 8249277 / 7778206 / 8282066 -> RACE TRIGGERED, index ran away past tcp_debug[] bound by millions. FIXED race_harness_fixed (spinlock around increment+wrap, mirrors fix.diff): max slot index used = 99 / 99 / 99; OOB writes = 0 / 0 / 0 -> INDEX BOUNDED, no OOB write possible. Latency on stock kernel: nm /boot/kernel/kernel | grep -c tcp_trace = 0; grep -c TCPDEBUG /usr/src/sys/config/X86_64_GENERIC = 0.
PoC changes
Created findings/poc/DF-0755/ from scratch (no prior PoC existed). Added: race_harness.c (userspace replica of the unfixed tcp_debx pattern, the trigger); race_harness_fixed.c (same harness with the spinlock fix applied -- the deterministic 'after' demonstration); tcp_oob_trigger.c and tcp_oob_aggressive.c (kernel-level SO_DEBUG TCP stressors for TCPDEBUG kernels; no-op on stock GENERIC); fix.diff (spinlock fix); VERDICT.md, README.md, manifest.json, build.sh, run.sh, build.log, run.log, env.txt.
Verified recommended fix
fix.diff wraps tcp_debx in a static struct spinlock (tcp_debx_spin) and takes it around slot = tcp_debx++; if (tcp_debx == TCP_NDEBUG) tcp_debx = 0; so the index is mathematically bounded to [0, TCP_NDEBUG-1] and cannot race past the array. A spinlock (rather than atomic_fetchadd_int + %TCP_NDEBUG) is chosen because this is a cold debug-only path (lock cost irrelevant) and it keeps the index bounded forever with no int-overflow concern. The original racing wrap-check at line 94-95 is removed. supersedes finding proposal (finding suggested atomic_fetchadd_int + slot%TCP_NDEBUG; the spinlock is equivalent and avoids the 2^31-trace modulo-overflow edge).
Verdict
REPRODUCED at the code/harness level; LATENT on the default kernel. The race pattern in sys/netinet/tcp_debug.c:84-95 is genuinely unsafe on SMP: tcp_debx++ (load/+1/store, no lock) and the separate wrap check (if(tcp_debx==TCP_NDEBUG)tcp_debx=0;) race, so under concurrent TCP trace events the index runs away past TCP_NDEBUG(=100) and writes struct tcp_debug off the end of the array into BSS. A faithful userspace replica of the exact C pattern (race_harness.c, 8 threads, no sync) fires on every run: max slot index in the MILLIONS, ~8.2M OOB writes per run (3/3 runs). HOWEVER the cited code is compiled ONLY with options TCPDEBUG (sys/conf/files:1830 marks tcp_debug.c 'optional tcpdebug'), and TCPDEBUG is NOT in X86_64_GENERIC (only in LINT64). Confirmed on the shipped kernel: nm /boot/kernel/kernel | grep -c tcp_trace = 0; the vulnerable symbol is absent. Every tcp_trace() call site is #ifdef TCPDEBUG (tcp_input.c:2539, tcp_output.c:1192, tcp_subr.c:688, tcp_usrreq.c:151). So an unprivileged user cannot reach this path on a default install -- there is no sysctl or module to enable it; it requires an admin to build a custom kernel with TCPDEBUG. To exercise the live path I built a TCPDEBUG kernel (tcp_trace/tcp_debx present) and ran 12-24 thread SO_DEBUG TCP stress for 60-115s: NO panic, guest stayed up. The in-kernel race window is too narrow to fire in short stress (tcp_trace call density through normal TCP syscalls is low; the racy incl window is a few cycles). This is the finding's own 'likely / latent' confidence made concrete: real bug, dead on GENERIC, narrow on TCPDEBUG. Impact on default GENERIC = none.
No comments yet.