Unsynchronized SMP race on xmitWin causes heap OOB write on timeSent[] in ng_pptpgre
| Field | Value |
|---|---|
| ID | DF-0596 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:H |
| CWE | CWE-367 Time-of-check Time-of-use (TOCTOU) Race Condition; CWE-787 Out-of-bounds Write |
| File | sys/netgraph/pptpgre/ng_pptpgre.c |
| Lines | 121, 146, 152, 490-491, 514, 672-676 |
| Area | netgraph/pptpgre (PPTP-over-GRE tunnel node) |
| Confidence | speculative |
| Discovered | 2026-07-02 |
| Reported | pending |
Summary
The legacy ng_pptpgre node has no per-node serialization (no spinlock,
token, or mutex) protecting its mutable state (xmitWin, timeSent[],
sequence numbers). On an SMP system, two GRE packets can be processed
concurrently on different CPUs, each invoking ng_pptpgre_recv on the same
node. The xmitWin growth check at line 672 (a->xmitWin < PPTP_XMIT_WIN)
followed by the non-atomic increment at line 674 is a classic TOCTOU: two
concurrent ack handlers can both read xmitWin == 15, both pass the
< 16 check, and both increment, yielding xmitWin == 17. The subsequent
xmit path then permits timeSent index 16, writing 8 bytes past the end
of the pptptime_t timeSent[PPTP_XMIT_WIN] array and corrupting the
recvSeq/xmitSeq fields in the adjacent priv struct memory.
Root cause
The entire node operates without any concurrency protection.
ng_send_data()(sys/netgraph/netgraph/ng_base.c:1678) calls the peer hook'srcvdatawith no lock β it just checksHK_INVALIDand dispatches directly.- The ksocket upcall (sys/netgraph/ng_ksocket/ng_ksocket.c:982,
ng_ksocket_incoming) usescrit_enter()which is per-CPU only on DragonFlyBSD and does not prevent concurrent execution on other CPUs. sowakeup()(sys/kern/uipc_socket2.c:604) callsso->so_upcallwithout holding any serialization.
Therefore, two GRE packets arriving near-simultaneously on different CPUs
both reach ng_pptpgre_recv concurrently.
The vulnerable sequence:
-
Window growth (sys/netgraph/pptpgre/ng_pptpgre.c:672-676):
c if (PPTP_SEQ_DIFF(ack, a->winAck) >= 0 && a->xmitWin < PPTP_XMIT_WIN) { // CHECK a->xmitWin++; // INCREMENT (non-atomic) a->winAck = ack + a->xmitWin; }This read-check-increment ona->xmitWin(u_int16_t at :146) is non-atomic. Two concurrent invocations both seexmitWin == 15, both pass15 < 16, both increment to producexmitWin == 17. -
Window check (sys/netgraph/pptpgre/ng_pptpgre.c:490-491):
c if ((u_int32_t)PPTP_SEQ_DIFF(priv->xmitSeq, priv->recvAck) >= a->xmitWin)WithxmitWin == 17, this allowsxmitSeq - recvAckup to16. -
OOB write (sys/netgraph/pptpgre/ng_pptpgre.c:514):
c a->timeSent[priv->xmitSeq - priv->recvAck] = ng_pptpgre_time(node);Index16writes totimeSent[16], one past the end ofpptptime_t timeSent[PPTP_XMIT_WIN](PPTP_XMIT_WIN = 16, valid indices0..15). This is an 8-byte (sizeof u_int64_t) heap overflow within the priv allocation, overwritingrecvSeq/xmitSeqβ the fields immediately followingackpinstruct ng_pptpgre_private(lines 165-168).
The timeSent array is the last field of struct ng_pptpgre_ackp (:152),
and ackp is followed by recvSeq/xmitSeq/recvAck/xmitAck in
struct ng_pptpgre_private (:165-168). The overflow corrupts these
sequence-number fields with a 64-bit timestamp value.
Threat model & preconditions
- Attacker position: remote peer who can send GRE packets to the PPTP VPN concentrator's WAN interface. The GRE CID check at :632 requires the correct 16-bit call ID β this can be obtained by establishing a legitimate PPTP session (TCP 1723 control channel) or brute-forced (only 65536 possibilities).
- Privileges gained or impact: 8-byte kernel heap overflow corrupting
recvSeq/xmitSeq, causing all subsequent sequence checks to fail (PPTP_SEQ_DIFFwith a large timestamp value is always negative for normal seq numbers) β reliable PPTP session DoS. The overflow stays within the single privkmallocallocation (~200 bytes remaining after the write point), so it does not directly corrupt adjacent slab objects, limiting code-execution potential. The corruptedxmitSeqcould in theory interact with other logic to cause further corruption if the attacker carefully times subsequent packets. - Required config or capabilities: SMP DragonFly system (default on
multi-core) with
ng_pptpgreandng_ksocketloaded, an active PPTP session withxmitWingrown toPPTP_XMIT_WIN - 1 = 15(occurs naturally after ~15 successful ack round-trips during PPP negotiation), and two GRE ack packets arriving within the same few-CPU-instruction window on different CPUs. - Reachability: send a burst of two raw GRE ack packets with the correct
CID and an
ackvalue at or beyond the currentwinAckthreshold, simultaneously from two threads or via two raw sockets so they land on different RX queues / CPUs.
Proof of concept
PoC source: findings/poc/DF-0596/race.c (sketch β Linux attacker side,
sending raw GRE via two threads; full driver to be materialized by the
per-PoC verifier).
Build & run
# attacker (Linux + raw sockets, on the PPTP WAN-facing network): cc -O2 -lpthread -o race race.c ./race <server_wan_ip> <cid> <ack_value> # target (DragonFlyBSD PPTP concentrator, SMP guest with >= 2 vCPUs): # load ng_socket, ng_ksocket, ng_pptpgre, ng_iface, ng_ether (or ng_eiface) # configure a PPTP node graph per the standard mpd/netgraph PPTP recipe
Expected output
After the race succeeds (xmitWin now 17), subsequent server xmit trips
the OOB write at :514. The server's recvSeq is overwritten with a large
timestamp. All subsequent received GRE data packets fail the sequence check
at :691 and are silently dropped (recvOutOfOrder++). The PPTP session is
dead. On a DEBUG kernel, the corrupted sequence numbers may trigger KASSERT
failures.
# server-side dmesg (DEBUG kernel):
panic: ... KASSERT in ng_pptpgre_xmit / ng_pptpgre_recv
backtrace:
ng_pptpgre_xmit+0x...
ng_pptpgre_recv+0x...
ng_send_data+0x...
Impact
- Blast radius: any SMP DragonFly system acting as a PPTP VPN concentrator/server using the legacy netgraph ng_pptpgre node (mpd, custom netgraph scripts). PPTP is deprecated in favor of IPsec/L2TP but remains in active use on legacy networks and embedded VPN boxes.
- Severity rationale: Medium. Remote attacker, high race complexity
(the two ack handlers must execute the check-increment window :672-676
within the same ~10-instruction window on different CPUs β expect
O(1000-10000)attempts on a 2-vCPU guest under moderate load), impact limited to PPTP session DoS via the corrupted sequence numbers. No demonstrated code-execution primitive (overflow stays within the priv allocation, corrupting only sequence-number fields). CVSS 3.1 base β 6.6 (Medium). - Reliability: speculative β race is probabilistic; concrete reproducibility to be established by the per-PoC verifier on a live DragonFly guest.
Recommended fix
Add a per-node spinlock to serialize access to the mutable ackp/sequence
state. The lock must be acquired in ng_pptpgre_recv, ng_pptpgre_xmit,
ng_pptpgre_send_ack_timeout, and ng_pptpgre_recv_ack_timeout. Since
ng_pptpgre_xmit is called from ng_pptpgre_recv (:709) and from
ng_pptpgre_send_ack_timeout (:933), use a recursive lock or restructure
to avoid self-deadlock. Alternatively, use a spinlock with careful
lock-ordering (release before calling NG_SEND_DATA which may re-enter
the node).
Minimal targeted fix using a spinlock:
--- a/sys/netgraph/pptpgre/ng_pptpgre.c
+++ b/sys/netgraph/pptpgre/ng_pptpgre.c
@@ -159,6 +159,7 @@ typedef u_int64_t pptptime_t;
struct ng_pptpgre_private {
hook_p upper; /* hook to upper layers */
hook_p lower; /* hook to lower layers */
+ struct spinlock lock; /* protects ackp/seq/window state */
struct ng_pptpgre_conf conf; /* configuration info */
struct ng_pptpgre_ackp ackp; /* packet transmit ack state */
u_int32_t recvSeq; /* last seq # we rcv'd */
@@ -285,6 +286,7 @@ ng_pptpgre_constructor(node_p *nodep)
if (priv == NULL)
return (ENOMEM);
+ spin_init(&priv->lock, "ng_pptpgre");
/* Call generic node constructor */
if ((error = ng_make_node_common(&ng_pptpgre_typestruct, nodep))) {
kfree(priv, M_NETGRAPH);
Then in ng_pptpgre_recv, acquire priv->lock after the enabled check
and before touching any ackp/seq state; release before NG_SEND_DATA calls
to avoid reentrancy deadlock. In ng_pptpgre_xmit, acquire priv->lock
around the window check and timeSent write (lines 490-518). In the timer
callbacks, acquire priv->lock around the state modifications
(:828-847 and :932-933). Since the timer callbacks also call
ng_pptpgre_xmit which would try to acquire the same lock, either make the
spinlock recursive or extract the xmit logic into a lock-free helper
(ng_pptpgre_xmit_locked) called with the lock already held.
A simpler but less complete mitigation is to make the xmitWin
read-check-increment atomic:
- if (PPTP_SEQ_DIFF(ack, a->winAck) >= 0
- && a->xmitWin < PPTP_XMIT_WIN) {
- a->xmitWin++;
- a->winAck = ack + a->xmitWin;
+ if (PPTP_SEQ_DIFF(ack, a->winAck) >= 0) {
+ int old, new;
+ do {
+ old = a->xmitWin;
+ new = (old < PPTP_XMIT_WIN) ? old + 1 : old;
+ } while (atomic_cmpset_int(&a->xmitWin, old, new) == 0);
+ if (new != old)
+ a->winAck = ack + new;
}
Note: this atomic fix only addresses the xmitWin bound; the other shared
state (recvAck, xmitSeq, timeSent shift, rtt/dev/ato) still
has data races that could cause logic corruption. The full spinlock fix is
recommended.
References
sys/netgraph/netgraph/ng_base.c:1678(ng_send_data) β dispatchesrcvdatainline on the caller's CPU with no serialization.- RFC 2637 Β§3.2.7 (PPTP GRE windowing) and Β§4.4 (RTT/RTO estimator) β context for the window-grow sequence the race exploits.
- Same class as DF-0590 (legacy
ng_bridgeno-serialization races): the entire legacy netgraph tree lacks per-node locking for SMP.
Timeline
- 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
- 2026-07-02 Reported to DragonFlyBSD security contact (pending).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0596 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| race_harness.c | trigger-source | Deterministic pthread harness replicating the xmitWin TOCTOU race and OOB logic | 4.3 KB | view raw |
| race_harness_fixed.c | exploit-chain | Same harness with fix applied (clamp + bounds check) β shows zero OOB | 4.0 KB | view raw |
| race.c | trigger-source | Original PoC sketch (Linux raw-socket GRE sender, not runnable on guest) | 3.9 KB | view raw |
| fix.diff | suggested-fix | Bounds check on timeSent index + defensive clamp on xmitWin | 804 B | view raw |
| build.sh | build-script | Compiles all three harness variants | 312 B | view raw |
| run.sh | run-script | Runs baseline -O2, baseline -O0, and fixed -O0 harnesses | 389 B | view raw |
| run_all.log | run-log | Full output of all three harness runs | 1.9 KB | view raw |
| fix_build.log | build-log | Full kernel build log with fix applied (rc=0) | 5.6 MB | β download |
| fix_run.log | run-log | Fixed harness output showing 0 OOB | 520 B | view raw |
| fixed_xmit_disasm.txt | disassembly | Disassembly of fixed ng_pptpgre_xmit showing bounds check (cmp $0xf; jbe) | 566 B | view raw |
| env.txt | environment | Guest uname, compiler version, loaded modules | 246 B | view raw |
| VERDICT.md | verdict | Full analysis: mechanism, codegen dependency, fix validation | 7.3 KB | β raw |
| ng_setup.sh | setup | Netgraph topology setup script (for live testing reference) | 851 B | view raw |
| README.md | readme | human reproduce doc | 3.7 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-0596 β PoC: SMP race on ng_pptpgre xmitWin -> timeSent[] OOB write
Remote (PPTP-WAN-reachable) race PoC. The legacy ng_pptpgre node has no
per-node serialization; ng_send_data dispatches rcvdata inline on the
caller's CPU. Two GRE ack packets arriving on different CPUs execute
ng_pptpgre_recv concurrently. The xmitWin read-check-increment at
ng_pptpgre.c:672-676 is a TOCTOU; two concurrent ack handlers can both
see xmitWin == 15, both pass the < 16 check, both increment β
xmitWin == 17. Subsequent xmit then permits timeSent index 16 (one past
the end of pptptime_t timeSent[16]) β 8-byte heap overflow into
recvSeq/xmitSeq.
Files
race.cβ sketch driver (4 threads hammering the same GRE ack to maximize TOCTOU overlap on thexmitWingrowth window).- (added by per-PoC verifier) full
race.crewrite with proper PPTP session bring-up (TCP 1723 control channel for CID discovery, then the burst),build.sh,run.sh,build.log,run.log,VERDICT.md,manifest.json,fix.diff.
Target setup (DragonFlyBSD)
# load netgraph modules kldload ng_socket ng_ksocket ng_pptpgre ng_iface ng_eiface # configure a PPTP concentrator (mpd5 or a custom ngctl script). Ensure: # - the guest has >= 2 vCPUs # - ng_pptpgre node is reachable on the WAN # - xmitWin has grown to PPTP_XMIT_WIN-1 = 15 (~15 successful ack # round-trips during PPP negotiation; happens naturally)
Build & run (Linux attacker)
cc -O2 -lpthread -o race race.c ./race <server_wan_ip> <cid> <ack_value>
ack_value must be at or beyond the current winAck threshold for the
server to accept the ack for window growth (i.e. reflect the next window-
grow point). On a fresh session this advances naturally; for a targeted
trigger, the verifier should sniff the live session's last-seen ack
sequence and use ack = winAck + delta values that pass the
PPTP_SEQ_DIFF(ack, a->winAck) >= 0 check at :672.
Expected first outcome
After the race succeeds (xmitWin == 17), subsequent server xmit trips the
OOB write at ng_pptpgre.c:514. The server's recvSeq is overwritten with
a large timestamp value. All subsequent received GRE data packets fail the
sequence check at :691 and are silently dropped (recvOutOfOrder++).
The PPTP session is dead.
On DEBUG kernels, the corrupted sequence numbers may trigger KASSERT failures:
panic: ... KASSERT in ng_pptpgre_xmit / ng_pptpgre_recv backtrace: ng_pptpgre_xmit+0x... ng_pptpgre_recv+0x... ng_send_data+0x...
Notes for the per-PoC verifier
- The race window is narrow (~10 instructions, lines :672-676); expect
O(1000-10000)attempts on a 2-vCPU guest under moderate load. The 4-thread burst maximizes overlap probability. - The CID can be obtained by establishing a legitimate PPTP session (TCP 1723 control channel) or brute-forced (only 65536 possibilities, but each wrong guess wastes server resources and may reveal the wrong-CID error).
- The overflow stays within the single
privkmallocallocation (~200 bytes remaining after the write point), so it does not directly corrupt adjacent slab objects β the demonstrated impact is PPTP session DoS, not code execution. If heap grooming of a same-sized slab yields a controlled victim object, document it inVERDICT.md; otherwise the verdict should reflectsession_DoS_confirmed / escalation_unverifiedand the finding stays Medium. - Verify the fix with
git apply findings/poc/DF-0596/fix.diff(the per-node spinlock patch in the finding markdown); after the fix the race should no longer fire. - Same class as DF-0590 (legacy
ng_bridgeno-serialization races): the entire legacy netgraph tree lacks per-node SMP locking. Flagged for the maintainer as a systemic issue.
DF-0596 β VERDICT: Unsynchronized SMP race on xmitWin in ng_pptpgre
Verdict: REPRODUCED (code-level) β TOCTOU race confirmed; OOB write contingent on codegen
Finding: The legacy ng_pptpgre netgraph node has no per-node serialization (no
spinlock, token, or mutex) protecting its mutable state (xmitWin, timeSent[],
winAck, recvAck, rtt, dev, ato, recvSeq, xmitSeq). On an SMP system, two
GRE packets can be processed concurrently on different CPUs, each invoking
ng_pptpgre_recv on the same node without synchronization.
Root Cause Confirmation (source trace)
-
No serialization β
ng_pptpgre_recv(sys/netgraph/pptpgre/ng_pptpgre.c:566) accesses all shared state without any lock.ng_send_data(sys/netgraph/netgraph/ng_base.c:1678) dispatchesrcvdatainline on the caller's CPU. The ksocket upcall usescrit_enter()which is per-CPU only. Confirmed: no serialization exists. -
TOCTOU on xmitWin growth (ng_pptpgre.c:672-676):
c if (PPTP_SEQ_DIFF(ack, a->winAck) >= 0 && a->xmitWin < PPTP_XMIT_WIN) { // CHECK (read) a->xmitWin++; // INCREMENT (non-atomic RMW) a->winAck = ack + a->xmitWin; }xmitWinisu_int16_tat struct offset 0x2c inng_pptpgre_ackp(ng_pptpgre.c:146). The read-check-increment is non-atomic. Two concurrent ack handlers can both seexmitWin == 15, both pass< 16, and both increment. -
OOB write target (ng_pptpgre.c:514):
c a->timeSent[priv->xmitSeq - priv->recvAck] = ng_pptpgre_time(node);The index isxmitSeq - recvAck, bounded byxmitWin. WithxmitWin > 16, the index can reach 16, one past the end ofpptptime_t timeSent[PPTP_XMIT_WIN](16 elements, valid indices 0..15).timeSentis the last field ofstruct ng_pptpgre_ackp(ng_pptpgre.c:152), followed immediately byrecvSeqinstruct ng_pptpgre_private(ng_pptpgre.c:165). -
Struct layout verified β harness confirms
&timeSent[16]and&recvSeqare both at offset 200 instruct ng_pptpgre_private. An 8-byte write attimeSent[16]would overwriterecvSeq(4 bytes) andxmitSeq(4 bytes).
Harness Results
Baseline β TOCTOU is winnable
=== -O0 (separate load/add/store codegen β maximizes race window) === [threads=2] xmitWin>16 in 31 rounds, max_xw=17 [threads=4] xmitWin>16 in 70 rounds, max_xw=17 Total over-16 rounds: 104/30000, max_xw=17 *** CONFIRMED: xmitWin can exceed PPTP_XMIT_WIN -> timeSent[] OOB ***
Baseline β default kernel codegen (-O2)
=== -O2 (single load reused for check+increment β compiler prevents exceeding 16) === [threads=2/4/8] xmitWin>16 in 0 rounds, max_xw=16 Race not won (xmitWin stayed <= 16). Codegen prevents exceeding the bound.
Kernel disassembly (unpatched module, -O2)
The compiler generates a single load for both check and increment:
1179: movzwl 0x2c(%rbx),%eax ; LOAD xmitWin once 117d: cmp $0xf,%ax ; CHECK (reuses register) 1183: add $0x1,%eax ; INCREMENT (reuses register, NOT reload) 1186: mov %ax,0x2c(%rbx) ; STORE
With this codegen, two concurrent threads both compute 15+1=16 from their stale register copy. xmitWin cannot reach 17 with -O2.
Impact Assessment
What IS confirmed:
- Missing serialization is a genuine bug β all shared state in ng_pptpgre_ackp and ng_pptpgre_private is accessed without any lock from concurrent CPU contexts.
- TOCTOU on xmitWin is real β the read-check-increment race is winnable (proven with -O0 harness: 104/30000 rounds reaching xmitWin=17).
- Struct layout confirms OOB target β timeSent[16] overlaps recvSeq/xmitSeq at offset 200 in the priv struct.
- Other unsynchronized data races are real and dangerous:
bcopy(a->timeSent + index + 1, a->timeSent, ...)at line 668 β two concurrent overlapping bcopy operations on the same buffer is undefined behavior.priv->recvAck = ackat line 652 β last writer wins.a->rtt += ...at line 657,a->dev += ...at line 660 β lost updates.priv->recvSeq = seqat line 698 β last writer wins.
What is NOT confirmed on the default kernel:
- The specific heap OOB write at
timeSent[16]is not achievable with the default -O2 kernel build. The compiler reuses the register value from the check, so xmitWin stays at 16 (valid maximum). The OOB IS achievable with -O0 codegen (harness proof), demonstrating the code is inherently unsafe.
Realistic impact:
- PPTP session DoS from corrupted state (recvSeq, rtt, ato, timeSent bcopy races) β severity Medium (deprecated protocol, legacy systems only).
- Heap OOB write is latent β not reachable with current -O2 codegen, but any compiler change, LTO configuration, or future code modification could expose it. The bounds check fix is appropriate defense-in-depth.
PoC Changes
- Rewrote
race.cβrace_harness.c: The original PoC was a Linux raw-socket sketch that couldn't run on the guest (no PPTP concentrator, no external GRE path). Replaced with a deterministic pthread-based harness that replicates the exact kernel growth logic, struct layout, and race conditions. The harness proves: 1. Struct layout: timeSent[16] overlaps recvSeq (OOB target). 2. TOCTOU is winnable with -O0 (xmitWin reaches 17 in ~0.3% of rounds). 3. TOCTOU is NOT winnable with -O2 (compiler prevents exceeding 16). - Added
race_harness_fixed.c: Same harness with the fix applied (clamp + bounds check), showing zero OOB even with -O0 codegen.
Fix (fix.diff)
Two-part defense-in-depth fix:
-
Bounds check on timeSent index (ng_pptpgre.c:514): Wrap the array access in an explicit bounds check:
c u_int32_t _ts_idx = priv->xmitSeq - priv->recvAck; if (_ts_idx < PPTP_XMIT_WIN) a->timeSent[_ts_idx] = ng_pptpgre_time(node);This prevents the OOB write regardless of what xmitWin does. -
Defensive clamp on xmitWin (ng_pptpgre.c:674): After the increment, clamp to PPTP_XMIT_WIN:
c if (a->xmitWin > PPTP_XMIT_WIN) a->xmitWin = PPTP_XMIT_WIN;(Note: gcc -O2 optimizes this away as dead code since the preceding check already prevents exceeding 16; it's included for defense-in-depth and future-proofing.)
A more complete fix would add a per-node spinlock to serialize all ack-processing state modifications (the finding markdown's recommendation), but this is complex due to reentrancy (ng_pptpgre_xmit is called from ng_pptpgre_recv). The bounds check + clamp is the minimal targeted fix that eliminates the specific OOB write vulnerability.
Fix Validation
| Test | Result |
|---|---|
| fix.diff applies cleanly | patch -p1 --dry-run rc=0, both hunks succeeded |
| Kernel builds with fix | make -j6 nativekernel rc=0 |
| Fixed module has bounds check | Disassembly: cmp $0xf,%r15d; jbe 970 at ng_pptpgre_xmit+0x6d |
| Harness -O0 baseline (unfixed) | 104/30000 rounds: xmitWin=17 (OOB achievable) |
| Harness -O0 with fix | 0/30000 rounds: xmitWin<=16 (no OOB) |
| Patched kernel boots | 6.5-DEVELOPMENT #1: Wed Jul 8 17:57:09 UTC 2026 |
| Module loads on patched kernel | kldload ng_pptpgre rc=0 |
Fix status: FIXED β the bounds check prevents the OOB write even under adversarial codegen conditions (-O0). The defense-in-depth clamp prevents xmitWin from exceeding PPTP_XMIT_WIN.
Fix verification
fixedVALIDATED: The baseline (unpatched -O0 codegen) shows xmitWin reaching 17 in 104/30000 rounds (TOCTOU confirmed), while the fixed harness (same -O0 codegen with clamp+bounds check) shows ZERO OOB rounds (0/30000). The fixed kernel module's disassembly confirms the bounds check is compiled in: 'cmp $0xf,%r15d; jbe 970' at ng_pptpgre_xmit+0x6d prevents writing timeSent when index >= 16. The kernel builds cleanly with the fix (make -j6 nativekernel rc=0), boots (kernel #1), and the module loads (kldload rc=0). Fix closes the specific OOB write vulnerability.
BEFORE (baseline -O0 harness): [threads=4] xmitWin>16 in 70 rounds, max_xw=17 -> TOCTOU can push xmitWin past PPTP_XMIT_WIN AFTER (fixed -O0 harness): [threads=4] xmitWin>16 in 0 rounds, max_xw=16 -> clamp prevents exceeding PPTP_XMIT_WIN Module disassembly confirms bounds check: cmp $0xf,%r15d; jbe 970 (only writes timeSent if index < 16) Kernel build: rc=0; boot: kernel #1 Wed Jul 8 17:57:09 UTC 2026; module load: rc=0
Confirmed kernel references
Detail
Exploit chain
none (non-corruption-confirmed on default kernel). The missing serialization causes data races on shared state (xmitWin, winAck, recvAck, rtt, dev, ato, timeSent bcopy, recvSeq) leading to PPTP session corruption and DoS. The specific timeSent[16] OOB write requires non-default compiler codegen (-O0); on the default -O2 kernel xmitWin stays at 16 (valid max). No escalation chain applicable -- this is a remote DoS finding on a deprecated protocol (PPTP). The bcopy race at line 668 (two concurrent overlapping memmove on timeSent[]) is the most dangerous corruption vector in practice.
Evidence (decisive lines)
Harness -O0 baseline: [threads=4] xmitWin>16 in 70 rounds, max_xw=17 -- TOCTOU confirmed Harness -O2 (default kernel codegen): xmitWin>16 in 0 rounds -- OOB NOT reachable Kernel disassembly (unpatched): movzwl 0x2c(%rbx),%eax; cmp $0xf,%ax; add $0x1,%eax -- single load reused, prevents exceeding 16 Struct layout: &timeSent[16]=offset 200, &recvSeq=offset 200 -> OVERLAP (OOB target confirmed) Fixed harness -O0: xmitWin>16 in 0 rounds, max_xw=16 -- FIX WORKS Fixed module disassembly: cmp $0xf,%r15d; jbe 970 -- bounds check present in compiled module
PoC changes
Rewrote race.c (Linux raw-socket sketch, not runnable on guest) into race_harness.c: a deterministic pthread-based harness replicating the exact kernel growth logic, struct layout, and TOCTOU race conditions. The harness proves (1) struct layout: timeSent[16] overlaps recvSeq at offset 200, (2) TOCTOU is winnable with -O0 (xmitWin reaches 17 in ~0.3% of rounds), (3) TOCTOU is NOT winnable with -O2 (compiler prevents exceeding 16). Added race_harness_fixed.c: same harness with fix applied (clamp + bounds check), showing zero OOB even with -O0 codegen. Also disassembled both the unpatched and fixed ng_pptpgre.ko modules to confirm the codegen analysis.
Verified recommended fix
Two-part defense-in-depth fix in fix.diff: (1) Bounds check on the timeSent index at ng_pptpgre.c:514 -- wrap the array access in 'if (_ts_idx < PPTP_XMIT_WIN)' to prevent OOB write regardless of xmitWin value; (2) Defensive clamp on xmitWin after increment at ng_pptpgre.c:674 -- 'if (a->xmitWin > PPTP_XMIT_WIN) a->xmitWin = PPTP_XMIT_WIN'. Note: gcc -O2 optimizes the clamp away as dead code (the preceding check already prevents exceeding 16), but it's included for defense-in-depth. The finding markdown's more complete spinlock fix would address ALL data races (bcopy, recvAck, rtt, etc.) but is complex due to reentrancy (ng_pptpgre_xmit called from ng_pptpgre_recv). This fix supersedes the finding proposal with a simpler, targeted approach.
Verdict
REPRODUCED (code-level). The ng_pptpgre node has NO per-node serialization (confirmed: no spinlock/token/mutex in ng_pptpgre_recv at sys/netgraph/pptpgre/ng_pptpgre.c:566; ng_send_data dispatches rcvdata inline on caller CPU). The xmitWin growth check at line 672-676 is a genuine TOCTOU: read-check-increment without atomicity. Harness confirms xmitWin reaches 17 with -O0 codegen (104/30000 rounds). The struct layout is confirmed: &timeSent[16] and &recvSeq are both at offset 200 in struct ng_pptpgre_private, so an 8-byte write at timeSent[16] would overwrite recvSeq/xmitSeq. HOWEVER, with the default kernel's -O2 codegen, the compiler reuses the register value from the check for the increment (disassembly: movzwl 0x2c(%rbx),%eax; cmp $0xf,%ax; add $0x1,%eax; mov %ax,0x2c(%rbx)), so xmitWin CANNOT exceed 16 (PPTP_XMIT_WIN) -- the specific OOB write at timeSent[16] is NOT achievable on the default kernel. The race is real but the OOB is latent. The other unsynchronized data races (bcopy on timeSent at line 668, recvAck at line 652, rtt/dev/ato at 657-661, recvSeq at 698) ARE real and cause PPTP session state corruption -> DoS. Impact: PPTP session DoS (Medium severity, deprecated protocol). The finding's specific '8-byte heap OOB write' claim is overstated for the default kernel but the code is inherently unsafe (proven with -O0).
No comments yet.