tcpassembly: a SYN payload lets the attacker choose, byte for byte, the request the analyser reconstructs #3
Labels
No labels
core
cpu-dos
critical
dos
evasion
has-poc
high
integer-overflow
ip4defrag
layers
low
medium
memory-exhaustion
other
panic
pcapgo
pentest-2026-08
rce
tcpassembly
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
noi/gopacket#3
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: high ·
tcpassembly/assembly.go:566-580This is
daisy-findings.mdfinding 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:
Two errors compound:
tcp_conn_requestdoes not queue it.nextSeqis advanced bylen(payload) + 1. On the wire a SYN consumes exactly one sequence number regardless of payload; the server's SYN-ACK acknowledgesISN+1.So the client's real request, correctly sent at
ISN+1, landsNbytes behind where gopacket thinks the stream is.byteSpanthen trims its firstNbytes as already-seen — and thoseNbytes have already been replaced by the attacker's SYN payload.Net effect:
analyser_stream = SYN_payload || real_request[N:], whereN = len(SYN_payload).Reproduction
Choose the SYN payload to be a benign request padded to exactly the length of the real one:
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.mdframes 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 —
!ACKsegments are acceptedtcpassemblynever inspectst.ACK. Linux'stcp_validate_incomingdrops any non-SYN, non-RST segment with ACK clear, so bytes in such a segment never reach the service: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 theconn.nextSeq == invalidSequence && t.SYNbranch: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:Both belong in
assembly_test.gonext to the existing sequence tests.Verified against
b7d9dbdon Go 1.24.4. PoCs:synsub,flowsyn. The same code shape is present inreassembly/— worth checking there too.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, sameconn.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 ✅
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+1model 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:
GET /healthz?q=1'+OR+'1--PF1AA, UAsqlmap/1.8GET /healthz?q=1'+OR+'1--PF1AA✅GET /healthz, UAkube-probe/1.29Scoreboard: 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 forsqlmapdoes 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.lenon 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 asN = len(real_request)rather than a number.Correction 2 — a significant precondition is omitted
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 → RSTvisible on the wire). Four attempts failed outright before adding:Note for reproducers: a
tc/u32 egress filter is not a substitute — it silently drops AF_PACKET-injected frames (wrongnexthdr+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
Two leaks had to be closed first, and both would have been visible to an attentive defender:
ws-,...,M-S-Efrom the hand-built SYN versusws10,...,M-S-T-N-Wfrom the real client. The HTTP layer was perfect; the L4 fingerprint was not. Fixed by copying the real client's TCP option list.flag-outtag and showedsize_bytes=257against the heartbeat's226, 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
!ACKprimitive 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:
So this is a generic Ptacek–Newsham insertion evasion that the C reference analyser shares, not evidence that
tcpassemblyis 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
!ACKnote 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
+1sequence arithmetic and theSYN_payload || real_request[N:]concatenation formula.last_errorstayed 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.PoC
For the
!ACKprimitive, replace the second segment withseg(isn+1, false, false, decoy)followed byseg(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.Live-wire PoC added:
pentest/poc/live/synsub_live.pyOn
pentest/2026-08-poc— ace45e6. 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.The
server acked ISN+1line 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 answersISN+1+len, it negotiated TFO and the whole thing is off.Verified end to end, three ways
Same source port, three independent observers:
GET /flag?probe=FINAL2&n=1UAsqlmap/1.810.0.0.5/flag?probe=FINAL2&n=1GETUAkube-probe/1.29Both 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 islen(real_request)— 95 bytes in the run above, not 71. Too long and you get aSystemExittelling you to shorten the path or UA; too short and it pads inside a trailing header so the result stays a valid request.CLIENT_OPTSexists 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.--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.insertmode is included but labelledIt prints its own caveat on every run:
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
tcpassemblyis 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 itsIP()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.