layers/dns: name-decompression bomb — one 65 KB packet retains 377 MB, ~21 packets OOM an 8 GB box #13

Open
opened 2026-08-26 10:09:10 +00:00 by claude · 1 comment
Collaborator

Severity: high · layers/dns.go:536-610 (decodeName), layers/dns.go:637,711,920-980 (call sites)

decodeName bounds pointer-chase depth at maxRecursionLevel = 255. Nothing bounds how much each level appends, and nothing bounds how many names one packet asks it to decode. The product is the bomb.

Mechanism

const maxRecursionLevel = 255

func decodeName(data []byte, offset int, buffer *[]byte, level int) ([]byte, int, error) {
        if level > maxRecursionLevel {
                return nil, 0, errMaxRecursion
        }
        ...
        case 0xc0:
                offsetp := int(binary.BigEndian.Uint16(data[index:index+2]) & 0x3fff)
                _, _, err := decodeName(data, offsetp, buffer, level+1)   // appends to the SAME buffer

Each level may append up to a 63-byte label plus a separator before following its pointer, so one name expands to depth × 64 bytes. With depth = 248 that is 15,872 bytes per name. The depth cap is per name, not per packet — every record name starts again at level = 1.

buffer is the packet's shared decode buffer and it is retained on the decoded DNS layer, so this is live memory, not churn.

Construction

Compression pointers are 14 bits, so every chain block must live below offset 16383. The sequential record parser would otherwise walk straight into the chain, so it is buried inside one record's RDATARDLENGTH makes the parser skip it while pointers still reach in:

  12    question    : [ptr -> 30][QTYPE][QCLASS]
  18    answer 1    : [ptr -> 30][TYPE][CLASS][TTL][RDLENGTH = 16368]
  30    RDATA       : 248 blocks of [0x3f]['a' x 63][ptr -> next], last terminated
16398   answers 2..n: 12 bytes each, NAME = [ptr -> 30]

Every additional answer record costs 12 bytes and buys another 15,872 bytes of expansion.

Measurements

wire bytes   records   decode      allocated    amp       retained after decode
16998        51        2ms         4.0 MB       246x      3.9 MB     err=<nil>
23994        634       22ms        50.6 MB      2212x     50.1 MB    err=<nil>
39990        1967      56ms        156.3 MB     4097x     154.2 MB   err=<nil>
64998        4051      129ms       381.2 MB     6149x     376.8 MB   err=<nil>

--- end to end: ethernet + IPv4 + UDP/53 ---
  65040-byte frame -> DNS layer decoded=true  95ms  alloc=381.2 MB  retained=376.8 MB  err=<nil>

err=<nil> throughout. This is not a malformed packet that trips an error path — it is a completely successful parse. No SetTruncated, no error layer, nothing for a caller to check.

Cost to the attacker

  • Memory: ~21 packets, about 1.4 MB of traffic, to reach 8 GB. The memory is live and attached to the decoded layer, so it is only released when the caller drops the packet — and a tool that buffers packets, batches them, or holds them for a worker pool holds all of it at once.
  • CPU: 129 ms per 65 KB packet, i.e. one core saturated by roughly 4 Mbit/s, while generating ~3 GB/s of allocation.

Delivery: a 65 KB UDP datagram is IP-fragmented into ~45 frames on a 1500-byte MTU. That is fine here — the capturer records the fragments and an analyser that defragments (as ours does) reassembles and parses them. DNS over TCP/53 carries 64 KB messages natively via stream reassembly, with no fragmentation needed at all.

Scaling down honestly: confined to a single unfragmented 1472-byte datagram the same construction only reaches roughly 30×, because the chain itself needs ~16 KB before the cheap referring records start paying off. The headline numbers need a large datagram, which both delivery paths above provide.

Fix

The depth cap is the wrong dimension. Bound the output:

// maxDecodedNameBytes bounds total name expansion for one packet, not the
// depth of a single pointer chase.  Compression can otherwise turn a 12-byte
// record into 16 KB, once per record.
const maxDecodedNameBytes = 1 << 20

func decodeName(data []byte, offset int, buffer *[]byte, level int) ([]byte, int, error) {
        if len(*buffer) > maxDecodedNameBytes {
                return nil, 0, errors.New("DNS name expansion budget exhausted")
        }
        ...

Two further hardenings worth having alongside it:

  1. Cap the depth far lower. RFC 1035 limits a name to 255 bytes and a label to 63, so a legitimate name cannot exceed 4 levels of labels; 255 levels of pointer chasing serves no real traffic. Something like 16 would be generous and would cut the per-name expansion by 15×.
  2. Reject pointers that do not strictly decrease the offset. RFC 1035 §4.1.4 intends pointers to reference prior occurrences; every real encoder emits backward pointers only. Requiring offsetp < index makes chains finite by construction and costs nothing on valid traffic — it is the standard defence and is what most other DNS parsers do.

(2) alone reduces the maximum chain to the number of distinct decreasing offsets, and combined with (1) makes the whole class unreachable.

Worth adding the generator above as a test asserting a bounded allocation, next to the existing FuzzDecodeFromBytes in layers/dns_test.go.


Verified against b7d9dbd on Go 1.24.4. PoC: dnsbomb.

**Severity: high** · `layers/dns.go:536-610` (`decodeName`), `layers/dns.go:637,711,920-980` (call sites) `decodeName` bounds pointer-chase **depth** at `maxRecursionLevel = 255`. Nothing bounds how much each level appends, and nothing bounds how many names one packet asks it to decode. The product is the bomb. ## Mechanism ```go const maxRecursionLevel = 255 func decodeName(data []byte, offset int, buffer *[]byte, level int) ([]byte, int, error) { if level > maxRecursionLevel { return nil, 0, errMaxRecursion } ... case 0xc0: offsetp := int(binary.BigEndian.Uint16(data[index:index+2]) & 0x3fff) _, _, err := decodeName(data, offsetp, buffer, level+1) // appends to the SAME buffer ``` Each level may append up to a 63-byte label plus a separator before following its pointer, so one name expands to `depth × 64` bytes. With `depth = 248` that is **15,872 bytes per name**. The depth cap is per *name*, not per packet — every record name starts again at `level = 1`. `buffer` is the packet's shared decode buffer and it is **retained** on the decoded `DNS` layer, so this is live memory, not churn. ## Construction Compression pointers are 14 bits, so every chain block must live below offset 16383. The sequential record parser would otherwise walk straight into the chain, so it is buried inside one record's `RDATA` — `RDLENGTH` makes the parser skip it while pointers still reach in: ``` 12 question : [ptr -> 30][QTYPE][QCLASS] 18 answer 1 : [ptr -> 30][TYPE][CLASS][TTL][RDLENGTH = 16368] 30 RDATA : 248 blocks of [0x3f]['a' x 63][ptr -> next], last terminated 16398 answers 2..n: 12 bytes each, NAME = [ptr -> 30] ``` Every additional answer record costs **12 bytes** and buys another 15,872 bytes of expansion. ## Measurements ``` wire bytes records decode allocated amp retained after decode 16998 51 2ms 4.0 MB 246x 3.9 MB err=<nil> 23994 634 22ms 50.6 MB 2212x 50.1 MB err=<nil> 39990 1967 56ms 156.3 MB 4097x 154.2 MB err=<nil> 64998 4051 129ms 381.2 MB 6149x 376.8 MB err=<nil> --- end to end: ethernet + IPv4 + UDP/53 --- 65040-byte frame -> DNS layer decoded=true 95ms alloc=381.2 MB retained=376.8 MB err=<nil> ``` `err=<nil>` throughout. This is not a malformed packet that trips an error path — it is a **completely successful parse**. No `SetTruncated`, no error layer, nothing for a caller to check. ## Cost to the attacker - **Memory: ~21 packets, about 1.4 MB of traffic, to reach 8 GB.** The memory is live and attached to the decoded layer, so it is only released when the caller drops the packet — and a tool that buffers packets, batches them, or holds them for a worker pool holds all of it at once. - **CPU: 129 ms per 65 KB packet**, i.e. one core saturated by roughly **4 Mbit/s**, while generating ~3 GB/s of allocation. Delivery: a 65 KB UDP datagram is IP-fragmented into ~45 frames on a 1500-byte MTU. That is fine here — the capturer records the fragments and an analyser that defragments (as ours does) reassembles and parses them. DNS over **TCP/53** carries 64 KB messages natively via stream reassembly, with no fragmentation needed at all. Scaling down honestly: confined to a single unfragmented 1472-byte datagram the same construction only reaches roughly 30×, because the chain itself needs ~16 KB before the cheap referring records start paying off. The headline numbers need a large datagram, which both delivery paths above provide. ## Fix The depth cap is the wrong dimension. Bound the **output**: ```go // maxDecodedNameBytes bounds total name expansion for one packet, not the // depth of a single pointer chase. Compression can otherwise turn a 12-byte // record into 16 KB, once per record. const maxDecodedNameBytes = 1 << 20 func decodeName(data []byte, offset int, buffer *[]byte, level int) ([]byte, int, error) { if len(*buffer) > maxDecodedNameBytes { return nil, 0, errors.New("DNS name expansion budget exhausted") } ... ``` Two further hardenings worth having alongside it: 1. **Cap the depth far lower.** RFC 1035 limits a name to 255 bytes and a label to 63, so a legitimate name cannot exceed 4 levels of labels; 255 levels of *pointer chasing* serves no real traffic. Something like 16 would be generous and would cut the per-name expansion by 15×. 2. **Reject pointers that do not strictly decrease the offset.** RFC 1035 §4.1.4 intends pointers to reference *prior* occurrences; every real encoder emits backward pointers only. Requiring `offsetp < index` makes chains finite by construction and costs nothing on valid traffic — it is the standard defence and is what most other DNS parsers do. (2) alone reduces the maximum chain to the number of distinct decreasing offsets, and combined with (1) makes the whole class unreachable. Worth adding the generator above as a test asserting a bounded allocation, next to the existing `FuzzDecodeFromBytes` in `layers/dns_test.go`. --- *Verified against `b7d9dbd` on Go 1.24.4. PoC: `dnsbomb`.*
Author
Collaborator

Reachability note — not exploitable against the consumer audited in this round

No dispute with the library finding; I did not re-measure the 377 MB figure. Adding a reachability data point that may be useful for prioritising this against the other issues in the pentest-2026-08 set.

The consumer audited alongside these issues is not reachable by this bug, for a reason worth writing down because it is easy to lose:

  1. Its zero-alloc path uses a DecodingLayerParser with an explicit decoder set — Ethernet, Dot1Q, IPv4, IPv6, IPv6ExtensionSkipper, TCP, UDP, Payload. LayerTypeDNS is not registered, and IgnoreUnsupported is set, so a UDP/53 datagram terminates the chain at Payload.
  2. Its forensic path uses gopacket.NewPacket with DecodeOptions{Lazy: true} and only ever calls NetworkLayer() and TransportLayer(). Lazy decoding stops as soon as the requested layer is available, so layers.UDP.NextLayerType() returning LayerTypeDNS never causes the DNS decoder to run.
  3. Nothing in the ingest path calls packet.Layers() or packet.ApplicationLayer(), either of which would force the full chain and re-open this.

So the exposure is gated on calling the DNS decoder at all, which for a flow-level analyser is optional. That is a meaningfully narrower blast radius than #1 or #3, both of which fire on the decode path every caller uses.

Two things this suggests:

  • The reachability precondition belongs in the issue. As written it reads as though any gopacket consumer handling UDP/53 is exposed; in practice a caller must have registered LayerTypeDNS or forced application-layer decode. That distinction changes who needs to act urgently.
  • Point 3 is a fragile guarantee. Any future change that calls packet.Layers() — a debug dump, a "store all decoded layer types" feature, a protocol-detection heuristic — silently re-opens this with no local signal. Worth a note for callers that a lazy-decode strategy is load-bearing security, not just a performance choice.

Same reasoning applies to #8 and #9 (no Geneve decoder registered) and to #14 (the reassembly package is not used, though that finding does block any future migration off tcpassembly).


Reachability assessed against gopacket/gopacket v1.7.0.

## Reachability note — not exploitable against the consumer audited in this round No dispute with the library finding; I did not re-measure the 377 MB figure. Adding a reachability data point that may be useful for prioritising this against the other issues in the `pentest-2026-08` set. The consumer audited alongside these issues is **not reachable** by this bug, for a reason worth writing down because it is easy to lose: 1. Its zero-alloc path uses a `DecodingLayerParser` with an explicit decoder set — Ethernet, Dot1Q, IPv4, IPv6, IPv6ExtensionSkipper, TCP, UDP, Payload. `LayerTypeDNS` is not registered, and `IgnoreUnsupported` is set, so a UDP/53 datagram terminates the chain at `Payload`. 2. Its forensic path uses `gopacket.NewPacket` with `DecodeOptions{Lazy: true}` and only ever calls `NetworkLayer()` and `TransportLayer()`. Lazy decoding stops as soon as the requested layer is available, so `layers.UDP.NextLayerType()` returning `LayerTypeDNS` never causes the DNS decoder to run. 3. Nothing in the ingest path calls `packet.Layers()` or `packet.ApplicationLayer()`, either of which would force the full chain and re-open this. So the exposure is gated on **calling the DNS decoder at all**, which for a flow-level analyser is optional. That is a meaningfully narrower blast radius than #1 or #3, both of which fire on the decode path every caller uses. Two things this suggests: - **The reachability precondition belongs in the issue.** As written it reads as though any gopacket consumer handling UDP/53 is exposed; in practice a caller must have registered `LayerTypeDNS` or forced application-layer decode. That distinction changes who needs to act urgently. - **Point 3 is a fragile guarantee.** Any future change that calls `packet.Layers()` — a debug dump, a "store all decoded layer types" feature, a protocol-detection heuristic — silently re-opens this with no local signal. Worth a note for callers that a lazy-decode strategy is load-bearing security, not just a performance choice. Same reasoning applies to #8 and #9 (no Geneve decoder registered) and to #14 (the `reassembly` package is not used, though that finding does block any future migration off `tcpassembly`). --- *Reachability assessed against `gopacket/gopacket v1.7.0`.*
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#13
No description provided.