For the complete documentation index, see llms.txt. This page is also available as Markdown.

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:

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-keys configured, which enables unauthenticated public /ws;

    • --api-keys configured, which enables authenticated /ws/{api_key};

    • --api-keys plus --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 WebSocketUpgrade path, which does not configure max_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 --memory and --memory-swap set to the same value, so swap does not hide the proxy memory failure.

Vulnerability Detail

1

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.

2

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.

3

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.

4

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.

5

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.

6

The PoC confirms Docker OOMKilled on stock code

The generated Docker victim is built from the repository's unmodified websocket-proxy binary. The attacker speaks raw WebSocket using Python's standard library and sends masked 15 MiB binary frames. Docker records:

Attack Path

  1. 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.

  2. The connection is accepted by the global and per-IP connection limiter.

  3. Registry::subscribe starts a reader task for that client.

  4. The attacker sends large masked Binary or Text frames. The proxy does not need these frames for any feature.

  5. Axum/tungstenite reads, reassembles, unmasks, validates, and allocates those frames.

  6. start_reader matches the resulting Message::Binary or Message::Text through _ => {} and discards it.

  7. The attacker repeats this across multiple long-lived connections.

  8. Memory and CPU consumption rise independently of upstream flashblock volume.

  9. 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.

1

Step 0: clone and checkout the vulnerable version

2

Step 1: add the PoC helper files

The upstream repository will not already contain the PoC helper files. From the repository root, apply this patch to create the Dockerfiles, mock upstream, raw WebSocket helper, attacker, and runner:

The runner created by the patch is:

3

Step 2: sanity-check local prerequisites

Make sure Docker is running and the helper files parse:

Expected: docker version and docker ps succeed, bash -n prints nothing, and the Python compile command exits with status 0.

4

Step 3: run the Docker PoC

Run the harness from the repository root:

Default parameters:

The runner does the following:

  1. Builds f20-websocket-proxy-victim:latest from bin/websocket-proxy with the stock source tree.

  2. Builds f20-websocket-proxy-harness:latest with only Python stdlib helper scripts.

  3. Starts an idle mock upstream WebSocket server so the proxy initializes normally.

  4. Starts the stock proxy with --memory 256m and --memory-swap 256m so swap does not hide the OOM.

  5. Opens 32 downstream /ws clients and repeatedly sends valid masked 15 MiB binary frames.

  6. Polls Docker until the proxy exits, then saves logs and docker inspect output.

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:

5

Step 4: expected output

A successful run prints output like:

The important fields are:

That means Docker recorded Status=exited, OOMKilled=true, and ExitCode=137 for the victim proxy container.

6

Step 5: inspect the saved evidence

The runner writes artifacts under:

Confirm the Docker OOM state:

Expected evidence:

Check the attacker log if desired:

It should show the raw WebSocket clients repeatedly reconnecting or progressing as the victim dies.

7

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.py is 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.py is 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.

Reject unexpected client data early and lower inbound limits:

  1. Configure WebSocketUpgrade with explicit small max_message_size and max_frame_size values appropriate for a one-directional downstream feed.

  2. In start_reader, close the connection when receiving client Text or Binary frames instead of silently discarding them.

  3. Add a per-connection inbound byte/frame rate limiter if any client messages must remain supported.

  4. Keep ping/pong handling, but make it explicit that only pong and close frames are accepted from downstream clients.

  5. 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?