The flood limiter that never fires: a DoS in Base's consensus gossip
Base's gossip layer caps how many blocks a node will accept per height, to stop flooding. But it only recorded a block after checking its signature, so blocks with forged signatures were never counted. The limiter guarded a list the attack never filled, and every junk block still cost a full ECDSA recovery.
Blockchain nodes do not talk to one point of authority; they gossip. Every node relays new blocks to its peers, who relay them onward, and that is how a block reaches the whole network in seconds. Anything that whole peers can send you freely is also something a malicious peer can abuse, so the code that receives gossiped blocks needs a flood limiter: a cap on how much junk one peer can make you chew on. Base has such a limiter. This finding is about it quietly not working against exactly the traffic it was built to stop. It was submitted to the Base Azul audit competition and selected as the lead report.
The two moving parts
-
The flood limiter. For each block height, the validator keeps a small
set of the block hashes it has already seen,
seen_hashes, and refuses to accept more than a handful of distinct blocks at that height (MAX_BLOCKS_TO_KEEP = 5). The idea is that a peer cannot make you process an endless stream of different blocks all claiming the same height. -
The signature check. A real block is signed by the sequencer, and the
node verifies that with an ECDSA recovery (
ecrecover). That operation is deliberately expensive, it is elliptic-curve maths, and doing a lot of them is exactly the cost you do not want an attacker to be able to force.
The order the checks ran in
Here is the shape of the validator. Watch where the map actually gets written:
if let Some(seen_hashes_at_height) =
self.seen_hashes.get_mut(&envelope.payload.block_number())
{
// flood limiter: reads seen_hashes...
if seen_hashes_at_height.len() > Self::MAX_BLOCKS_TO_KEEP {
return Err(BlockInvalidError::TooManyBlocks { .. });
}
if seen_hashes_at_height.contains(&envelope.payload.block_hash()) {
return Err(BlockInvalidError::BlockSeen { .. });
}
}
// ...then the expensive signature check runs here
let Ok(msg_signer) = envelope.signature.recover_address_from_prehash(&msg) else {
return Err(BlockInvalidError::Signature);
};
if msg_signer != block_signer {
return Err(BlockInvalidError::Signer { .. });
}
// ...and only a VALID block is ever recorded in seen_hashes
self.seen_hashes
.entry(envelope.payload.block_number())
.or_default()
.insert(envelope.payload.block_hash());
The flood limiter reads seen_hashes, but the line that writes to
seen_hashes sits after the signature check and only runs on success. A
forged-signature block fails and returns before that write, so it is never recorded.
Send a stream of blocks that all have unique hashes and garbage signatures, and
seen_hashes stays empty for every one of them. The counter the limiter
checks never moves, so TooManyBlocks never triggers, and the node keeps
running a full ecrecover on every message forever.
In one sentence: the guard and the state that makes the guard work are separated by the exact expensive operation the guard was meant to protect. Legitimate blocks get counted and capped; the attacker's blocks fail earlier and are never counted, so for them the cap does not exist.
Proof of concept
The test sends ten times the cap in forged-signature blocks at a single height and checks what the validator did with them.
#[test]
fn test_invalid_signature_blocks_bypass_flood_limiter() {
let mut handler = /* ... BlockHandler for base-mainnet ... */;
let target_height = 12345_u64;
let spam_count = BlockHandler::MAX_BLOCKS_TO_KEEP * 10; // 50
let mut signature_errors = 0usize;
let mut flood_errors = 0usize;
for _ in 0..spam_count {
// a uniquely-hashed block at the same height, with a junk signature
let envelope = forged_envelope_at(target_height);
match handler.block_valid(&envelope) {
Err(BlockInvalidError::Signer { .. }) => signature_errors += 1,
Err(BlockInvalidError::TooManyBlocks { .. }) => flood_errors += 1,
_ => {}
}
}
assert_eq!(signature_errors, spam_count); // all 50 rejected on signature
assert_eq!(flood_errors, 0); // the flood limiter NEVER fires
assert!(handler.seen_hashes.is_empty()); // and nothing was ever recorded
}
running 1 test
test block_validity::tests::test_invalid_signature_blocks_bypass_flood_limiter ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; finished in 6.99s
All fifty blocks are rejected on the signature, TooManyBlocks is raised zero
times, and seen_hashes stays empty. The limiter is fully bypassed, and the
node has done fifty expensive recoveries for nothing. Scale that to a real gossip stream
and it is sustained, unbounded CPU load on the target, which is what maps this to the
program's Medium bar: raising a node's resource consumption without brute force.
The fix
Count the block before you spend the expensive operation on it. Move the write into
seen_hashes ahead of the signature check, so every unique hash, valid or
forged, is tracked and counted against the per-height cap:
// record the block FIRST, so the flood limiter sees all traffic
self.seen_hashes
.entry(envelope.payload.block_number())
.or_default()
.insert(envelope.payload.block_hash());
// ...then run the expensive signature check
let Ok(msg_signer) = envelope.signature.recover_address_from_prehash(&msg) else {
return Err(BlockInvalidError::Signature);
};
Now a burst of unique forged hashes fills the per-height set and trips
TooManyBlocks just as it would for real blocks, and the attacker can no longer
make the node recover signatures without limit.
Key takeaways
- A rate limiter is only as good as when it updates its counter. If the counter is written after the expensive work, the expensive work is not rate limited.
- Do the cheap rejection before the costly one. Counting a hash is nearly free; an
ecrecoveris not, so the cap has to come first. - Guards fail silently when the state they read is populated somewhere the attack never reaches. Trace who writes the data your check depends on.
- Denial of service on a node is not just downtime; forcing everyone's nodes to burn CPU is a cheap, unauthenticated way to degrade a whole network.