On June 21, 2026, Taiko’s Ethereum L1 bridge accepted forged Taiko source-chain message proofs and released assets from L1 custody. The attacker 0x7506DeA0c38ca0B55364B22424374c5A1ae1B76a first registered fresh SGX verifier instances, submitted a Taiko checkpoint, and used that checkpoint to mark multiple bridge messages as valid or retriable on Ethereum. Follow-up transactions then released ERC20 tokens from the Taiko ERC20Vault and ETH from the Bridge; Blockaid estimated the incident at more than $1M, and local L1 evidence confirms releases including USDC, USDT, crvUSD, ETH/WETH, WBTC, weETH, CRV, iZi, and TAIKO.

The core issue is not ERC20Vault token accounting. ERC20Vault acted as the release sink after the Bridge accepted message proofs. The trust boundary failure is in the L1 source-signal proof/checkpoint path: Bridge.processMessage delegates source-message authenticity to SignalService.proveSignalReceived, and SignalService accepts a Merkle proof against any saved checkpoint. In the attack sequence, the accepted checkpoint was produced immediately after attacker-controlled SGX instance registration, and sampled Taiko source-chain checks found no matching MessageSent events for the forged USDC and ETH message hashes around the proof block IDs.

Root Cause

Vulnerable Contract

  • Ethereum Bridge proxy: 0xd60247c6848b7ca29eddf63aa924e53db6ddd8ec.
  • Verified Bridge implementation: 0x2705b12a971da766a3f9321a743d61cead67da2f (MainnetBridge / Bridge).
  • Ethereum SignalService proxy: 0x9e0a24964e5397b566c1ed39258e21ab5e35c77c.
  • Verified SignalService implementation: 0xbc442f342fe247dc7981ac7fbe8293c8891f8752.
  • ERC20Vault proxy and asset custody sink: 0x996282cA11E5DEb6B5D122CC3B9A1FcAAD4415Ab.
  • Verified ERC20Vault implementation: 0xb20c8ffc2dd49596508d262b6e8b6817e9790e63.
  • SGX verifier contracts used in setup: 0x08568df252ecf37d6c3efd24f6ca3688118697f1 and 0xa1018ba2e22139076f91da2a856b2cab22d968f6, both verified SgxVerifier source.

Vulnerable Function

Bridge.processMessage((uint64,uint64,uint32,address,uint64,address,uint64,address,address,uint256,bytes),bytes) selector 0x2035065e is the primary L1 execution path. It accepts a user-supplied source message and proof, calls SignalService.proveSignalReceived(...), and then invokes the message target or sends ETH. Bridge.retryMessage((...),bool) selector 0x0432873c is the follow-up path for messages that were already marked RETRIABLE by the forged proof batch.

The internal trust-boundary function is SignalService._verifySignalReceived(uint64,address,bytes32,bytes). It validates a storage proof only against a locally saved checkpoint. If the checkpoint root is malicious but accepted, the Bridge has no independent source-chain MessageSent check before releasing funds.

Vulnerable Code

function processMessage(Message calldata _message, bytes calldata _proof) external returns (Status) {
    if (_message.destChainId != block.chainid) revert B_INVALID_CHAINID();
    if (_message.srcChainId == 0 || _message.srcChainId == block.chainid) revert B_INVALID_CHAINID();

    bytes32 msgHash = hashMessage(_message);
    _checkStatus(msgHash, Status.NEW);

    address signalService = resolve(LibStrings.B_SIGNAL_SERVICE, false);
    _proveSignalReceived(signalService, msgHash, _message.srcChainId, _proof); // <-- VULNERABILITY: accepted proof unlocks the release path

    bool ok = _invokeMessageCall(_message, msgHash, _invocationGasLimit(_message), true);
    _updateMessageStatus(msgHash, ok ? Status.DONE : Status.RETRIABLE);
}

function _proveSignalReceived(
    address _signalService,
    bytes32 _signal,
    uint64 _chainId,
    bytes calldata _proof
) private returns (uint32) {
    ISignalService(_signalService).proveSignalReceived( // <-- VULNERABILITY: authenticity is delegated entirely to SignalService
        _chainId,
        resolve(_chainId, LibStrings.B_BRIDGE, false),
        _signal,
        _proof
    );
}

function saveCheckpoint(Checkpoint calldata _checkpoint) external {
    if (msg.sender != _authorizedSyncer) revert SS_UNAUTHORIZED();
    if (_checkpoint.stateRoot == bytes32(0)) revert SS_INVALID_CHECKPOINT();
    if (_checkpoint.blockHash == bytes32(0)) revert SS_INVALID_CHECKPOINT();

    _checkpoints[_checkpoint.blockNumber].blockHash = _checkpoint.blockHash;
    _checkpoints[_checkpoint.blockNumber].stateRoot = _checkpoint.stateRoot;
}

function _verifySignalReceived(
    uint64 _chainId,
    address _app,
    bytes32 _signal,
    bytes calldata _proof
) private view {
    bytes32 slot = getSignalSlot(_chainId, _app, _signal);
    HopProof memory proof = abi.decode(_proof, (HopProof[]))[0];

    Checkpoint memory checkpoint = _getCheckpoint(uint48(proof.blockId));
    if (checkpoint.stateRoot != proof.rootHash) revert SS_INVALID_CHECKPOINT();

    LibTrieProof.verifyMerkleProof( // <-- VULNERABILITY: proof under an accepted malicious root caches the forged signal
        checkpoint.stateRoot,
        _remoteSignalService,
        slot,
        _signal,
        proof.accountProof,
        proof.storageProof
    );
}

The setup also used the verified SgxVerifier path:

uint64 public constant INSTANCE_VALIDITY_DELAY = 0;

function registerInstance(V3Struct.ParsedV3QuoteStruct calldata _attestation)
    external
    returns (uint256)
{
    (bool verified,) = IAttestation(automataDcapAttestation).verifyParsedQuote(_attestation);
    require(verified, SGX_INVALID_ATTESTATION());

    address[] memory addresses = new address[](1);
    addresses[0] = address(bytes20(_attestation.localEnclaveReport.reportData));

    // New on-chain-attested SGX instances become immediately usable.
    return _addInstances(addresses, false)[0];
}

Why It’s Vulnerable

Expected behavior: A message processed on Ethereum should correspond to a real Taiko source-chain Bridge.sendMessage(...) and MessageSent(msgHash, message) event. The L1 checkpoint used for source-signal proofs should represent a canonical, finalized Taiko state root produced by sufficiently trusted and delayed proof machinery. Newly registered proving instances should not be able to immediately authorize high-value bridge withdrawals without an effective challenge/finality window.

Actual behavior: The attacker registered new SGX instances and, in the same Ethereum transaction, submitted a proof path that saved a checkpoint for Taiko block 1805600 with state root 0x5064e2a3dfde1cd6d164e07b5dae47433d20a2d2c1ba6c9bda60c1ab6765215f. Bridge.processMessage then accepted multiple source-signal proofs against that checkpoint and marked 10 messages as RETRIABLE. Narrow Taiko source-chain log checks around the proof block IDs found no matching MessageSent logs for sampled forged message hashes 0x7f153b...831c9 and 0x855db4...82ef1 from the Taiko bridge 0x1670000000000000000000000000000000000001.

Once the Bridge cached or accepted these forged signals, ERC20Vault and ETH transfers followed normal code paths. For ERC20 releases, ERC20Vault.onMessageInvocation(bytes) decoded the message data and transferred canonical tokens from L1 vault custody to the attacker-controlled recipient. For ETH releases, the Bridge called the message target with the message value.

Attack Execution

High-Level Flow

  1. The attacker EOA 0x7506DeA0c38ca0B55364B22424374c5A1ae1B76a called executor 0xe0df6fc36deb38dd11dc53d327475c2b2b0ab98a.
  2. The executor registered two SGX instances through SgxVerifier.registerInstance(...), producing InstanceAdded logs for instance IDs 5 and 6.
  3. The executor called the Taiko proof/checkpoint path, which saved a SignalService checkpoint for Taiko block 1805600.
  4. In the same transaction, the executor called Bridge.processMessage(...) 10 times with source chain 167000; each call proved a forged source signal and marked a message as RETRIABLE.
  5. Follow-up transactions retried or processed those messages, releasing ERC20 assets from ERC20Vault and ETH from the Bridge.

Detailed Call Trace

The setup transaction 0x2f44dc1b883522a88f9b0cbbdfabf9ec33884b69dd4326600c3fab7fb2277260 entered 0xe0df6fc36deb38dd11dc53d327475c2b2b0ab98a.execute(bytes). It called SgxVerifier.registerInstance(...) on 0x08568df252ecf37d6c3efd24f6ca3688118697f1 and 0xa1018ba2e22139076f91da2a856b2cab22d968f6, and both calls reached Automata DCAP quote verification before emitting InstanceAdded events.

The same executor then called a proof endpoint at 0x6f21c543a4af5189ebdb0723827577e1ef57ef1f with selector 0xea191743 (prove(bytes,bytes) by selector lookup). That path called SignalService.saveCheckpoint((uint48,bytes32,bytes32)) through proxy 0x9e0a24964e5397b566c1ed39258e21ab5e35c77c, saving block 1805600, block hash 0x69c9a97c034c4fd60be5dbd00cdad419acaf5ba30e6b8371437b7e919d1a49dd, and state root 0x5064e2a3dfde1cd6d164e07b5dae47433d20a2d2c1ba6c9bda60c1ab6765215f.

After the checkpoint was saved, the executor called Bridge.processMessage(...) 10 times. Each Bridge call delegated to implementation 0x2705b12a971da766a3f9321a743d61cead67da2f, called SignalService.proveSignalReceived(167000, 0x1670000000000000000000000000000000000001, msgHash, proof), and then emitted MessageStatusChanged(msgHash, RETRIABLE). The affected message hashes in the setup batch included 0x7f153b854d24873c672947cbdc48eb1d84b2ffa447dab52ea8da186475a831c9, later used for the USDC release.

The release transaction 0x017292a7de5fef52a3274e37dda5ace4c4d0cdafe91b7b4ac9c700f02fae35ee called Bridge.retryMessage(...) for message hash 0x7f153b854d24873c672947cbdc48eb1d84b2ffa447dab52ea8da186475a831c9. The message targeted ERC20Vault 0x996282cA11E5DEb6B5D122CC3B9A1FcAAD4415Ab with onMessageInvocation(bytes) calldata. ERC20Vault emitted TokenReceived(...), and USDC 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 transferred 649,761.236201 USDC from the vault to the attacker.

The release transaction 0xb8befb015a67de8f40890b1f8667c597c3b66a52b388ec1c6cd28637fd65dd13 called Bridge.processMessage(...) directly for message hash 0x855db45d77ae44a0a2ac4d2dad3fbf9fd4c341d923738c8356dbcd2f22482ef1. The decoded message had srcChainId = 167000, from = 0x1670000000000000000000000000000000000001, destChainId = 1, value 130 ETH, and target 0xA98035081fB739EbE9C8f80904668fb11438a846. The Bridge proved the source signal against Taiko block 1806100 and sent 130 ETH to the target.

Financial Impact

Local L1 evidence confirms at least the following releases to attacker-controlled message recipients in the observed exploit window:

  • 675,761.236201 USDC (649,761.236201 USDC in the Blockaid-listed retry transaction plus another 26,000 USDC release).
  • 138,139.564343 USDT.
  • 156,832.011092154729015188 crvUSD.
  • 130 ETH and 20.70482 WETH.
  • 0.42634415 WBTC.
  • 0.530999165959580756 weETH.
  • 126,160.973069281665593531 CRV.
  • 2,140,403.026072231869425941 iZi.
  • 1,990,000 TAIKO.

Blockaid estimated the total impact at more than $1M. The report treats token-unit releases as authoritative and does not assign a precise USD value because volatile token pricing and any post-release swaps were not fully reconstructed here.

Evidence

  • Setup transaction: 0x2f44dc1b883522a88f9b0cbbdfabf9ec33884b69dd4326600c3fab7fb2277260 on Ethereum, block 25367938, status success, timestamp 2026-06-21 19:03:59 UTC.
  • Release transaction: 0x017292a7de5fef52a3274e37dda5ace4c4d0cdafe91b7b4ac9c700f02fae35ee on Ethereum, block 25368853, status success, timestamp 2026-06-21 22:07:23 UTC.
  • Release transaction: 0xb8befb015a67de8f40890b1f8667c597c3b66a52b388ec1c6cd28637fd65dd13 on Ethereum, block 25368908, status success, timestamp 2026-06-21 22:18:23 UTC.
  • Attacker: 0x7506DeA0c38ca0B55364B22424374c5A1ae1B76a.
  • Vulnerable proof/bridge path: Bridge proxy 0xd60247c6848b7ca29eddf63aa924e53db6ddd8ec, SignalService proxy 0x9e0a24964e5397b566c1ed39258e21ab5e35c77c, and SGX verifier contracts 0x08568df252ecf37d6c3efd24f6ca3688118697f1 / 0xa1018ba2e22139076f91da2a856b2cab22d968f6.
  • Impacted custody component: ERC20Vault proxy 0x996282cA11E5DEb6B5D122CC3B9A1FcAAD4415Ab.
  • Key on-chain fact: setup transaction logs show InstanceAdded for SGX instance IDs 5 and 6, then CheckpointSaved for Taiko block 1805600, then 10 MessageStatusChanged(..., RETRIABLE) events.
  • Key on-chain fact: 0x017292... emitted USDC Transfer from ERC20Vault to the attacker for 649,761.236201 USDC and changed message hash 0x7f153b...831c9 to DONE.
  • Key on-chain fact: 0xb8bef... changed message hash 0x855db4...82ef1 to DONE and the trace shows the Bridge sending 130 ETH to 0xA98035081fB739EbE9C8f80904668fb11438a846.
  • Key source-chain check: Taiko RPC log queries around proof block 1805600 and 1806100 returned no MessageSent logs from 0x1670000000000000000000000000000000000001 for sampled forged message hashes 0x7f153b...831c9 and 0x855db4...82ef1.

Remediation

  • Invalidate fraudulent checkpoints and cached received-signal slots, and revoke or quarantine the SGX instance IDs used in the setup transaction.
  • Add an effective challenge/finality delay between SGX instance registration, checkpoint acceptance, and high-value bridge message processing; INSTANCE_VALIDITY_DELAY = 0 makes same-transaction registration-to-withdrawal possible.
  • Require multi-prover or committee quorum for checkpoint roots that can unlock L1 custody, rather than accepting a single freshly registered SGX path for immediate withdrawals.
  • Enforce source-message authenticity at the Bridge layer by requiring a canonical source-chain checkpoint with independent finality guarantees; a storage proof under a newly accepted root should not be enough to unlock custody.
  • Add monitoring and circuit breakers for the sequence registerInstance -> saveCheckpoint -> processMessage/retryMessage, especially when the messages release ERC20Vault funds or ETH above configured thresholds.