This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Attack-surface assessment of gopacket at
b7d9dbd, August 2026. Every finding below was reproduced with a runnable program on Go 1.24.4, linux/amd64, 4 cores / 8 GB. PoCs: branchpentest/2026-08-poc.
Threat model
A rival team on the game network can send arbitrary bytes. tcpdump captures them, gopacket parses them. The attacker never touches the analyser's API, config, host or the operator's shell.
Two consequences of that shape drive most of what follows:
- The packets do not have to be accepted by anything. A fragment addressed to an unused IP, a TCP segment that no host will ever ACK, a UDP datagram to a closed port — all are captured and all are parsed. "A real stack would drop this" is not a mitigation.
- The capture is lossy in a specific way. Snaplen truncation and short frames mean a declared length field routinely disagrees with the bytes actually present. Any code that guards on one and indexes the other is a crash primitive.
Findings
| # | Finding | Severity | Cost to attacker |
|---|---|---|---|
| #1 | ip4defrag panic — two independent root causes |
critical | 3 fragments, 254-byte pcap |
| #13 | DNS name-decompression bomb | high | 1.4 MB → 8 GB |
| #3 | SYN payload rewrites the reconstructed request | high | 71 bytes on a packet already being sent |
| #4 | Quadratic tcpassembly insertion, unbounded buffering |
high | ~1.2 Mbit/s per core |
| #2 | Overlapping fragments never reassemble | high | 1 packet |
| #8 | Geneve: two off-by-one guards | high | 7 bytes |
| #9 | Geneve: uint8 overflow shifts the inner frame 256 B |
high | 1 packet |
| #11 | 26 layer decoders index past their own guard | medium | 15–66 byte frame |
| #12 | No layer-nesting cap; Dump() amplifies 744× |
medium | ~8 Mbit/s per core |
| #5 | FastHash merges four 4-tuples into one key |
medium | 1 extra connection |
| #6 | Reassembled IPv4 Length lies about its payload |
medium | 2 fragments |
| #7 | "Fragment will overrun" check is vacuous (uint16) |
medium | latent |
| #10 | pcapgo: 40-byte file → 1 GB alloc; snoop panics | medium | needs an untrusted pcap |
Status of the four findings inherited from daisy-findings.md
| daisy # | Verdict |
|---|---|
| 2 — overlapping-fragment panic | Confirmed, and worse. Minimised from 4 fragments / 2.2 KB to 3 fragments / 138 wire bytes. A second, independent root cause needs no truncation at all. The fix proposed in that document does not stop it — see below. → #1 |
| 4 — quadratic reassembly | Confirmed. 4.35 MB of descending-sequence segments = 35.9 s of CPU, 780× the in-order cost, with 28× memory amplification. → #4 |
8 — connKey collision |
Confirmed, and broader. Because both halves of netFlow.FastHash() ^ tcpFlow.FastHash() are commutative, it collapses four distinct 4-tuples, not two. → #5 |
| 9 — SYN-payload desync | Confirmed, and materially worse than described. Not a truncation that breaks HTTP parsing — a byte-for-byte substitution that lets the attacker choose a complete, well-formed, benign request for the analyser to record. → #3 |
⚠ The proposed fix for finding 2 is insufficient
daisy-findings.md proposes, before handing a fragment to ip4defrag:
if want := int(ip.Length) - int(ip.IHL)*4; want > 0 && len(ip.Payload) < want {
return nil, false
}
That guard is necessary but not sufficient. There is a second root cause: fragmentList.insert() computes the fragment size as in.Length - 20 (hardcoded) while securityChecks() uses ip.Length - IHL*4. For any fragment carrying IP options these disagree, and the payload is exactly its declared length, so the guard passes:
frag1 IHL=5 Length=44 want=24 len(Payload)=24 -> proposed fix ACCEPTS
frag2 IHL=5 Length=28 want=8 len(Payload)=8 -> proposed fix ACCEPTS
frag3 IHL=15 Length=68 want=8 len(Payload)=8 -> proposed fix ACCEPTS
panic: runtime error: slice bounds out of range [16:8]
Fix insert() to use in.Length - uint16(in.IHL)*4, and bound the splice in build() by len(frag.Payload) rather than by a wire field. Details in #1.
Where the panic recovery actually is
Worth being precise, because it separates the critical findings from the medium ones.
| Path | Recovers? | Opt-out |
|---|---|---|
gopacket.NewPacket |
yes, by default | DecodeOptions{SkipDecodeRecovery: true} |
DecodingLayerParser.DecodeLayers |
yes, by default (parser.go:304) |
DecodingLayerParserOptions{IgnorePanic: true} |
ip4defrag |
no recovery anywhere | — |
tcpassembly / reassembly |
no recovery anywhere | — |
pcapgo readers |
no recovery anywhere | — |
So the layer-decoder panics (#8, #11) are hard crashes only for callers who opted out of recovery for performance — which gopacket's own documentation encourages ("Handling errors does add latency"). The ip4defrag panic (#1) is a hard crash for everyone, which is why it is the only critical.
A caller must additionally place its own recover around the reader and the defragmenter, not just around the decode — a guard that wraps only DecodeLayers / NewPacket does not cover the path that actually panics.
What was checked and found sound
Negative results, so the next pass does not repeat them:
- TLV and option loops are well guarded. A structured sweep over TCP options (DataOffset 6/7/10/15), IPv4 options (IHL 6/7/10/15), IPv6 hop-by-hop TLVs, DHCPv4, DHCPv6, LLDP, TLS records, SIP headers, NTP, RADIUS and MLDv2 — with zero-length options, lengths past the end, and repeated malformed kinds — produced no panics and no non-terminating loops. The bugs are concentrated in fixed-header decoders, not in the loops.
- DNS pointer-chase depth is capped at
maxRecursionLevel = 255and self-referential loops are caught cleanly. The bomb in #13 works by staying under the cap and multiplying across records, not by defeating it. pcapgo/read.godoes boundCaptureLength— the problem in #10 is that it bounds it against two other fields from the same file, not that the check is missing.- IPv4's own
DecodeFromBytesis careful: it rejectsLength < 20,IHL < 5andIHL*4 > Length, and setsSetTruncated()correctly. The defrag bugs are downstream of a correct decode.
Method
- Deterministic short-input sweep (
sweep): every registeredLayerType× lengths 0–80 × six byte patterns, with recovery off — 972,000 probes, 26 layers panicking. This found more than fuzzing would have in the same wall-clock, and it is reproducible. - Structured option sweep (
optsweep): valid outer header, hostile option area, with a 2-second watchdog per probe so a non-terminating loop is distinguishable from a pass. - Targeted construction for the amplification findings, measured with
runtime.MemStats.TotalAllocfor churn andHeapAllocafter a forced GC for retention — the distinction matters, and #13 is retention. - End-to-end replay through
pcapgo.Reader→gopacket.NewPacket→ip4defrag.DefragIPv4for #1, so the finding is not an artifact of calling an internal API directly.
Suggested order of work
- #1 — the only unrecoverable remote crash, and the cheapest to trigger. Fix
insert()'s length arithmetic and bound the splice by the payload. - #13 and #4 — the two resource exhaustions that need only a few Mbit/s.
- #3 and #9 — the two forgery primitives. These are the ones that produce wrong answers rather than no answers, which is the harder failure to notice.
- #11 — add the short-input test to CI first, then fix the 26 decoders it flags. The test is worth more than any individual fix.
- The rest.
Two changes worth making regardless of the individual fixes:
- Add the short-input sweep from #11 as a CI test. It is ~60 lines, runs in under a minute, is fully deterministic, and would have caught 39 of these.
- Compute lengths in
int, never in the header field's own width.uint8/uint16arithmetic on wire fields is the direct cause of #1, #7, #9 and much of #11.