Reproduced Exploit

DODO Cross-Chain DEX — swap output token ≠ withdrawal target drains gateway

1. onCall performs _doMixSwap(..., params.toToken) then withdraws decoded.targetZRC20. 2. Nothing requires toToken == targetZRC20. 3. Attacker crafts a message: swap BTC→USDC, withdraw ETH (accumulated from prior ops). 4. Gateway spends the swap output amount of ETH to the attacker while USDC sits…

May 2025Otherlogic3 min read

Chain

Other

Category

logic

Date

May 2025

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: 58578-h-1-missing-swap-withdrawal-validation-enables-accumulated-t. Standalone Foundry PoC and full write-up: 58578-h-1-missing-swap-withdrawal-validation-enables-accumulated-t_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/cross-contract-state-consistency · vuln/loss-of-funds/direct-drain · vuln/bridge/message-parameter-mismatch

Reproduction: a self-contained Foundry PoC that compiles & runs in an isolated project with only forge-std — no fork, no RPC, no anvil_state. Full trace: output.txt. PoC: test/58578-h-1-missing-swap-withdrawal-validation-enables-accumulated-t_exp.sol.

AuditVault taxonomy: severity/high · sector/bridge · sector/dex · sector/stable · platform/sherlock · cross-contract-state-consistency · variant


Key info#

ImpactHIGH — attacker swaps cheap input → cheap output, then withdraws a different high-value token accumulated on the gateway
ProtocolDODO Cross-Chain DEX — GatewayCrossChain (ZetaChain)
Vulnerable codeonCall swaps with params.toToken then withdraws decoded.targetZRC20 with no equality check (GatewayCrossChain.sol#L465-L508, #L371-L376, #L431-L437)
Bug classMissing validation between swap output and withdrawal target
FindingSherlock 2025-05-dodo-cross-chain-dex · #58578 · reporter patitonar (and many others)
Reportsherlock-audit/2025-05-dodo-cross-chain-dex-judging
SourceAuditVault
StatusAudit finding — fixed in PR 33. Reproduced as a standalone local PoC.
Compiler^0.8.24 (PoC)

TL;DR#

  1. onCall performs _doMixSwap(..., params.toToken) then withdraws decoded.targetZRC20.
  2. Nothing requires toToken == targetZRC20.
  3. Attacker crafts a message: swap BTC→USDC, withdraw ETH (accumulated from prior ops).
  4. Gateway spends the swap output amount of ETH to the attacker while USDC sits on the contract.
  5. Fix: require(toToken == targetZRC20).

The vulnerable code#

SOLIDITY
uint256 outputAmount = _doMixSwap(fromZRC20, toToken, amount);
// FIX: require(toToken == targetZRC20, "swap/target mismatch");
_withdraw(targetZRC20, receiver, outputAmount - GAS_FEE); // @> VULN

Root cause#

Two independent message fields control the economic flow: the swap's output token and the withdrawal token. The code uses the swap's amount with the withdrawal's token. When they differ, the gateway pays the attacker from whichever balance targetZRC20 has — typically residual high-value tokens from refunds/prior bridges.

Preconditions#

  • Gateway holds accumulated ZRC20 balances (common after refunds / partial fills).
  • Attacker can initiate a cross-chain depositAndCall (or the synthetic onCall entry).

Attack walkthrough#

  1. Gateway holds 1000 ETH.ZRC20 from prior operations.
  2. Attacker supplies 1 BTC.ZRC20; message sets toToken = USDC, targetZRC20 = ETH.
  3. Swap converts BTC→USDC on the gateway.
  4. Withdraw transfers 1 ETH.ZRC20 to the attacker receiver.
  5. Protocol loses ETH; attacker paid only BTC.

Diagrams#

flowchart LR A["Attacker BTC in"] --> B["_doMixSwap to USDC"] B --> C["USDC sits on gateway"] B --> D["_withdraw targetZRC20=ETH amount=output"] D --> E["Attacker receives ETH"] F["Accumulated ETH on gateway"] --> D

Impact#

Complete drainage of any accumulated ZRC20 the attacker can name as targetZRC20, sized by how much input they swap (output amount drives the withdraw size). Sherlock PoC showed ~99% drainage of 1000 ETH with a tiny BTC input under realistic pricing.

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.