75410 bc high cross topic message id collision in gossipsub allows attacker to censor blocks from network nodes
Submitted on Apr 29th 2026 at 01:35:05 UTC by @DeltaXV for Audit Comp | Base Azul
Report ID: #75410
Report Type: Blockchain/DLT
Report severity: High
Target: https://github.com/base/base/tree/v0.8.0-rc.28
Impacts:
Unintended chain split (network partition)
Temporary freezing of network transactions by delaying one block by 500% or more of the average block time of the preceding 24 hours beyond standard difficulty adjustments
Description
Brief/Intro
Base's gossipsub compute_message_id function computes message IDs from SHA256(domain || data) without including the topic. This follows the L1-inherited spec, which was safe on L1 where each topic carries a distinct payload type. However, Base's multi-version block topic architecture (V1 through V4) creates a situation the L1 spec never anticipated: the sequencer publishes identical compressed wire bytes that get routed to different SSZ decoders based on the topic. Because gossipsub maintains a single global duplicate_cache keyed by message ID, and inserts the ID before the application validator runs, an attacker can suppress legitimate blocks by republishing the sequencer's block data on a wrong-version topic. The wrong-topic copy fails SSZ decoding and is rejected, but the message ID is already cached — so the legitimate copy is silently dropped as a duplicate when it arrives. The victim node never receives the block. The attack can be sustained per-block, freezing the victim's unsafe head indefinitely until L1 derivation catches up (minutes to tens of minutes).
Vulnerability Details
Root Cause
The function compute_message_id in base/crates/consensus/gossip/src/config.rs:104 computes:
The hash is SHA256(domain || decompressed_data)[:20]. The msg.topic is not included.
This follows the L1 Ethereum consensus spec which uses the same formula. On L1, this is safe because each gossip topic carries a fundamentally different payload type (beacon blocks, attestations, etc.) — identical bytes across topics are practically impossible.
Base's architecture breaks this assumption. Base subscribes to four block topics simultaneously (V1 through V4), where the wire format is nearly identical across versions. Specifically, V4 data is V3 data with an appended withdrawals_root field — the signature, parent beacon root, and compressed SSZ payload prefix are byte-for-byte identical. This means the sequencer's V4 block, re-published on the V3 topic, produces the exact same message ID. gossipsub's global duplicate_cache then treats the two as duplicates.
Why This Is Exploitable
gossipsub (libp2p-gossipsub v0.49.4, used by Base) maintains a single global duplicate_cache that is not per-topic. When a message is received, the message ID is inserted into this cache before the application-level validator runs. The relevant code in libp2p-gossipsub/src/behaviour.rs:1781:
The duplicate_cache is write-only — there is no remove method anywhere in the gossipsub source. Once a message ID is inserted, it remains for duplicate_cache_time (120 seconds in Base's config at config.rs:89). A Reject result from the application validator removes the message from mcache but not from duplicate_cache.
Attack Path
Attacker republishes the same bytes on V3
The attacker receives the block from the mesh and immediately re-publishes the exact same compressed bytes on the V3 topic to the victim node. If the attacker has lower network latency to the victim than the sequencer does (e.g. geographically closer, or the sequencer is multiple mesh hops away), the V3 copy arrives first.
Victim caches the wrong-topic message ID
The victim's gossipsub layer receives the V3 message:
Computes
msg_id = SHA256([0x01,0,0,0] || decompress(data))[:20]message_is_valid()passes (the attacker is not blacklisted)duplicate_cache.insert(msg_id)→ inserts the ID (point of no return)Delivers
Event::MessagetoBlockHandler::handle()Handler dispatches to
decode_v3()because the topic is V3ExecutionPayloadV3::from_ssz_bytes()fails on V4-encoded data (V4 has an additionalwithdrawals_rootfield that changes the SSZ offset table)Handler returns
(MessageAcceptance::Reject, None)report_message_validation_result(Reject)removes frommcache, but not fromduplicate_cache
Legitimate V4 block is dropped
The legitimate V4 message arrives at the victim:
gossipsub computes
msg_id = SHA256([0x01,0,0,0] || decompress(data))[:20]— identical to step 4 (same data, topic not in hash)duplicate_cache.insert(msg_id)returnsfalse→ message is silently droppedThe victim's
BlockHandlernever sees the valid V4 block
References
base/crates/consensus/gossip/src/config.rs:104-122—compute_message_idomits topiclibp2p-gossipsub behaviour.rs:1781 —
duplicate_cacheinsertion before validator runsspecs.base.org/protocol/consensus/p2p — message ID spec (inherited from L1)
Link to Proof of Concept
https://gist.github.com/DeltaXV/5d9371841dd53439b20cdd431879088b
Proof of Concept
The PoC consists of two tests added directly to the Base codebase, using the existing test infrastructure. Gist with all tests as well -> https://gist.github.com/DeltaXV/5d9371841dd53439b20cdd431879088b
Within the base/crates/consensus/gossip/src/config.rs test suite paste the following test at the end of the file:
And do the same for base/crates/consensus/gossip/src/handler.rs:
How to run
Test output
Both tests passing confirms the vulnerability: the assert_eq on message IDs proves the collision, and the handler test proves a valid V4 block is accepted on V4 but rejected on V3, with identical message IDs in both cases.
Mitigation
Include the topic string and its length in the message ID hash. This goes beyond the L1-inherited spec but is necessary for Base's multi-version topic architecture where identical wire bytes can appear on different topics:
Was this helpful?