logo

Database

Improper resource allocation In github.com/klever-io/klever-go

Description

Klever-Go: Unauthenticated WebSocket /subscribe: no read-size limit, no connection cap, permissive origin -> remote node memory/goroutine exhaustion (DoS) ## 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 <-- 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.goHandleClientInsertion 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.goloopIn 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

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Component
Affected version
Patched versions