Reproduced Exploit
Anyswap (Multichain V4 Router) Exploit — Underlying-Transfer Cross-Chain Drain
anySwapOutUnderlyingWithPermit (AnyswapV4Router.sol:261-277) implements an "out with underlying" cross-chain swap in three steps:
Loss
~$8M (WETH) across the incident; the cross-chain burn path was weaponized
Chain
Ethereum
Category
Reentrancy
Date
Jan 2022
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2022-01-Anyswap_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/Anyswap_exp.sol.
Vulnerability classes: vuln/dependency/unsafe-external-call · vuln/logic/missing-validation
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified vulnerable source: AnyswapV4Router.sol.
Key info#
| Loss | ~$8M (WETH) across the incident; the cross-chain burn path was weaponized |
| Vulnerable contract | AnyswapV4Router — 0x6b7a87899490EcE95443e979cA9485CBE7E71522 |
| Underlying token | WETH — 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 |
| Attack tx | 0xe50ed602bd916fc304d53c4fed236698b71691a95774ff0aeeb74b699c6227f7 |
| Chain / block / date | Ethereum mainnet / 14,037,236 / Jan 2022 |
| Bug class | Logic flaw — anySwapOutUnderlyingWithPermit trusts a malicious token's underlying()/depositVault() callbacks, moving real underlying (WETH.transferFrom) into the anyToken contract which the attacker then drains |
TL;DR#
anySwapOutUnderlyingWithPermit (AnyswapV4Router.sol:261-277)
implements an "out with underlying" cross-chain swap in three steps:
address _underlying = AnyswapV1ERC20(token).underlying(); // attacker-controlled callback
IERC20(_underlying).permit(from, address(this), amount, ...); // permit
TransferHelper.safeTransferFrom(_underlying, from, token, amount); // ⚠️ moves WETH into `token`
AnyswapV1ERC20(token).depositVault(amount, from); // attacker-controlled callback
_anySwapOut(from, token, to, amount, toChainID); // AnyswapV1ERC20(token).burn(from, amount)
The router fetches the underlying address from the token argument via a staticcall, then
calls token.depositVault(...) (an ordinary call) and AnyswapV1ERC20(token).burn(from, ...).
The whole design assumes token is a legitimate, honestly-deployed Anyswap ERC20 whose
depositVault mints any-tokens against the underlying it just received. There is no check that
token is a registered/trusted Anyswap token. Anyone can deploy (or, as here, be) a contract
that satisfies the interface while doing nothing in depositVault/burn — yet the router still
performs the real WETH.transferFrom into it. The net effect is a permissionless transfer of
underlying into an attacker-controlled address with no real cross-chain burn.
The PoC demonstrates this by having the test contract itself act as token:
function burn(address, uint256) external returns (bool) { return true; } // no-op
function depositVault(uint256, address) external returns (uint256) { return 1; } // no-op
function underlying() external view returns (address) { return WETH_Address; }
So WETH.transferFrom(victim → this) succeeds (the test gave itself allowance), and then the
"burn" does nothing — the underlying stays with the attacker.
Root cause#
A trust boundary violation: the router accepts an arbitrary address token from the caller and
treats it as a fully-trusted Anyswap bridge token, calling privileged functions (burn, depositVault)
and trusting its underlying() view. The two safety properties that should have held:
tokenmust be whitelisted/registered as a genuine Anyswap bridge asset.- The underlying must be moved to the router (or escrowed), not to
token, unlesstokenis trusted.
Both were missing. anySwapOutUnderlying (:255-259)
and anySwapOutUnderlyingWithTransferPermit (:279-293)
share the same shape. Because the underlying is pulled via permit-granted allowance, the caller does
not even need to pre-approve — the signed permit is what authorizes transferFrom.
Attack walkthrough (from the trace)#
Fork: mainnet @ 14,037,236. token = address(this) (the test contract), from = 0x3Ee…4FAB,
amount = 308.636 WETH.
anySwapOutUnderlyingWithPermit(from, token, msg.sender, amount, …, 56)— router firststaticcallstoken.underlying()→ returns WETH.WETH.permit(...)(here with dummyv,r,s, succeeds because the forked WETH/allowance is set up).WETH.transferFrom(from → token, 308.636 WETH)— the real value move. Trace shows theTransferevent and storage slot change: the victim's WETH balance slot goes0x10bb31a12e4317f437 → 0, i.e. the full 308.636 WETH is pulled.token.depositVault(amount, from)→ returns 1 (no-op), andtoken.burn(from, amount)→ returns true (no-op).LogAnySwapOut(token, from, to, amount, 1 → 56)is emitted — the router thinks 308.636 anyWETH was burnt and will be minted on BSC, but no anyToken was ever involved.- The underlying now sits at
address(this); the PoC transfers 308.636 WETH tomsg.sender(After exploit, WETH balance of attacker: 308636644758370382901).
Preconditions#
- The attacker must hold (or obtain, via a signed permit) allowance over some victim's underlying
token balance — i.e. a valid
permitsignature. In practice this is the hard part and is why the real-world incident was coupled to a token whosepermitwas abusable / the attacker controlled. - No whitelist on the Anyswap router prevents passing a malicious
token.
Diagrams#
Remediation#
- Whitelist bridge tokens. Only accept
tokenvalues from a router-managed registry of genuine Anyswap ERC20 deployments; revert otherwise. This is the single most important fix. - Never move underlying into an untrusted
token. Iftokenis trusted, escrow underlying at the router (or a dedicated vault) and let the trusted anyToken account for it; do not calltoken.depositVaulton an arbitrary address. - Validate
underlying()against a stored mapping rather than trusting the live return value of an attacker-supplied contract. - Separate the permit-and-pull from the burn behind an access-gated internal function so a forged
tokencannot reachsafeTransferFrom.
How to reproduce#
_shared/run_poc.sh 2022-01-Anyswap_exp --mt testExample -vvvvv
- RPC: mainnet archive (block 14,037,236).
foundry.tomluses Infura mainnet. - Result:
[PASS] testExample()—After exploit, WETH balance of attacker: 308636644758370382901(≈ 308.6 WETH).
Reference: Anyswap/Multichain V4 router underlying-path logic flaw, Jan 2022.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2022-01-Anyswap_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
Anyswap_exp.sol.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Anyswap (Multichain V4 Router) Exploit".
- 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.