Reproduced Exploit

Blast — finalizeBridgeETHDirect compares msg.sender to the un-aliased L1 bridge and locks the deposit

1. The depositor locks 5 stETH in the L1 escrow. That balance is what a successful finalize is supposed to release as ETH on L2. 2. The caller is a local messenger whose address is exactly alias(OTHER_BRIDGE). It is not OTHER_BRIDGE. 3. finalizeBridgeETHDirect hits require(msg.sender == address(OTH…

Jan 2024•Other•bridge•5 min read

Chain

Other

Category

bridge

Date

Jan 2024

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 29873-msgsender-has-to-be-un-aliased-in-l2blastbridgefinalizebridg. Standalone Foundry PoC and full write-up: 29873-msgsender-has-to-be-un-aliased-in-l2blastbridgefinalizebridg_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/bridge/missing-validation · vuln/logic/wrong-condition · vuln/dos/frozen-funds

Reproduction: a self-contained Foundry PoC that compiles and runs with only forge-std — no fork, no RPC. Full trace: output.txt. PoC: test/29873-msgsender-has-to-be-un-aliased-in-l2blastbridgefinalizebridg_exp.sol.

AuditVault taxonomy: lang/solidity · platform/spearbit · has/github · severity/high · sector/bridge · sector/liquid-staking · genome: wrong-condition · permanent · bridge-sender-auth


Key info#

ImpactHIGH — a direct L1-to-L2 deposit of 5 stETH (5000000000000000000) never credits ETH. The finalize reverts and the 5 stETH stays in the escrow. Credited ETH is 0
ProtocolBlast — L2BlastBridge.finalizeBridgeETHDirect
Vulnerable coderequire(msg.sender == address(OTHER_BRIDGE), "StandardBridge: function can only be called from the other bridge")
Bug classThe direct L1-to-L2 path checks the raw msg.sender instead of undoL1ToL2Alias(msg.sender)
FindingSpearbit — Blast contracts review, Milo Truck · #29873
Reportreport-blast-contracts-review-draft.pdf
SourceAuditVault
StatusAudit finding, reproduced as a local L2 synthetic
Compilersolc 0.8.27, cancun, via_ir

OTHER_BRIDGE is the L1BlastBridge address. A contract-initiated L1-to-L2 transaction is delivered with msg.sender = alias(L1BlastBridge). The alias is the OP-stack offset 0x1111000000000000000000000000000000001111 added to the L1 address. The check compares against the un-aliased address, so the legitimate caller never passes.


TL;DR#

  1. The depositor locks 5 stETH in the L1 escrow. That balance is what a successful finalize is supposed to release as ETH on L2.
  2. The caller is a local messenger whose address is exactly alias(OTHER_BRIDGE). It is not OTHER_BRIDGE.
  3. finalizeBridgeETHDirect hits require(msg.sender == address(OTHER_BRIDGE)) and reverts. The error string is the one in the report.
  4. After the revert the escrow still holds 5000000000000000000 stETH, released is 0, and the depositor's ETH balance is 0.
  5. Control: the same messenger calling the fixed bridge, which checks undoL1ToL2Alias(msg.sender) == OTHER_BRIDGE, credits the depositor 5 ETH.

This is the L2 side only. The messenger is a local contract at the aliased address. No second chain is required.


The vulnerable code#

The require is the line the report quotes. The surrounding checks are the ones the deployed finalizeBridgeETHDirect uses after the sender check.

SOLIDITY
function finalizeBridgeETHDirect(address _from, address _to, uint256 _amount, bytes calldata _extraData)
    public
    payable
{
    // @> VULN: msg.sender is alias(L1BlastBridge), not OTHER_BRIDGE, so this always reverts
    require(msg.sender == address(OTHER_BRIDGE), "StandardBridge: function can only be called from the other bridge");
    require(msg.value == _amount, "StandardBridge: amount sent does not match amount required");
    require(_to != address(this), "StandardBridge: cannot send to self");
    require(_to != address(messenger), "StandardBridge: cannot send to messenger");
    (bool success,) = _to.call{value: _amount}("");
    require(success, "L2BlastBridge: ETH transfer failed");
    released += _amount;
}
DIFF
- require(msg.sender == address(OTHER_BRIDGE), "StandardBridge: function can only be called from the other bridge");
+ require(undoL1ToL2Alias(msg.sender) == address(OTHER_BRIDGE), "StandardBridge: function can only be called from the other bridge");

Root cause#

finalizeBridgeETHDirect is the direct L1-to-L2 entry, not a messenger relay. On the OP stack a deposit transaction sent by a contract has msg.sender equal to address(uint160(l1Sender) + 0x1111000000000000000000000000000000001111). OTHER_BRIDGE is stored as the plain L1 address, and other L2BlastBridge functions compare against that plain address after the cross-domain messenger has already un-aliased the sender. This function skips that step, so the equality is false for every real deposit.

Preconditions#

  • OTHER_BRIDGE is the un-aliased L1BlastBridge.
  • The finalize is invoked as a direct L1-to-L2 transaction, so msg.sender is the alias.
  • The depositor has already locked stETH on L1. There is no other function that returns it.

Attack walkthrough#

Numbers from output.txt:

QuantityAfter the buggy finalizeControl, un-aliased check
stETH in the escrow50000000000000000005000000000000000000 still held as the L1 deposit
ETH credited to the depositor05000000000000000000
released on the bridge05000000000000000000

The buggy call reverts inside the sender require. The fixed call is made by the same aliased messenger, with msg.value == 5 ether, and the depositor's receive is credited 5 ETH.

Diagrams#

flowchart TD A[Depositor locks 5 stETH in the L1 escrow] --> B[Messenger address is the alias of OTHER_BRIDGE] B --> C[Messenger calls finalizeBridgeETHDirect] C --> D["require msg.sender equals OTHER_BRIDGE"] D --> E[Require reverts because the sender is the alias] E --> F[5 stETH stays locked and depositor ETH is 0]

Impact#

A user who deposits stETH through L1BlastBridge never receives the ETH on L2. The 5 stETH remains in the escrow, released stays 0, and the depositor's ETH balance stays 0. Repeating the finalize does not help: every direct call has the same aliased sender. The control shows the one-line un-alias pays the same depositor 5 ETH.

Remediation#

Compare undoL1ToL2Alias(msg.sender) to OTHER_BRIDGE, which is the check the report recommends and the check the deployed L2BlastBridge now uses.

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.