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

76025 bc low unbounded tokio spawn and info level logging on payload by number sync stream log cpu memory dos reachable from any connected peer

Submitted on May 2nd 2026 at 09:39:15 UTC by @coffee_boi for Audit Comp | Base Azul

  • Report ID: #76025

  • 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

Summary

Base's consensus-layer gossip driver accepts inbound libp2p substreams on the /opstack/req/payload_by_number/{chain_id}/0/ protocol and dispatches them through a stub handler in crates/consensus/gossip/src/driver.rs:142-173. For every inbound substream the driver:

  1. Emits an info! log line tagged with the source peer ID (driver.rs:155).

  2. Spawns a fresh tokio task that owns the substream until it has written the 2-byte "not-found" response (driver.rs:157-170).

There is no per-peer concurrency cap, no global rate limit, no per-connection request limit, and no yamux substream limit configured. A peer that has already cleared the connection-limits behaviour (the cap added in #2432, default MAX_ESTABLISHED_PER_PEER = 1, MAX_ESTABLISHED = 30) can multiplex an effectively unbounded number of substreams over its single accepted connection at the yamux layer's natural pacing. Each substream open costs the attacker one yamux SYN frame; on the receiver it costs one tokio task allocation, one heap-allocated tracing event, plus whatever yamux receive-window memory sits unread for the lifetime of the substream because the handler never reads the request payload.

Because the substream is treated as a fire-and-forget two-byte write, the actual cost is dominated by:

  • info! log volume. Sustained substream open rate × ≈ 130 bytes per JSON / human log line. At realistic yamux pacing (single connection, single peer) this trivially saturates 10–100 MB/s of log output - enough to fill a node's local disk, push journald past its ratelimit (default RateLimitBurst=10000), or back-pressure structured-log forwarders (vector / fluentbit) onto the gossip task.

  • tokio::spawn task allocation. Each inbound substream allocates a new task on the multi-threaded runtime. Sustained spawn rate dominates the work the swarm task can do, since every spawn happens on the same task that drives the gossip stream.

  • Unread yamux receive-window memory. The handler never reads from inbound_stream, so the attacker can fill the per-stream yamux receive window (256 KB default in libp2p-yamux 0.47.0) on every substream before being window-throttled. With many concurrent substreams this is a slow but real heap-pressure source on top of the spawn rate.

This is reachable today by any peer that can complete the libp2p noise handshake and establish a single connection; no validator role, no Sybil, no gossip publishing, no protocol violation needed. Peer scoring (light mode in CLI defaults) does not lower scores for opening inbound substreams on this protocol - only for gossip misbehavior.

Details

The handler is registered in crates/consensus/gossip/src/builder.rs:188-195:

libp2p_stream::Behaviour::accept returns an IncomingStreams that yields one (PeerId, Stream) for every inbound substream that negotiates that protocol. The driver consumes it in crates/consensus/gossip/src/driver.rs:142-173:

Every iteration of the outer loop:

  1. Calls info!(...) - at the project-default log filter (info for gossip, see init_test_tracing and the production CLI defaults), this always emits a record.

  2. Calls tokio::spawn(...) - unconditionally.

  3. The spawned task does no read, no parse, no peer lookup, no rate-limit bookkeeping. It writes 2 constant bytes and exits.

There is no:

  • per-peer request counter / cooldown

  • global semaphore

  • yamux max_num_streams override (built with YamuxConfig::default in crates/consensus/gossip/src/builder.rs:200-216; default in libp2p-yamux 0.47 is generous)

  • short-circuit when the daily/hourly volume is anomalous

  • per-source IP throttle (this is L7; the connection-limits behaviour added in #2432 only counts established connections, not substreams over them)

Why the connection-limits PR (#2432) does not contain this

PR #2432 (merged 2026-04-29) installed libp2p::connection_limits::Behaviour with MAX_ESTABLISHED_INCOMING = peers_hi, MAX_ESTABLISHED_PER_PEER = 1, MAX_PENDING_INCOMING = 5. Those caps are correct and useful, but they apply at the connection layer. The payload_by_number flood happens at the substream layer - yamux multiplexes many substreams over one accepted connection. From the attacker's perspective:

  • Establish 1 connection (within the cap of 1 per peer, 30 total).

  • Open as many /opstack/req/payload_by_number/... substreams as yamux's local window allows. yamux 0.13 default permits hundreds of concurrent substreams per connection and re-uses substream IDs as they close, so sustained throughput is high.

  • Each substream open is one yamux SYN frame on the wire (a few bytes). Each one fires the handler above.

So #2432 raised the bar to "have one connection accepted by the victim". Once that bar is cleared, this finding is independent of #2432 and is exploitable.

Quantifying the cost (back-of-envelope)

Empirical pacing for yamux substream open over a localhost connection sits around 1–5 µs per open, mostly bounded by tokio scheduling and yamux frame parsing. Even at a conservative 20 µs/open the attacker can drive 50,000 substream opens/sec from a single connection. That is:

  • 50,000 info! records/sec × ~130 bytes ≈ 6.5 MB/s of log output.

  • 50,000 tokio::spawn tasks/sec on the same multi-threaded runtime that the gossip event loop runs on.

  • Up to 256 KB of unread yamux receive window per substream held until the substream closes (microseconds for the 2-byte write, but the attacker controls when the close happens by stalling its FIN; with 1,000 concurrent substreams that is 256 MB of yamux buffer pinned).

journald on a typical Linux deployment with default RateLimitInterval=30s, RateLimitBurst=10000 will start dropping gossip records under this load (and emit its own Suppressed N messages from ... records, which can themselves be rate-limited). Dropped log records mean observability collapse on the gossip subsystem while the attack is in flight - exactly when operators need it most. If structured logs are forwarded via vector / fluentbit / promtail with a bounded in-memory queue, the queue saturates and the gossip task experiences back-pressure on info! calls.

Why peer scoring does not contain this

Peer scoring in this codebase is wired only into behaviour.gossipsub (crates/consensus/gossip/src/builder.rs:170-184: behaviour.gossipsub.with_peer_score(...)). The libp2p_stream behaviour that backs the payload_by_number request/response path (crates/consensus/gossip/src/behaviour.rs:39-41, pub sync_req_resp: libp2p_stream::Behaviour) is a separate sub-behaviour with no scoring hook. So production peer scoring (any level) does not score this request/response substream path; it tracks only gossipsub mesh / message behaviour.

Concretely, the light profile (CLI default in crates/client/cli/src/p2p.rs:154-157) lowers scores on mesh failures, invalid gossipsub messages, and app-level Reject from BlockHandler - none of which apply to opening a payload_by_number substream. The peer_monitoring ban path (the only place that calls peerstore.remove(...)) therefore never fires for this attacker. The PoC runs against a devnet with --p2p.scoring Off, but enabling production scoring on a real node would not change reachability of this handler.

Likelihood explanation

High.

  1. Permissionless reach: any libp2p peer that has completed the noise handshake and one of the 30 inbound connection slots can run the attack. The peer already exists in the wild - discv5 is open, and inbound peers connect on their own.

  2. No required state: the attacker does not need a validator key, a Sybil swarm, snappy/SSZ knowledge, or a chain-aware payload. One connection + one substream-open loop is sufficient.

  3. No detection signal currently fires: gossip_peer_count is unaffected (the connection stays established), and the info! records are the attacker's primary effect, so there is no separate signal that is not already drowning in attacker noise.

Recommendation

Apply three independent fixes; each addresses a different class of cost.

1. Drop the tokio::spawn and respond inline

The handler's only job is to write 2 constant bytes. Inline that work in the outer loop's task - there is no I/O wait that benefits from per-stream concurrency, and writing 2 bytes through yamux is non-blocking after the first poll. This eliminates the per-substream task allocation:

This collapses the cost to one task globally for the entire sync protocol, regardless of substream rate.

2. Demote the per-request log to trace!

Change info! at driver.rs:155 to trace! (or remove it entirely; the spec is explicit that this protocol is being phased out). Replace it with a periodic aggregate metric Metrics::sync_requests_per_minute() so operators retain visibility without one record per stream.

3. Cap per-peer concurrent substreams on the sync protocol

Even with 1 + 2, an attacker can still drive substream open/close rate at high frequency. Add a per-peer in-flight counter and reject (close immediately) when a peer exceeds, say, 4 concurrent open substreams on this protocol. This neutralises the heap-pressure vector from the unread yamux receive window:

Couple this with the existing peer-score system: a peer that hits the per-peer cap repeatedly should have its score lowered, so the eventual ban path in service/src/actors/network/handler.rs:74-94 actually fires for substream-flood attackers.

4. (Defensive) Configure yamux max_num_streams

In crates/consensus/gossip/src/builder.rs:200-216, replace YamuxConfig::default with an explicit configuration that caps inbound substreams per connection. A reasonable value for Base - which only uses gossipsub plus this stub sync protocol - is on the order of 16 inbound substreams per connection. This is a defense-in-depth backstop that does not depend on the application-layer counters in fix #3.

Proof of Concept

Devnet PoC

A working Docker-devnet PoC, raw artefacts, and a measurement harness live alongside this finding:

  • Attacker crate: attacks/sync-flood/ - a libp2p TCP/noise/yamux client that opens substreams on /opstack/req/payload_by_number/<chain>/0/ over a single connection.

  • Measurement harness: attacks/sync-flood/run-poc.sh - phased baseline / attack / recovery, sampling process_resident_memory_bytes, process_cpu_seconds_total, victim log-line counts, and opp2p_peerCount once per second.

The canonical run targets the follower consensus node (base-client-cl, port 8003, RPC 8549, metrics 8300) so the sequencer's CPU/log signal is not muddied. Headline result from a single attacker, single TCP connection, 40 s burst:

Metric
Baseline (20 s)
Attack (40 s)
Recovery (20 s)

Received a sync request lines

0

30,766

0

Peak log rate

0/s

~900/s

0/s

process_cpu_seconds_total delta

0 s

22 s

2 s

opp2p_peerCount.connectedGossip

1

2 (1 + atk)

1

The 30,766 victim log lines all carry the same attacker peer_id, confirming this is a single-peer / single-connection cost - not a Sybil or connection-count attack. See the results file for the raw summary.txt, attacker tail, victim log samples, and unique-peer-id check.

Reproducer:

Output

summary.txt (verbatim)

Last 5 attacker progress lines (/tmp/sync-flood-poc/attacker.log)

First 5 victim sync-request log lines (/tmp/sync-flood-poc/cl.log)

Single-peer / single-connection proof

Unique attacker peer-ids observed across all 30,766 sync-request log lines:

opp2p_peerCount sampled across the run (one row per phase):

Both confirm: one attacker peer-id, one TCP connection above the baseline link, 30,766 handler invocations.

Was this helpful?