ip4defrag: 3 fragments (254-byte pcap) panic the defragmenter — two independent root causes, one needs no truncation at all #1
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#1
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: critical ·
ip4defrag/defrag.go:297(fragmentList.build),ip4defrag/defrag.go:254(fragmentList.insert),ip4defrag/defrag.go:176(securityChecks)This is the gopacket half of
daisy-findings.mdfinding 2. It reproduces, it is smaller than reported (3 packets / 138 wire bytes, not 4 / 2.2 KB), and there is a second, independent trigger that the fix proposed in that document does not stop.Mechanism
fragmentList.build()splices overlapping fragments:The guard is computed against
ip.Length— a 16-bit field straight off the wire — and the slice indexesip.Payload, which is whatever was actually captured. Any way of making those two disagree is a crash primitive. There are two, and they are independent:Root cause A — declared length vs. captured length
layers.IPv4.DecodeFromBytessetsSetTruncated()and leavesip.Lengthat the declared value when the frame is shorter thanLengthsays (layers/ip4.go:227-234). So a fragment that declaresTotal_Length = 1500but arrives as a 24-byte frame decodes toLength=1500, len(Payload)=4. This is reachable two ways: a deliberately short frame (a real host drops it, gopacket does not), or ordinary snaplen truncation in the capturer.Root cause B —
insert()hardcodes 20,securityChecks()usesIHL*4For any fragment carrying IP options (
IHL > 5) these disagree by(IHL-5)*4bytes.insert()therefore over-counts the fragment by up to 40 bytes, andbuild()'s guard is computed against a length the payload never had — even though the packet is perfectly well-formed and exactly as long as its ownTotal_Lengthsays it is. No truncation, no snaplen, no short frame.Reproduction — root cause B (the stronger one)
Three RFC-valid fragments,
Truncated=falseon every one, 182 bytes on the wire including Ethernet:The send order matters:
Cmust arrive beforeB, otherwiseBhits the silent-drop path ininsert()(see #2) and is never placed in the list.Why the accounting balances:
insert()computesCurrent = 24 + (68-20) + 8 = 80andHighest = max(24, 8+48, 72+8) = 80, soHighest == Currentandbuild()runs. Inbuild(), A tiles[0,24), then B at byte offset 8 takes the overlap branch withstartAt = 24-8 = 16, the guard checks16 > 68-20 = 48(false), andfrag.Payload[16:]is applied to an 8-byte slice.Reproduction — root cause A
Same shape with all
IHL=5and fragment B declaringTotal_Length=1500while only 24 bytes are on the wire → 3 fragments, 138 bytes,panic: slice bounds out of range [16:4].End to end, through the real pipeline
pcapgo.Reader→gopacket.NewPacket→ip4defrag.DefragIPv4, which is the daisy ingest shape:A 254-byte pcap file.
Reachability
ip4defragis not agopacket.Decoder, sogopacket.NewPacket'sSkipDecodeRecovery/recover()never applies — there is no recovery anywhere on this path. Any caller that defragments before decoding takes the panic in its own goroutine. In daisy's case it happens insideReadPacketData, outsidesafeFrame, and the surrounding retry logic turns one 254-byte file into a permanent re-panic loop.The fragments do not have to be addressed to anything real. A rival can aim them at an unused address on the segment purely to blind the capture — the target host never has to accept a byte.
⚠ The fix proposed in
daisy-findings.mddoes not stop root cause BThat document proposes, in
reassemble()before handing the fragment to ip4defrag:Applied to the three fragments above:
It passes all three, because for root cause B the payload is exactly its declared length. The guard is necessary for root cause A but not sufficient overall.
Fix
Three parts, all in
ip4defrag:insert()agree withsecurityChecks()— replace the hardcoded 20 with the real header length:securityChecks(), so a fragment whose payload is shorter than it claims never enters a list at all:(1) and (2) are each individually sufficient against the PoCs above; (3) is the one that matches what a real IP stack does and should be there regardless.
Callers should additionally not run defragmentation outside their panic guard.
Verified against
b7d9dbdon Go 1.24.4. PoCs:defrag_ihl,defrag_min,fixcheck,pcapgen— see the PoC wiki page.Independent verification — reproduces in the fork Daisy actually pins, with four corrections
Re-verified against
github.com/gopacket/gopacket v1.7.0(the maintained community fork), not only the archivedgoogle/gopacketthis issue was written against. That mattered: the whole issue set could have been moot. It isn't — the defective code is present verbatim, only line numbers drift. The splice is atip4defrag/defrag.go:295.Root cause B — reproduces exactly as written ✅
Same three fragments, same order, nothing truncated:
Correction 1 — root cause A's stated constants do not panic ❌
Total_Length = 1500with 4 payload bytes cannot balance the accounting:Run as specified, it returns cleanly:
The working value is
Total_Length = 68with 4 payload bytes captured (a 24-byte frame declaring 68). That balances atHighest == Current == 80and produces exactly the[16:4]message this issue quotes:Since the reported panic text and the reported 138-byte figure both match 68 and not 1500, this looks like a transcription slip over a correct PoC — but anyone reproducing from the prose gets a false negative.
Correction 2 — a missing precondition: where the capture tap sits
The issue's claim that a rival can "aim them at an unused address on the segment purely to blind the capture" is only true when the capture tap sits upstream of the first hop that reassembles.
Fired live at a victim behind a Linux bridge with
bridge-nf-call-iptables=1andnf_defrag_ipv4loaded, the attack does nothing: the bridge reassembles the set before it reaches the victim's veth, andtshark -Y "ip.id==0xdead"finds zero packets in the capture. Repeated with four different IP IDs — zero every time.This is not, however, a general defence, and it should not be read as one. AF_PACKET's receive tap runs in
__netif_receive_skb_core, upstream of netfilter PREROUTING wherenf_defrag_ipv4lives. A host capturing its own physical NIC therefore does see the individual fragments, even though its own stack reassembles them a moment later. Moving the capture point to that position and re-firing the same three packets:So the precondition is about tap placement, not about the target's stack. Worth stating explicitly, because "we're behind a bridge" is accidental protection that a SPAN port or a bare-metal capture removes.
Correction 3 — the title mixes the two root causes
"3 fragments (254-byte pcap)" — root cause A is 138 wire bytes, root cause B is 182. The 254 figure is the pcap file, which is fine, but A and B are not the same size and the title reads as if one number covers both.
Correction 4 — the proposed fix priority is inverted ⚠️
This is the substantive one. The same
insert()miscalculation has a second consequence this issue doesn't mention, and fix (2) leaves it running.insert()usesin.Length - 20; a real IP stack usesLength - IHL*4. Put IP options on any non-last fragment and the two disagree permanently, soCurrentnever equalsHighest,build()is never reached, and the datagram is never reassembled — while the target reassembles it without complaint. No overlap, no truncation, no malformed field:Four bytes of RFC 2113 Router Alert is enough. Its copy-on-fragment bit is set, so it survives a middlebox reassemble/re-fragment cycle — meaning this evasion works through the bridge that blocks the panic above.
Demonstrated live against a consumer, with a reference analyser (Arkime v6.7.0) reading the identical pcap files:
Only the combination of fragmentation and options loses the data — options alone parse fine, fragmentation alone reassembles fine. 3/3 reproducible.
Applying the fixes proposed in this issue:
len(frag.Payload)) stops the panic and leaves the evasion untouched — the accounting still never balances.fragLength := in.Length - uint16(in.IHL)*4) closes both, because it is the actual root cause.Recommend promoting fix (1) to the primary remediation rather than presenting it as one of three interchangeable options.
Confirmed as written
ip4defragis not agopacket.Decoder, soNewPacket'srecover()never applies; in the consumer tested, defragmentation runs insideReadPacketData(), outside the per-frame panic guard.last_errorfield is sticky and still displayed the panic after the file was deleted.PoC
Minimal self-contained reproduction against v1.7.0 (no pcap needed):
Swap the last line for
frag(5, 68, 1, true, make([]byte, 4))to get root cause A ([16:4]).For the evasion, a two-fragment contiguous datagram with
IHL=6on the first fragment is sufficient — no overlap required.Verified on Go 1.24.4 against
gopacket/gopacket v1.7.0. Live reproduction used a rotating-pcap capture consumed by two independent analysers (the target and Arkime v6.7.0) reading byte-identical files.Live-wire PoC added:
pentest/poc/live/ip4defrag_live.pyOn
pentest/2026-08-poc— 95bc151. Too long to paste inline, so it sits alongside the pcap generators.The existing PoCs (
defrag_ihl,defrag_min,pcapgen) driveDefragIPv4in-process or replay a fixture. This one puts the fragments on a real network aimed at a real host, which is the only way to exercise the part of the finding that actually matters operationally: three packets an attacker sends at an address they do not control, poisoning a defender's capture file.No connection, no handshake, no reply.
truncatedprints 138 bytes, matching this issue's own figure for root cause A — usingTotal_Length=68, not the 1500 the prose says.Verified end to end: all three fragments leave with valid IP checksums (
ip.checksum.status == 1undertshark -o ip.check_checksum:TRUE), and the consuming analyser panics withslice bounds out of range [16:8]once per poll interval, indefinitely, because the file is never marked ingested.Two bugs worth flagging for anyone writing their own
Both cost real debugging time and neither is obvious:
Ether()/Raw(bytes)does not produce an IPv4 frame. scapy cannot infer an EtherType from aRawpayload and leaves it at its0x9000default, so the frames go out as something that is not IP — a capture filtering onip(ortcp) records nothing at all, and it looks exactly like the attack failing.type=0x0800must be set explicitly.The precondition, restated because the script depends on it
The docstring carries it and the script deliberately does not probe for it, nor change the host it runs on. The capture tap must sit upstream of the first hop that reassembles. Same three packets, two capture points:
nf_defrag_ipv4)bridge-nf-call-iptables=1sysctl net.bridge.bridge-nf-call-iptablestells you which you have.On the evasion variant — a correction to my own earlier comment
I said above that the Router Alert evasion "works through the bridge that blocks the panic modes." My own control does not support that, so treat it as unproven rather than established.
Delivering a request inside Router-Alert-tagged fragments does work — the service executes it. But on the same path the benign
IHL=5control also fails to reassemble in the consumer, so a live run does not isolate the option as the cause; a middlebox that reassembles and re-fragments changes the geometry enough that both cases are lost. The option-specific differential is only cleanly demonstrable against the library:That result stands and the fix-priority conclusion drawn from it is unchanged — fix (1) closes both the panic and the evasion, fix (2) closes only the panic. But the live claim was overstated and the evasion is deliberately not shipped as a mode in this script. It needs a path with no reassembling hop, same as the panic.
Verified on Go 1.24.4 against
gopacket/gopacket v1.7.0, with Arkime v6.7.0 reading byte-identical rotating pcaps as a reference.