← Writeups

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 Immunefi submission #74849 for Base Azul: P2P Gossip Flood Limiter Bypass via Invalid-Signature Block Spam, marked Medium, Paid, Audit Competition, Chief Finding
Immunefi submission #74849 on the Base Azul audit competition: Medium, paid, and marked a Chief Finding.

The two moving parts

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 limiter guards a list the attack never fills

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 ecrecover is 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.
Denial of Service Consensus Blockchain P2P
Share on X

More writeups