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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0755 Β· 14 files
FileTypeDescriptionSize
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
README.md readme human-facing reproduction + impact summary
↓ download 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).
  • TCPDEBUG kernel: BSS out-of-bounds write under sustained concurrent TCP tracing. Realistic ceiling = DoS/panic (BSS corruption / INVARIANTS trip / page fault). Not exploitable to uid=0: write content is a struct tcpcb snapshot (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.

VERDICT.md verdict full narrative analysis
↓ download raw

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 when options TCPDEBUG is present.
  • sys/config/X86_64_GENERIC does NOT include options TCPDEBUG (grep -c TCPDEBUG = 0). It is documented only in sys/config/LINT64:382 with 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:

  1. Latency / reachability β€” the bug path is dead on the default GENERIC kernel (TCPDEBUG not 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.
  2. Primitive shape β€” even on a TCPDEBUG kernel, the write content is td_cb = *tp (a snapshot of the in-kernel struct 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 after tcp_debug[]. The adjacent BSS symbols are not attacker-interesting objects (no function pointers, no ucred*, no refcounts in the immediate vicinity). Converting this into a controlled corruption of a victim object would require (a) shaping struct tcpcb fields 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_testable
baseline reproduced→ patch + rebuild →patched clean

not_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).
↓ fix.diffn/a (TCPDEBUG kernels built for validation: 6.5-DEVELOPMENT #1 Thu Jul 9 10:34:51 / 11:11:02 UTC 2026 -- both compiled NK_DONE rc=0 with tcp_debx_spin present, but a DragonFly loader quirk prevented booting the rebuilt kernels from disk in this session; the FIRST plain-TCPDEBUG build did boot to #1)

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.