Reproduced Exploit

Blast — _initiateBridgeERC20 encodes DAI as the local token, so the user receives DAI instead of USDB

1. The L2 bridge holds 1000 DAI and 1000 USDB. 2. initiateBridgeERC20 encodes finalizeBridgeERC20(DAI, USDB, from, to, 1000 ether, extraData). 3. L2 transfers _localToken. That address is DAI, so the user receives 1000000000000000000000 DAI. 4. The user's USDB balance is 0. The L2 bridge still hold…

Jan 2024•Other•logic•4 min read

Chain

Other

Category

logic

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: 29874-l1blastbridge-uses-wrong-token-order-when-bridging-usd-yield. Standalone Foundry PoC and full write-up: 29874-l1blastbridge-uses-wrong-token-order-when-bridging-usd-yield_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/wrong-order · vuln/bridge/missing-validation · vuln/data/wrong-encoding

Reproduction: a self-contained Foundry PoC that compiles and runs with only forge-std — no fork, no RPC. Full trace: output.txt. PoC: test/29874-l1blastbridge-uses-wrong-token-order-when-bridging-usd-yield_exp.sol.

AuditVault taxonomy: lang/solidity · sector/bridge · sector/stable · platform/spearbit · has/github · severity/high · genome: wrong-condition · cross-chain-message · direct-drain


Key info#

ImpactHIGH — a 1000-token USD bridge pays the user 1000 DAI (1000000000000000000000) and 0 USDB. The 1000 USDB stays on the L2 bridge
ProtocolBlast — L1BlastBridge._initiateBridgeERC20
Vulnerable codeabi.encodeWithSelector(finalizeBridgeERC20.selector, DAI, USDB, ...)
Bug classLocal and remote token arguments are swapped
FindingSpearbit — Blast contracts review, Milo Truck · #29874
Reportreport-blast-contracts-review-draft.pdf
SourceAuditVault
StatusAudit finding, reproduced as a local synthetic
Compilersolc 0.8.27, cancun, via_ir

finalizeBridgeERC20(_localToken, _remoteToken, ...) transfers _localToken. On L2 the local token is USDB and the remote token is DAI. The encoder passes them in the other order.

The same report also describes a DelegateCalls calldata bug. That is a different root cause and is not this reproduction.


TL;DR#

  1. The L2 bridge holds 1000 DAI and 1000 USDB.
  2. initiateBridgeERC20 encodes finalizeBridgeERC20(DAI, USDB, from, to, 1000 ether, extraData).
  3. L2 transfers _localToken. That address is DAI, so the user receives 1000000000000000000000 DAI.
  4. The user's USDB balance is 0. The L2 bridge still holds 1000000000000000000000 USDB.
  5. Control: the fixed encoder passes USDB then DAI. The same user receives 1000 USDB and 0 DAI.

The vulnerable code#

The argument order is the order in the report. DAI is USDConversions.DAI. USDB is Predeploys.USDB.

SOLIDITY
// @> VULN: DAI is encoded as _localToken and USDB as _remoteToken, so L2 transfers DAI
bytes memory message = abi.encodeWithSelector(
    L2BlastBridge.finalizeBridgeERC20.selector,
    address(dai),
    address(usdb),
    _from,
    _to,
    amountToMintWad,
    _extraData
);

L2 then does MockToken(_localToken).transfer(_to, _amount).

DIFF
- address(dai),
- address(usdb),
+ address(usdb),
+ address(dai),

Root cause#

finalizeBridgeERC20 takes the local token first and the remote token second. For an L2 finalize, local is the L2 token (USDB) and remote is the L1 token (DAI). _initiateBridgeERC20 passes DAI first. L2 decodes _localToken = DAI and transfers that token. The report's failure mode, when no DAI contract exists on L2, is a revert. With both tokens present, which is this reduction, the transfer succeeds and the user is paid the wrong asset.

Preconditions#

  • The message is delivered to finalizeBridgeERC20.
  • The L2 bridge holds a balance of whichever address was encoded as _localToken.
  • _amount is 1000 ether of that token.

Attack walkthrough#

Numbers from output.txt:

Buggy order DAI, USDBControl order USDB, DAI
User DAI10000000000000000000000
User USDB01000000000000000000000
paidLocalDAIUSDB
paidRemoteUSDBDAI
USDB left on L210000000000000000000000

Diagrams#

flowchart TD A[L1 encodes finalizeBridgeERC20] --> B[First token argument is DAI] B --> C[Second token argument is USDB] C --> D[Messenger delivers the call to L2] D --> E[L2 transfers the local token] E --> F[User holds 1000 DAI and 0 USDB]

Impact#

The user who bridged 1000 of the USD yield token receives 1000 DAI. They receive 0 USDB. The 1000 USDB that should have been paid stays on the L2 bridge. The control, with the two addresses swapped back, pays that same 1000 as USDB and pays no DAI.

Remediation#

Pass USDB as _localToken and DAI as _remoteToken in _initiateBridgeERC20, which is the swap the report recommends.

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.