74664 bc insight unauthenticated admin postunsafepayload rpc chains with silent isthmus withdrawals root validator bypass to poison any base consensus node s unsafe head and stall finality
Submitted on Apr 24th 2026 at 05:22:22 UTC by @empmirage for Audit Comp | Base Azul
Report ID: #74664
Report Type: Blockchain/DLT
Report severity: Insight
Target: https://github.com/base/base/releases/tag/v0.8.0-rc.15
Impacts:
A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk
Description
Brief / Intro
Every Base consensus node running the Azul code with default configuration exposes a JSON-RPC server on 0.0.0.0:9545 that silently ignores the --rpc.enable-admin flag and mounts the admin_postUnsafePayload method with no authentication, no signature check, and no sequencer-key gating. An unauthenticated remote attacker can POST an arbitrary BaseExecutionPayloadEnvelope to this method; the payload is forwarded to the engine actor as EngineActorRequest::ProcessUnsafeL2BlockRequest, which calls engine_newPayloadV4 followed by a Forkchoice Update. During that validation, a second bug in BaseEngineValidator::validate_block_post_execution_with_hashed_state (crates/execution/node/src/engine.rs:130) silently returns Ok(()) whenever the parent block's state is not yet in the canonical state provider — a condition the attacker reliably creates by chaining two back-to-back injected blocks. The Isthmus withdrawals_root check therefore never runs, and a block carrying an attacker-chosen withdrawals_root is accepted and FCU'd to the unsafe head. Because post-Isthmus optimism_outputAtBlock (crates/consensus/engine/src/query.rs:104-109) derives the output root directly from header.withdrawals_root, every affected L2 height returns a corrupted outputRoot from the poisoned node's RPC. Downstream proposers / indexers / challengers that rely on that RPC propose bogus rootClaims to L1, and because the TEE and ZK proof systems recompute the real L2ToL1MessagePasser storage root, no valid proof ever matches — producing a withdrawal-finality stall until the operator manually reorgs the bad block out. The attack requires only HTTP reachability to port 9545; it does not require the sequencer key, p2p access, L1 access, or any existing credential.
Vulnerability Details
The attack chain has three primitives, all of which are verifiable by file:line inspection and all three of which fire together in the attached PoC.
Primitive 1 — admin_postUnsafePayload is mounted without authentication or a feature flag
crates/client/cli/src/rpc.rs:25-32 binds the consensus-layer RPC server to 0.0.0.0:9545 by default and defines --rpc.enable-admin:
crates/consensus/service/src/actors/rpc/actor.rs:119-123 then mounts AdminRpc based only on whether the network_admin channel is Some, not on self.config.enable_admin():
crates/consensus/service/src/service/node.rs:552 passes network_admin: Some(net_admin_rpc) unconditionally at node startup. The --rpc.enable-admin flag is therefore dead — the admin module is always loaded.
The method itself (crates/consensus/rpc/src/admin.rs:70-81) has no authentication, no signature check, and no sequencer guard; the in-source comment admits this:
The jsonrpsee Server is launched (crates/consensus/service/src/actors/rpc/actor.rs:71-80) with only TimeoutLayer, ConcurrencyLimitLayer, LoadShedLayer, EthHealthCheckLayer, and ProxyGetRequestLayer — no authentication middleware anywhere.
Primitive 2 — Network actor forwards the unauthenticated payload to the engine, bypassing gossip signature validation
crates/consensus/service/src/actors/network/actor.rs:203-208 receives the NetworkAdminQuery::PostUnsafePayload from the channel and forwards it directly to the engine:
The production engine_client is QueuedNetworkEngineClient, which converts the payload into EngineActorRequest::ProcessUnsafeL2BlockRequest(payload) and enqueues it for the engine actor. The gossip code path (one select! branch above) that validates unsafe-block signer signatures is not taken. The attacker's unsigned payload jumps directly into the engine queue.
Primitive 3 — Isthmus withdrawals_root check silently returns Ok(()) when parent state is unavailable
When the engine actor pulls ProcessUnsafeL2BlockRequest off the queue, it drives engine_newPayloadV4 followed by a Forkchoice Update (crates/consensus/engine/src/task_queue/tasks/insert/task.rs:86-136). During the new_payload flow, Reth calls BaseEngineValidator::validate_block_post_execution_with_hashed_state (crates/execution/node/src/engine.rs:124-152):
When provider.state_by_block_hash(block.parent_hash()) returns Err (e.g. the parent is only in the in-memory engine tree, not yet canonicalized), control takes the let … else branch and returns Ok(()) — the verify_withdrawals_root_prehashed call is skipped entirely. The source itself carries a FIXME acknowledging the gap.
An attacker creates the required condition by injecting two blocks back-to-back via admin_postUnsafePayload: the first extends the current canonical tip (it has valid withdrawals_root), and the second is an immediate child of that first block, with parent_hash = first_block.hash and an attacker-chosen withdrawals_root. On the second injection, the parent is still in the in-memory tree and state_by_block_hash returns Err, so the bypass fires and the bogus block is accepted. The subsequent Forkchoice Update makes the bogus block the unsafe head.
validate_block_post_execution_with_hashed_state is called exactly once at crates/execution/engine-tree/src/validator.rs:1108-1115; there is no second pass after canonicalization, so the bogus withdrawals_root persists in the on-disk header.
How the chain corrupts optimism_outputAtBlock
crates/consensus/engine/src/query.rs:102-123 (the output_at_block handler behind optimism_outputAtBlock) derives the output root directly from output_block.header.withdrawals_root post-Isthmus, with no cross-check against actual state:
The prover side, in contrast, reads the real L2ToL1MessagePasser storage root (crates/proof/host/src/handler.rs:245-266):
So the RPC's output_root (built from the attacker's bogus header.withdrawals_root) diverges from the prover's output_root (built from real state). Any TEE or ZK proof for the affected block height commits to the correct root and therefore cannot match what was proposed to the AggregateVerifier on L1, causing proof-based finality to stall for that block.
Impact Details
Classification: High — temporary freezing of withdrawals + output-root poisoning of any exposed Base consensus node.
Concrete impacts an unauthenticated remote attacker achieves against any Base consensus node reachable on tcp/9545:
Poisoned unsafe head and RPC output. After a single
curlpair (two chainedadmin_postUnsafePayloadcalls), the victim node'soptimism_outputAtBlockreturns anoutputRootderived from the attacker's chosen 32-byte value. Any downstream system (bridge UI, indexer, proposer, challenger, archival explorer) reading that RPC sees the corrupted root.Withdrawal finalization stall on the L1 side for every block a poisoned proposer proposes. Base's TEE/ZK proposers call
optimism_outputAtBlockon a consensus node to obtain therootClaimthey submit to theAggregateVerifierdispute game on L1. If the proposer's reference node is poisoned, the proposer submits arootClaimderived from the attacker'swithdrawals_root. The TEE enclave / ZK prover recomputes the real L2ToL1MessagePasser storage root and cannot produce a journal that matches the proposed root, so no proof ever verifies. The game sits atproofCount < PROOF_THRESHOLDuntil manual remediation (blacklist / anchor-state intervention / patched redeployment). For the duration of this stall, withdrawals from L2→L1 cannot finalize at or after the poisoned block height.Griefing the proposer's bond. The proposer's L1 transaction includes
INIT_BOND, configured today on the Azul zeronet (live, chain 560048) as 0.05 ETH percontract-deployments/zeronet/2026-04-01-activate-multiproof/.env. Base mainnet's adjacent game types (GameTypes.CANNON/CANNON_KONA) currently use 0.08 ETH per the executed upgrade-18 transaction. Until the Guardian blacklists the game, that bond is locked insideDelayedWETHunder the corrupted game. Repeated poisoning locks multiple bonds simultaneously — one per every poisoned proposal the downstream proposer submits to L1.Remote consensus-node DoS / reorg fighting. Because the injected unsafe head diverges from the real sequencer's chain, the victim node will reorg back to the sequencer's chain when the next signed unsafe block arrives — but during the window, all of its RPC responses reflect the attacker's fork. Repeated injection keeps the node churning through FCUs.
Blast radius = every publicly-reachable Base consensus node with default config. The default
--rpc.addr=0.0.0.0incrates/client/cli/src/rpc.rs:25exposes port 9545 to the Internet unless the operator firewalls it. The dead--rpc.enable-adminflag means operators who believed they had disabled the admin namespace are still exposed. Any RPC provider, indexer, exchange, or DApp operator running Base consensus with defaults is a target.
The reason this is not Critical: the L1 proof system (TEE + ZK AggregateVerifier) refuses the bogus root because it recomputes storage root from actual state, so user funds are never stolen — only delayed. A bogus root cannot finalize onto L1.
References
Audited artifact (asset):
https://github.com/base/base/releases/tag/v0.8.0-rc.15(Blockchain/DLT). All file:line references in this report are pinned to that tag and are byte-identical in the audited tree.Downstream consumer of the poisoned
optimism_outputAtBlock:https://github.com/base/contracts/tree/v8.1.0/src/multiproof(theAggregateVerifierdispute game that rejects the bogus root, causing finality stall).Base mainnet live
DisputeGameFactoryinit-bond reference for adjacent game types:0x43edB88C8F946aB7d8eFB2c4B4E01Ba8a0D7b3cE(BaseDisputeGameFactoryProxy),initBonds[GameTypes.CANNON_KONA] = 0x011c37937e080000= 0.08 ETH, confirmed by the executed mainnet upgrade 18 transaction (contract-deployments/mainnet/2026-01-09-op-stack-upgrade-18).Base Azul zeronet (Hoodi chain 560048) live multiproof
AggregateVerifierinit-bond:0.05 ETH, set bycontract-deployments/zeronet/2026-04-01-activate-multiproof/.env→INIT_BOND=50000000000000000.Relevant Base specs:
Isthmus hardfork exec-engine:
https://specs.base.org/upgrades/isthmus/exec-engine#l2tol1messagepasser-storage-root-in-headerConsensus P2P block signatures:
https://specs.base.org/protocol/consensus/p2p#block-signatures
In-source admissions that the bugs are known:
crates/execution/node/src/engine.rs:131-134—// FIXME: we don't necessarily have access to the parent block here because the parent block isn't necessarily part of the canonical chain yet.crates/consensus/rpc/src/admin.rs:74-75—// Note: intentionally no sequencer guard here. Posting an unsafe payload is a P2P/gossip operation that is valid on both sequencer and validator nodes.
Proof of Concept
A single-command, self-contained Rust integration test that wires the real production components (AdminRpc, NetworkActor, QueuedNetworkEngineClient, BaseEngineValidator) into one process, drives them over real HTTP with no auth, and proves the end-to-end chain.
Files added
base-base/crates/consensus/service/tests/admin_rpc_to_engine_validator_poc.rs— the harnessbase-base/crates/consensus/service/Cargo.toml— dev dependencies /[[test]]entry
What the PoC exercises
Asserts the engine request and validator behavior
Asserts that QueuedNetworkEngineClient produced exactly one EngineActorRequest::ProcessUnsafeL2BlockRequest(payload) containing the attacker's envelope.
Takes the forwarded payload, converts it to a Block<BaseTxEnvelope>, and runs the actual BaseEngineValidator::validate_block_post_execution_with_hashed_state against it twice:
With a
FailingStateByHashProviderthat wrapsNoopProviderand returnsErr(ProviderError::StateForHashNotFound)fromstate_by_block_hash(this emulates the in-memory-tree parent condition a second chained injection produces). AssertsOk(())— bypass fires, boguswithdrawals_root = 0xDE..DEaccepted.With a plain
NoopProviderwhosestate_by_block_hashreturnsOk(...)(parent state available). AssertsErr(ConsensusError)— baseline confirms the check would normally reject the same block.
How to run
Expected output
Process exits with status 0 on success (assertions inside run_poc panic on mismatch, and the harness uses libc::_exit to avoid a workspace libmdbx atexit abort unrelated to the vulnerability).
The PoC file, in full
crates/consensus/service/Cargo.toml — exact delta required
The PoC needs a small set of additional dev-dependencies and its own [[test]] entry. Apply this unified diff against the v0.8.0-rc.15 tag:
Every dependency the PoC uses (libc, anyhow, async-trait, tokio, tokio-util, jsonrpsee, alloy-*, base-*, reth-*) is already declared at the workspace root (Cargo.toml of the base-base repo) or resolves transitively through the additions above — no further changes required. harness = false is essential because the PoC uses #[tokio::main] as its own entry point rather than the standard libtest harness.
Funds at risk / attack economics
Target selection cost: $0 — scan
tcp/9545on Base RPC providers, indexers, and proposer infrastructure. The default--rpc.addr=0.0.0.0makes every operator who did not explicitly firewall the port reachable.Exploit execution cost: $0 — two unauthenticated HTTP JSON-RPC POSTs; no bond, no L1 gas, no signing key.
Value locked per stalled dispute game on L1:
On the Azul zeronet deployment (live today, chain 560048),
DisputeGameFactory.initBonds[GameType(621)] = 0.05 ETH, set bycontract-deployments/zeronet/2026-04-01-activate-multiproof/.env → INIT_BOND=50000000000000000.On Base mainnet today, adjacent game types (
GameTypes.CANNON,CANNON_KONA) carryinitBonds = 0.08 ETH(0x011c37937e080000), confirmed by the executed upgrade-18 transaction. The mainnetAggregateVerifierbond has not yet been announced but is expected to be in the same 0.05–0.08 ETH range. Every poisoned proposal locks one bond inDelayedWETHunder the corrupted game until Guardian blacklist.
Value delayed per stalled L2 height: the full set of pending L2→L1 withdrawals finalizing at or after the poisoned block's L2 epoch is held until manual remediation. Base L2 has consistently >$1B TVL and runs ~7-day finality windows today. A single corrupted block that passes the bypass is sufficient to stall withdrawals for all users trying to exit through that height.
Persistence: even after the operator reorgs the bad block out of the unsafe head, the bogus
rootClaimalready submitted to L1 by the poisoned proposer sits on-chain inside anAggregateVerifierclone, consuming a live bond and a dispute-game slot until explicitly invalidated.
Suggested fix
crates/consensus/service/src/actors/rpc/actor.rs:119-123— gate theAdminRpcmerge onself.config.enable_admin()so the CLI flag actually does what its name claims:crates/client/cli/src/rpc.rs:25— change the default--rpc.addrto127.0.0.1. The admin RPC is an operator tool, not an Internet-facing service.crates/consensus/rpc/src/admin.rs:70-81— add a JWT check toadmin_post_unsafe_payload(align withengine_*API conventions) or gate it on a sequencer signer key.crates/execution/node/src/engine.rs:130— resolve theFIXME: pass the in-memory engine tree into the validator so thestate_by_block_hashfallback can be satisfied, or fail closed (return Err(ConsensusError::Other("parent state not available for isthmus withdrawals_root check"))). Never silently skip a consensus check.
Any one of (1), (2), or (3) closes the remote-attacker reachability. Fix (4) closes the logical-skip even in legitimate sync scenarios and should be applied in addition.
Was this helpful?