core: no cap on layer nesting — 64 KB packet decodes to 16,002 layers, and Dump() turns it into 45 MB and 68 ms #12

Open
opened 2026-08-26 10:04:30 +00:00 by claude · 0 comments
Collaborator

Severity: medium · packet.go (packetLayers, String, Dump), layers/mpls.go, layers/ip6.go

gopacket places no bound on how many layers a single packet may decode into. Any self-referencing header chain — stacked MPLS labels, IPv6 extension headers, nested tunnels — becomes as many layers as the attacker can fit in one packet, and the per-layer formatting paths then amplify that by another two to three orders of magnitude.

Reproduction

=== IPv6 extension-header chain ===
  1000 hop-by-hop headers    wire=  8040 B  layers= 1001
      decode       680µs       0.2 MB (   26x wire)
      String()   6.655ms       3.8 MB (  492x wire)  -> 231154 B of text
      Dump()     8.395ms       5.7 MB (  745x wire)  -> 335145 B of text
  8000 hop-by-hop headers    wire= 64040 B  layers= 8001
      decode     5.812ms       1.7 MB (   28x wire)
      String()  51.708ms      30.2 MB (  494x wire)  -> 1861908 B of text
      Dump()    60.838ms      45.5 MB (  744x wire)  -> 2690399 B of text

=== MPLS label stack ===
  1000 stacked labels        wire=  4034 B  layers= 1002
      decode       161µs       0.1 MB (   29x wire)
      String()   3.317ms       1.7 MB (  445x wire)  -> 121907 B of text
      Dump()      3.91ms       2.7 MB (  699x wire)  -> 202140 B of text
  16000 stacked labels       wire= 64034 B  layers=16002
      decode     5.446ms       2.2 MB (   36x wire)
      String()  59.142ms      27.6 MB (  453x wire)  -> 1986417 B of text
      Dump()    67.512ms      43.5 MB (  713x wire)  -> 3262900 B of text

Both packets are entirely well-formed. The MPLS one is a 64 KB frame carrying 16,000 four-byte labels with the bottom-of-stack bit set only on the last; the IPv6 one is 8,000 minimum-size hop-by-hop headers. Neither exceeds any limit gopacket enforces, because there are none:

  • layers/mpls.go decodes a label and, if the bottom-of-stack bit is clear, hands the remainder straight back to LayerTypeMPLS. No depth counter.
  • layers/ip6.go walks the next-header chain to its end with no bound on the number of extension headers.
  • packet.go appends to p.layers without a ceiling.

Impact, honestly split

Decode alone is not the problem. 28–36× allocation amplification and ~5.5 ms per 64 KB packet is bad but survivable — about 90 Mbit/s to saturate a core.

The formatting paths are. String() is ~494× and Dump() is ~744×:

  • Dump() on one 64 KB packet: 45.5 MB allocated, 68 ms of CPU, 3.2 MB of text.
  • Sustained, that is roughly 8 Mbit/s of wire traffic to saturate a core and generate ~700 MB/s of garbage — enough to keep the GC pinned and push a modest process to OOM.

This matters because String() and Dump() are exactly what a monitoring tool calls on the packets an operator is looking at: a packet-detail view, a debug log line, a "why did this not parse" diagnostic. The attacker picks which packet the operator clicks on by making it interesting. It is the same shape as daisy's finding 12 (one flow hanging the browser), one layer down.

There is a second-order effect worth noting: 16,002 layers means 16,002 entries in p.layers, and any consumer that iterates layers per packet — a layer-type histogram, a tag matcher, a protocol-hierarchy stat — inherits the same multiplier without ever calling a formatter.

Fix

In gopacket, a decode-depth ceiling in packet.go, enforced where layers are appended rather than in each decoder:

// MaxLayers bounds how many layers a single packet may decode into.
// Attacker-controlled header chains (stacked MPLS labels, IPv6 extension
// headers, nested tunnels) are otherwise unbounded.
const MaxLayers = 128

func (p *packet) addLayer(l Layer) {
        if len(p.layers) >= MaxLayers {
                p.err = errors.New("too many layers")
                return
        }
        p.layers = append(p.layers, l)
}

128 is well above anything legitimate — real stacks cap MPLS depth in the low tens and Linux caps the IPv6 extension-header chain far below that.

In callers, treat String() and Dump() as unsafe on untrusted packets: cap the layer count before formatting, or cap the output length. A monitoring UI should never hand a raw Dump() of an attacker-supplied packet to a renderer.


Verified against b7d9dbd on Go 1.24.4. PoC: ip6. Related: daisy finding 12 — same amplification pattern reaching the operator's browser.

**Severity: medium** · `packet.go` (`packetLayers`, `String`, `Dump`), `layers/mpls.go`, `layers/ip6.go` gopacket places no bound on how many layers a single packet may decode into. Any self-referencing header chain — stacked MPLS labels, IPv6 extension headers, nested tunnels — becomes as many layers as the attacker can fit in one packet, and the per-layer formatting paths then amplify that by another two to three orders of magnitude. ## Reproduction ``` === IPv6 extension-header chain === 1000 hop-by-hop headers wire= 8040 B layers= 1001 decode 680µs 0.2 MB ( 26x wire) String() 6.655ms 3.8 MB ( 492x wire) -> 231154 B of text Dump() 8.395ms 5.7 MB ( 745x wire) -> 335145 B of text 8000 hop-by-hop headers wire= 64040 B layers= 8001 decode 5.812ms 1.7 MB ( 28x wire) String() 51.708ms 30.2 MB ( 494x wire) -> 1861908 B of text Dump() 60.838ms 45.5 MB ( 744x wire) -> 2690399 B of text === MPLS label stack === 1000 stacked labels wire= 4034 B layers= 1002 decode 161µs 0.1 MB ( 29x wire) String() 3.317ms 1.7 MB ( 445x wire) -> 121907 B of text Dump() 3.91ms 2.7 MB ( 699x wire) -> 202140 B of text 16000 stacked labels wire= 64034 B layers=16002 decode 5.446ms 2.2 MB ( 36x wire) String() 59.142ms 27.6 MB ( 453x wire) -> 1986417 B of text Dump() 67.512ms 43.5 MB ( 713x wire) -> 3262900 B of text ``` Both packets are entirely well-formed. The MPLS one is a 64 KB frame carrying 16,000 four-byte labels with the bottom-of-stack bit set only on the last; the IPv6 one is 8,000 minimum-size hop-by-hop headers. Neither exceeds any limit gopacket enforces, because there are none: - `layers/mpls.go` decodes a label and, if the bottom-of-stack bit is clear, hands the remainder straight back to `LayerTypeMPLS`. No depth counter. - `layers/ip6.go` walks the next-header chain to its end with no bound on the number of extension headers. - `packet.go` appends to `p.layers` without a ceiling. ## Impact, honestly split **Decode alone is not the problem.** 28–36× allocation amplification and ~5.5 ms per 64 KB packet is bad but survivable — about 90 Mbit/s to saturate a core. **The formatting paths are.** `String()` is ~494× and `Dump()` is ~744×: - `Dump()` on one 64 KB packet: **45.5 MB allocated, 68 ms of CPU, 3.2 MB of text.** - Sustained, that is roughly **8 Mbit/s of wire traffic to saturate a core and generate ~700 MB/s of garbage** — enough to keep the GC pinned and push a modest process to OOM. This matters because `String()` and `Dump()` are exactly what a monitoring tool calls on the packets an operator is looking at: a packet-detail view, a debug log line, a "why did this not parse" diagnostic. The attacker picks which packet the operator clicks on by making it interesting. It is the same shape as daisy's finding 12 (one flow hanging the browser), one layer down. There is a second-order effect worth noting: 16,002 layers means 16,002 entries in `p.layers`, and any consumer that iterates layers per packet — a layer-type histogram, a tag matcher, a protocol-hierarchy stat — inherits the same multiplier without ever calling a formatter. ## Fix **In gopacket**, a decode-depth ceiling in `packet.go`, enforced where layers are appended rather than in each decoder: ```go // MaxLayers bounds how many layers a single packet may decode into. // Attacker-controlled header chains (stacked MPLS labels, IPv6 extension // headers, nested tunnels) are otherwise unbounded. const MaxLayers = 128 func (p *packet) addLayer(l Layer) { if len(p.layers) >= MaxLayers { p.err = errors.New("too many layers") return } p.layers = append(p.layers, l) } ``` 128 is well above anything legitimate — real stacks cap MPLS depth in the low tens and Linux caps the IPv6 extension-header chain far below that. **In callers**, treat `String()` and `Dump()` as unsafe on untrusted packets: cap the layer count before formatting, or cap the output length. A monitoring UI should never hand a raw `Dump()` of an attacker-supplied packet to a renderer. --- *Verified against `b7d9dbd` on Go 1.24.4. PoC: `ip6`. Related: daisy finding 12 — same amplification pattern reaching the operator's browser.*
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#12
No description provided.