76521 bc low ignored inbound websocket frames can oom the websocket proxy
Submitted on May 4th 2026 at 18:23:52 UTC by @adeolu for Audit Comp | Base Azul
Report ID: #76521
Report Type: Blockchain/DLT
Report severity: Low
Target: https://github.com/base/base/tree/v0.8.0-rc.28
Impacts:
Increasing network processing node resource consumption by at least 30% without brute force actions, compared to the preceding 24 hours
Description
Affected code:
f20_websocket_proxy_poc/run_finding1_inbound_frame_oom.shf20_websocket_proxy_poc/scripts/attack_inbound_frames.pyf20_websocket_proxy_poc/scripts/raw_ws.py
Summary
The websocket proxy is intended to be one-directional: it subscribes to upstream flashblocks and broadcasts them to downstream clients. However, every downstream client connection still spawns a reader task that continuously calls ws_receiver.next().
The reader only handles Pong, Close, receive errors, and stream closure. Any other inbound frame, including Text and Binary, falls into _ => {} and is silently discarded after axum/tungstenite has already read, reassembled, validated, and allocated the message.
The exposure depends on how the proxy is configured. If no --api-keys are configured, the proxy enables the public /ws endpoint and any unauthenticated client can open a WebSocket and send inbound frames. If --api-keys are configured, the vulnerable path is reachable through /ws/{api_key} by any client with a valid API key. If --public-access-enabled is also set with API keys configured, the public /ws endpoint is enabled again. Enabling authentication does not remove the bug; it only changes the attacker prerequisite from "any unauthenticated client" to "any client with a valid API key."
The server's WebSocketUpgrade is used without lowering axum/tungstenite's message or frame limits. In tungstenite 0.28, the defaults allow large messages, with a default max message size of 64 MiB and default max frame size of 16 MiB. The PoC uses 15 MiB binary frames, which stay below the default frame limit while still forcing large per-connection allocations inside the proxy.
The Docker PoC runs the stock websocket-proxy binary with a 256 MiB cgroup memory limit. It opens 32 downstream WebSocket clients and repeatedly sends 15 MiB binary frames to /ws. The proxy does not need to broadcast anything to these clients. It OOMs while reading and discarding attacker-controlled inbound frames.
This maps most directly to:
If the websocket proxy is deployed as shared Base infrastructure for flashblocks consumers, crashing it also causes a service outage for downstream clients of that proxy.
Required Conditions
The issue shows when these configs and conditions line up:
The websocket proxy is reachable by an attacker in one of its supported downstream modes:
no
--api-keysconfigured, which enables unauthenticated public/ws;--api-keysconfigured, which enables authenticated/ws/{api_key};--api-keysplus--public-access-enabled, which re-enables unauthenticated public/ws.
The deployment does not have an outer reverse proxy or WAF enforcing a much lower WebSocket frame/message size before traffic reaches axum.
The proxy uses the current
WebSocketUpgradepath, which does not configuremax_message_size,max_frame_size, or an inbound close-on-unexpected-data policy.The attacker can keep connections open long enough to send repeated large frames. The PoC disables client ping checks and uses the default one-directional behavior.
Connection limits do not limit inbound bytes. The PoC raises the instance and per-IP connection limits so the test isolates the byte-processing bug rather than the limiter. In production, equivalent concurrency can come from configured higher limits, multiple source IPs, or the separately reported forwarded-IP spoofing issue.
Docker memory is capped in the PoC with
--memoryand--memory-swapset to the same value, so swap does not hide the proxy memory failure.
Vulnerability Detail
Server routing exposes either public or authenticated downstream websocket endpoints
Source: server.rs:92-106
Therefore, the unauthenticated version of the attack applies to deployments with no configured API keys, or deployments that explicitly set --public-access-enabled. Authenticated deployments still expose the same vulnerable reader to any valid API-key client through /ws/{api_key}. Authentication gates who can reach the reader; it does not change how inbound Text or Binary frames are processed once the WebSocket is upgraded.
Each client connection starts a reader even though client data is unused
Source: registry.rs:50-56
This creates a downstream WebSocket reader for every subscriber.
The reader consumes all inbound frames
Source: registry.rs:149-179
The final _ => {} means client Text, Binary, and Ping frames are accepted and ignored. For large Text or Binary frames, the expensive work has already happened before this match arm is reached.
The WebSocket upgrade does not lower frame or message limits
Source: server.rs:226-273
There is no call to configure a smaller message limit, frame limit, or backpressure policy for inbound client frames.
Connection limits do not account for inbound bytes or frame rate
The rate limiter only limits active connections. Once a connection is admitted, it can keep sending inbound frames and make the proxy allocate and parse them until the connection closes or the process is killed.
The PoC raises connection caps to avoid proving a different bug:
That does not modify the vulnerable code path. It only makes the Docker proof deterministic on one host.
Attack Path
The attacker connects to the proxy's downstream WebSocket endpoint. In no-API-keys mode this is public
/ws; in authenticated mode it is/ws/{api_key}for a valid API key.The connection is accepted by the global and per-IP connection limiter.
Registry::subscribestarts a reader task for that client.The attacker sends large masked
BinaryorTextframes. The proxy does not need these frames for any feature.Axum/tungstenite reads, reassembles, unmasks, validates, and allocates those frames.
start_readermatches the resultingMessage::BinaryorMessage::Textthrough_ => {}and discards it.The attacker repeats this across multiple long-lived connections.
Memory and CPU consumption rise independently of upstream flashblock volume.
Under a realistic container memory cap, the kernel kills the proxy process.
Impact
The proven impact is external resource exhaustion of the websocket proxy. A client can make the proxy allocate and process large inbound WebSocket messages that are unused by the protocol.
The most direct contest category is:
If a fleet uses the same proxy configuration, the same attack can be run against each exposed instance. The PoC demonstrates full process death under a small but normal cgroup limit. It does not prove a total Base network shutdown, but it is a credible resource-exhaustion path for the public flashblocks proxy service.
Proof of Concept
The PoC is a Docker harness that builds and runs the stock websocket-proxy binary from a fresh checkout, caps the victim container's memory, starts a mock upstream WebSocket server, and then launches raw downstream WebSocket clients that repeatedly send large ignored binary frames.
The victim process is the repository's real websocket-proxy binary. The helper containers only provide a mock upstream and attacker clients so the issue is reproducible on a clean machine.
These steps start from a freshly cloned repository.
Step 3: run the Docker PoC
Run the harness from the repository root:
Default parameters:
The runner does the following:
Builds
f20-websocket-proxy-victim:latestfrombin/websocket-proxywith the stock source tree.Builds
f20-websocket-proxy-harness:latestwith only Python stdlib helper scripts.Starts an idle mock upstream WebSocket server so the proxy initializes normally.
Starts the stock proxy with
--memory 256mand--memory-swap 256mso swap does not hide the OOM.Opens 32 downstream
/wsclients and repeatedly sends valid masked 15 MiB binary frames.Polls Docker until the proxy exits, then saves logs and
docker inspectoutput.
The attack frame is intentionally below tungstenite's default 16 MiB frame limit. Raising FRAME_BYTES above that can test different failure behavior, but is not needed to prove this issue.
Useful tuning knobs:
Step 6: what is mocked
The victim is not mocked. The Docker image builds and runs the stock websocket-proxy binary from the checked-out repository.
The mocked pieces are only external test scaffolding:
mock_upstream.pyis an idle upstream WebSocket server so the proxy can start normally. The inbound-frame bug does not require any upstream flashblock broadcast.attack_inbound_frames.pyis the downstream attacker. It speaks the WebSocket protocol directly using Python's standard library and sends valid masked client frames.Docker resource exhaustion observable through
OOMKilled=true.
This reaches the vulnerable path through normal downstream WebSocket connections. The proxy reads, allocates, and discards the client binary frames before the process is killed.
Validation
PoC file syntax:
Result: passed.
Finding 1 Docker PoC:
Result: passed. Docker marked the victim proxy container OOMKilled=true with exit code 137.
Recommended Mitigation
Reject unexpected client data early and lower inbound limits:
Configure
WebSocketUpgradewith explicit smallmax_message_sizeandmax_frame_sizevalues appropriate for a one-directional downstream feed.In
start_reader, close the connection when receiving clientTextorBinaryframes instead of silently discarding them.Add a per-connection inbound byte/frame rate limiter if any client messages must remain supported.
Keep ping/pong handling, but make it explicit that only pong and close frames are accepted from downstream clients.
Add integration tests that send large inbound frames and assert the proxy closes the connection without large memory growth.
The safest invariant is:
Was this helpful?