tcpassembly: 4.35 MB of descending-sequence segments costs 36 s of CPU — quadratic insertion with unlimited buffering by default #4
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#4
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:679-687(traverseConn),tcpassembly/assembly.go(DefaultAssemblerOptions)This is
daisy-findings.mdfinding 4, re-measured against gopacket directly. It reproduces, and the exponent is worse than a clean quadratic once GC pressure from the unbounded page list joins in.Mechanism
Two defaults combine.
1. Insertion is a backwards linear scan.
The assumption is stated in the comment and is entirely reasonable for benign traffic. It is also entirely under the attacker's control. Feed strictly descending sequence numbers and every insertion walks the whole list from tail to head:
O(n²)comparisons fornbuffered segments.2. Nothing bounds the list.
DefaultAssemblerOptionssetsMaxBufferedPagesPerConnection: 0andMaxBufferedPagesTotal: 0, both meaning unlimited. The same file warns that these defaults "can result in ever-increasing memory usage unless one of the Flush* methods is called on a regular basis" — but a caller whose flush is driven by capture-time idle timeout will never flush inside a single capture window, so the list only grows.Measurements
4 cores / 8 GB, Go 1.24.4, 60-byte payloads, one connection,
DefaultAssemblerOptions:Doubling the segment count multiplies wall-clock by 4–10×. At 40,000 segments the out-of-order case is 780× slower than the identical byte count delivered in order.
Cost to the attacker
4.35 MB inside one 30-second capture window is about 1.2 Mbit/s — and it buys 36 seconds of a core. One attacker at just over a megabit per second permanently consumes more than a full core of parsing capacity, and the next capture file lands before the current one is finished.
Concentrating everything in a single connection is optimal for the attacker:
kconnections costn²/k, so splitting is strictly worse for them. There is nothing to spread the load across.Memory amplification runs at 19–28× and is still climbing at the top of the table, so a sustained flood is also a slow OOM: roughly 285 MB of wire traffic per 8 GB of RSS.
The traffic does not need to be a valid conversation, does not need a handshake, and does not need any host to answer. Descending sequence numbers on a single 4-tuple are sufficient.
Fix
Set explicit bounds — both degrade gracefully by flushing the oldest buffered data rather than failing:
daisy-findings.mdmeasured this at 816× faster on the same poison capture (66,150 ms → 81 ms) with no effect on in-order traffic, which matches the shape of the table above.Two things worth changing in gopacket itself rather than leaving to every caller:
MaxBufferedPagesTotal: 0meaning "unlimited" is a reasonable API but a poor default for a library whose entire purpose is parsing untrusted input. A finite default with a documented opt-out to unlimited would fail safe.O(n²)up to the bound. A skip list or a small ordered tree keyed on sequence would make the worst caseO(n log n)and remove the attacker's leverage entirely rather than just capping it.Callers should additionally flush every N packets regardless of timestamps, and keep any capture-time idle timeout below the capture rotation period.
Verified against
b7d9dbdon Go 1.24.4. PoC:asmbench.reassembly/should be measured the same way — it has the same insertion shape.Independent verification — reproduces in
gopacket/gopacket v1.7.0, measurements agreeRe-measured against the maintained fork on 4 cores / 8 GB, Go 1.24.4, 60-byte payloads, one connection,
DefaultAssemblerOptions:Within noise of the numbers in the issue (22 / 145 / 603 / 3710 ms). Ten times the input costs 278 times the wall clock; the identical byte count delivered in order costs 20 ms, a 250× gap at 20,000 segments. I stopped at 20,000 rather than 40,000 to be kind to the test box, but the growth curve extrapolates cleanly onto the reported ~36 s.
Both root causes confirmed present verbatim in the fork:
traverseConn's backwards scan, andDefaultAssemblerOptionsleavingMaxBufferedPagesTotalandMaxBufferedPagesPerConnectionat 0.The "flush never fires" precondition is real, and easy to hit accidentally
This issue notes in passing that "a caller whose flush is driven by capture-time idle timeout will never flush inside a single capture window". Confirming that against a real consumer, because it is the part most likely to be dismissed as hypothetical.
The consumer tested builds its assembler with default options and flushes like this:
with
connIdleTimeout = 60 * time.Second— against a capture that rotates every 30 seconds. Capture timestamps within one file therefore span less than the idle timeout, the condition is never true, andFlushOlderThanis never called for the lifetime of a file. The page list is released only byFlushAll()after the last packet.That is not a misconfiguration; it is the natural result of picking an idle timeout that matches the target's TCP behaviour and a rotation interval that matches operational needs, independently. Any caller whose flush interval exceeds its capture window has the same property, and nothing in the package's documentation flags the interaction.
Suggest the docs on
DefaultAssemblerOptionssay explicitly that a time-driven flush is not a substitute for a page cap, and that callers setMaxBufferedPagesPerConnectionregardless of their flush policy.PoC
Flip the
seqline tobase + uint32(i*len(payload))for the in-order control.Verified on Go 1.24.4 against
gopacket/gopacket v1.7.0.