GitHub Advisoryadvisory · Go

GHSA-4fwh-wrm6-97xm Klever-Go: Unauthenticated WebSocket /subscribe: no read-size limit, no connection cap, permissive origin -> remote node memory/goroutine exhaustion (DoS)

HIGH Published 2026-09-23 Modified 2026-09-23 Official page ↗
Aliases
CVE-2026-86065
Ecosystem
Go
Packages
github.com/klever-io/klever-go
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Weaknesses
CWE-770
Reviewed
2026-09-23

Affected packages

• Go github.com/klever-io/klever-go: < 1.7.20 → fixed in 1.7.20

Description

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. 2. 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. 3. 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 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 hub.HandleClientInsertion(...) websocket/websocket.go:121 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 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 (…

Related

Alias · CVE (1)

  • CVE-2026-86065 Klever-Go is the Go implementation of the Klever blockchain protocol. vulnerability

Weakness · CWE (1)

  • CWE-770 Allocation of Resources Without Limits or Throttling weakness

References