pcapgo: readers size allocations from file-supplied lengths — 40-byte file → 1 GB alloc, and a 40-byte snoop file panics #10

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

Severity: medium · pcapgo/read.go:127-137,145-166, pcapgo/snoop.go:130,149,165

Every pcapgo reader sizes its packet buffer from a length field in the file. read.go does bound that field — but only against two other fields from the same file, which is not a bound at all.

Defect 1 — read.go: the guard compares attacker input against attacker input

func (r *Reader) ReadPacketData() (data []byte, ci gopacket.CaptureInfo, err error) {
        if ci, err = r.readPacketHeader(); err != nil { return }
        if ci.CaptureLength > int(r.snaplen) {           // r.snaplen: uint32 from the file header
                err = fmt.Errorf("capture length exceeds snap length: %d > %d", ...)
                return
        }
        if ci.CaptureLength > ci.Length {                // ci.Length: uint32 from the packet header
                err = fmt.Errorf("capture length exceeds original packet length: %d > %d", ...)
                return
        }
        data = make([]byte, ci.CaptureLength)            // unbounded
        _, err = io.ReadFull(r.r, data)

r.snaplen is read from bytes 16–20 of the file header and is never validated. ci.Length is read from the same 16-byte packet header as ci.CaptureLength. Setting all three to the same large value satisfies both guards, and the make happens before io.ReadFull discovers there is nothing to read.

honest 64 KB snaplen   file=40 B  snaplen=65535       CaptureLength=60          -> heap +0.0 MB     (amplification 111x)        err=EOF
snaplen 256 MB         file=40 B  snaplen=268435456   CaptureLength=268435456   -> heap +256.0 MB   (amplification 6710995x)    err=EOF
snaplen 1 GB           file=40 B  snaplen=1073741824  CaptureLength=1073741824  -> heap +1024.0 MB  (amplification 26843632x)   err=EOF

A 40-byte file — 24-byte file header plus one 16-byte packet header, no packet data at all — buys a 1 GB allocation. CaptureLength is an int from a uint32, so on 64-bit the ceiling is 4 GB per packet, and the file can repeat the packet header to do it again.

ZeroCopyReadPacketData is worse: it caches the oversized buffer in r.packetBuf (make([]byte, snaplen)), so the peak allocation is retained for the life of the reader rather than being collectable after the failed read.

ngread.go:538,566 has the same shape driven by the Enhanced Packet Block's captured length and the Interface Description Block's snaplen.

Defect 2 — snoop.go: a negative length reaches make

r.pad = int(binary.BigEndian.Uint32(r.buf[8:12])) - (24 + ci.Length)
...
data = make([]byte, ci.CaptureLength+r.pad)

r.pad is derived by subtraction and is never checked for sign. RecordLength = 0 with OriginalLength = 100 gives pad = -124, and CaptureLength + pad is negative:

snoop file: 40 bytes
PANIC: runtime error: makeslice: len out of range

maxCaptureLen bounds CaptureLength but nothing bounds pad, so a large RecordLength is also an unbounded allocation on the same line. ZeroCopyReadPacketData (snoop.go:165) has both problems.

Reachability — read this before rating it

In the daisy threat model the pcap files are written by our own tcpdump, so a rival cannot set these fields and this is not remotely triggerable through the normal ingest path. That is why this is medium and not high.

It becomes reachable if any of the following is true, and each is worth checking:

  • pcaps are ever ingested from a source other than our own capturer — an operator-supplied file, an Arkime export, a capture shared between teams, anything uploaded;
  • a rotation is ever killed mid-write and the truncated tail is re-read (the failure mode here is a wild CaptureLength read from a partially-written header);
  • the file is read off shared or network storage.

The library-level bug is real regardless: bounding a length field against another field from the same untrusted file is not a bound, and pcapgo is a general-purpose pcap reader whose callers will not all have a trusted-file threat model.

Fix

Give the readers a real ceiling that does not come from the file:

// pcapgo: package-level, overridable by the caller
const MaxPacketSize = 8 << 20   // generous: well above any real snaplen

func (r *Reader) SetMaxPacketSize(n int) { r.maxPacket = n }

// in readPacketHeader / ReadPacketData
if ci.CaptureLength < 0 || ci.CaptureLength > r.maxPacket {
        return ci, fmt.Errorf("capture length %d out of range", ci.CaptureLength)
}

and validate snaplen once when the file header is parsed, rather than trusting it as a bound for everything after.

For snoop.go, check the sign and the magnitude before the make:

if r.pad < 0 || ci.CaptureLength+r.pad > maxCaptureLen {
        return ci, errors.New("snoop: invalid record length")
}

A cheap general mitigation for all of them: read into a growing buffer capped at the ceiling rather than allocating CaptureLength up front, so a file that lies about its size costs one failed read rather than a gigabyte.


Verified against b7d9dbd on Go 1.24.4. PoCs: pcapalloc, snoop. ngread.go was not measured here — same shape, worth its own pass.

**Severity: medium** · `pcapgo/read.go:127-137,145-166`, `pcapgo/snoop.go:130,149,165` Every `pcapgo` reader sizes its packet buffer from a length field in the file. `read.go` does bound that field — but only against **two other fields from the same file**, which is not a bound at all. ## Defect 1 — `read.go`: the guard compares attacker input against attacker input ```go func (r *Reader) ReadPacketData() (data []byte, ci gopacket.CaptureInfo, err error) { if ci, err = r.readPacketHeader(); err != nil { return } if ci.CaptureLength > int(r.snaplen) { // r.snaplen: uint32 from the file header err = fmt.Errorf("capture length exceeds snap length: %d > %d", ...) return } if ci.CaptureLength > ci.Length { // ci.Length: uint32 from the packet header err = fmt.Errorf("capture length exceeds original packet length: %d > %d", ...) return } data = make([]byte, ci.CaptureLength) // unbounded _, err = io.ReadFull(r.r, data) ``` `r.snaplen` is read from bytes 16–20 of the file header and is never validated. `ci.Length` is read from the same 16-byte packet header as `ci.CaptureLength`. Setting all three to the same large value satisfies both guards, and the `make` happens before `io.ReadFull` discovers there is nothing to read. ``` honest 64 KB snaplen file=40 B snaplen=65535 CaptureLength=60 -> heap +0.0 MB (amplification 111x) err=EOF snaplen 256 MB file=40 B snaplen=268435456 CaptureLength=268435456 -> heap +256.0 MB (amplification 6710995x) err=EOF snaplen 1 GB file=40 B snaplen=1073741824 CaptureLength=1073741824 -> heap +1024.0 MB (amplification 26843632x) err=EOF ``` A **40-byte file** — 24-byte file header plus one 16-byte packet header, no packet data at all — buys a 1 GB allocation. `CaptureLength` is an `int` from a `uint32`, so on 64-bit the ceiling is 4 GB per packet, and the file can repeat the packet header to do it again. `ZeroCopyReadPacketData` is worse: it caches the oversized buffer in `r.packetBuf` (`make([]byte, snaplen)`), so the peak allocation is retained for the life of the reader rather than being collectable after the failed read. `ngread.go:538,566` has the same shape driven by the Enhanced Packet Block's captured length and the Interface Description Block's snaplen. ## Defect 2 — `snoop.go`: a negative length reaches `make` ```go r.pad = int(binary.BigEndian.Uint32(r.buf[8:12])) - (24 + ci.Length) ... data = make([]byte, ci.CaptureLength+r.pad) ``` `r.pad` is derived by subtraction and is never checked for sign. `RecordLength = 0` with `OriginalLength = 100` gives `pad = -124`, and `CaptureLength + pad` is negative: ``` snoop file: 40 bytes PANIC: runtime error: makeslice: len out of range ``` `maxCaptureLen` bounds `CaptureLength` but nothing bounds `pad`, so a large `RecordLength` is also an unbounded allocation on the same line. `ZeroCopyReadPacketData` (`snoop.go:165`) has both problems. ## Reachability — read this before rating it In the daisy threat model the pcap files are written by our own `tcpdump`, so a rival cannot set these fields and this is **not** remotely triggerable through the normal ingest path. That is why this is medium and not high. It becomes reachable if any of the following is true, and each is worth checking: - pcaps are ever ingested from a source other than our own capturer — an operator-supplied file, an Arkime export, a capture shared between teams, anything uploaded; - a rotation is ever killed mid-write and the truncated tail is re-read (the failure mode here is a wild `CaptureLength` read from a partially-written header); - the file is read off shared or network storage. The library-level bug is real regardless: bounding a length field against another field from the same untrusted file is not a bound, and `pcapgo` is a general-purpose pcap reader whose callers will not all have a trusted-file threat model. ## Fix Give the readers a real ceiling that does not come from the file: ```go // pcapgo: package-level, overridable by the caller const MaxPacketSize = 8 << 20 // generous: well above any real snaplen func (r *Reader) SetMaxPacketSize(n int) { r.maxPacket = n } // in readPacketHeader / ReadPacketData if ci.CaptureLength < 0 || ci.CaptureLength > r.maxPacket { return ci, fmt.Errorf("capture length %d out of range", ci.CaptureLength) } ``` and validate `snaplen` once when the file header is parsed, rather than trusting it as a bound for everything after. For `snoop.go`, check the sign and the magnitude before the `make`: ```go if r.pad < 0 || ci.CaptureLength+r.pad > maxCaptureLen { return ci, errors.New("snoop: invalid record length") } ``` A cheap general mitigation for all of them: read into a growing buffer capped at the ceiling rather than allocating `CaptureLength` up front, so a file that lies about its size costs one failed read rather than a gigabyte. --- *Verified against `b7d9dbd` on Go 1.24.4. PoCs: `pcapalloc`, `snoop`. `ngread.go` was not measured here — same shape, worth its own pass.*
Owner

Can this be exploited? Is there a way to reproduce this issue over the wire? Like someone would send the malicious packets to a service that's being dumped, then Daisy would parse it with gopacket. If not, close the issue.

Can this be exploited? Is there a way to reproduce this issue over the wire? Like someone would send the malicious packets to a service that's being dumped, then Daisy would parse it with gopacket. If not, close the issue.
Author
Collaborator

Measured ngread.go, which the original report left open. It is worse than read.go.

pcapgo/ngread.go:538:

data = make([]byte, r.ci.CaptureLength)
if err = r.readBytes(data); err != nil {
        return
}

r.ci.CaptureLength is the Enhanced Packet Block's 4-byte Captured Packet Length field (ngread.go:461), used with no validation at all — not even the weak snaplen/original-length comparison read.go performs. The Interface Description Block's snaplen is parsed and stored, and then never consulted on this path.

honest                                 file=80 B  snaplen=65535  CaptureLength=60          -> heap +0.0 MB (74x)         err=EOF
CaptureLength 256 MB, snaplen 65535    file=80 B  snaplen=65535  CaptureLength=268435456   -> heap +256.0 MB (3355516x)  err=EOF
CaptureLength 1 GB, snaplen 65535      file=80 B  snaplen=65535  CaptureLength=1073741824  -> heap +1024.0 MB (13421901x) err=EOF

An 80-byte file — a 28-byte Section Header Block, a 20-byte Interface Description Block declaring a perfectly ordinary 65535-byte snaplen, and a 32-byte Enhanced Packet Block header with no packet data behind it — produces a 1 GB allocation. The ceiling is 4 GB per block, and the file can repeat the block header to do it again.

Note the snaplen is honest here: a consumer that validates the IDB snaplen before trusting the file still gets hit, because the reader never compares the two. That makes this the more dangerous of the two readers.

ZeroCopyReadPacketData (ngread.go:566) does consult the interface snaplen, but only to pick the larger of it and CaptureLength:

snaplen := int(r.ifaces[ci.InterfaceIndex].SnapLength)
if snaplen < ci.CaptureLength {
        snaplen = ci.CaptureLength
}
r.packetBuf = make([]byte, snaplen)

so it allocates the same amount and then caches it for the life of the reader.

One related observation that did not reproduce as a failure, recorded so it is not re-chased: readBlock computes r.currentBlock.length = r.getUint32(r.buf[4:8]) - 8 with no floor, so a block whose Total Length field is below 8 underflows to ~4.29e9. I built a file with an EPB of Total Length 0 and it terminated cleanly at EOF rather than misbehaving — the subsequent reads run out of file first. Worth adding the floor check anyway, since it is one line and the underflow is real:

total := r.getUint32(r.buf[4:8])
if total < 12 {
        return fmt.Errorf("invalid block total length %d", total)
}
r.currentBlock.length = total - 8

The MaxPacketSize ceiling proposed in the issue body should be applied on this path too — it is the one that most needs it.

PoC: ngbomb on branch pentest/2026-08-poc.

Measured `ngread.go`, which the original report left open. **It is worse than `read.go`.** `pcapgo/ngread.go:538`: ```go data = make([]byte, r.ci.CaptureLength) if err = r.readBytes(data); err != nil { return } ``` `r.ci.CaptureLength` is the Enhanced Packet Block's 4-byte *Captured Packet Length* field (`ngread.go:461`), used with **no validation at all** — not even the weak snaplen/original-length comparison `read.go` performs. The Interface Description Block's snaplen is parsed and stored, and then never consulted on this path. ``` honest file=80 B snaplen=65535 CaptureLength=60 -> heap +0.0 MB (74x) err=EOF CaptureLength 256 MB, snaplen 65535 file=80 B snaplen=65535 CaptureLength=268435456 -> heap +256.0 MB (3355516x) err=EOF CaptureLength 1 GB, snaplen 65535 file=80 B snaplen=65535 CaptureLength=1073741824 -> heap +1024.0 MB (13421901x) err=EOF ``` An **80-byte file** — a 28-byte Section Header Block, a 20-byte Interface Description Block declaring a perfectly ordinary 65535-byte snaplen, and a 32-byte Enhanced Packet Block header with no packet data behind it — produces a 1 GB allocation. The ceiling is 4 GB per block, and the file can repeat the block header to do it again. Note the snaplen is honest here: **a consumer that validates the IDB snaplen before trusting the file still gets hit**, because the reader never compares the two. That makes this the more dangerous of the two readers. `ZeroCopyReadPacketData` (`ngread.go:566`) does consult the interface snaplen, but only to pick the *larger* of it and `CaptureLength`: ```go snaplen := int(r.ifaces[ci.InterfaceIndex].SnapLength) if snaplen < ci.CaptureLength { snaplen = ci.CaptureLength } r.packetBuf = make([]byte, snaplen) ``` so it allocates the same amount and then caches it for the life of the reader. One related observation that did **not** reproduce as a failure, recorded so it is not re-chased: `readBlock` computes `r.currentBlock.length = r.getUint32(r.buf[4:8]) - 8` with no floor, so a block whose Total Length field is below 8 underflows to ~4.29e9. I built a file with an EPB of Total Length 0 and it terminated cleanly at `EOF` rather than misbehaving — the subsequent reads run out of file first. Worth adding the floor check anyway, since it is one line and the underflow is real: ```go total := r.getUint32(r.buf[4:8]) if total < 12 { return fmt.Errorf("invalid block total length %d", total) } r.currentBlock.length = total - 8 ``` The `MaxPacketSize` ceiling proposed in the issue body should be applied on this path too — it is the one that most needs it. *PoC: `ngbomb` on branch `pentest/2026-08-poc`.*
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#10
No description provided.