All challenge files + exploit can be found here: https://github.com/farazsth98/CTF/tree/master/realworldctf-2024/safebridge
The setup of the challenge is as follows:
- Two bridge contracts deployed on L1 and L2.
- WETH is deployed on L2 at hardcoded address
L2_WETH
. - The deployer has already transferred 2 WETH from L1 to L2, so the L1 bridge has 2 WETH in it.
- The objective is to drain the L1 bridge.
To run the exploit, just place the SafeBridgeExploit.s.sol
script into a foundry project's script/
directory, and the L2MaliciousToken.sol
contract into the src/
directory.
Then, set up the l1
and l2
rpc urls in foundry.toml
(see foundry docs, rpcs are provided by the challenge).
Then run forge script script/SafeBridgeExploit.s.sol --broadcast
.
After that, run the two cast
commands at the bottom of the SafeBridgeExploit.s.sol
script (see the comments).
Finally, just get the flag.
The bug is in the L1ERC20Bridge
contract. In the _initiateERC20Deposit()
function, all arguments except _from
are controlled. Can you spot it?
function _initiateERC20Deposit(address _l1Token, address _l2Token, address _from, address _to, uint256 _amount)
internal
{
IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);
bytes memory message;
if (_l1Token == weth) {
message = abi.encodeWithSelector(
IL2ERC20Bridge.finalizeDeposit.selector, address(0), Lib_PredeployAddresses.L2_WETH, _from, _to, _amount
);
} else {
message =
abi.encodeWithSelector(IL2ERC20Bridge.finalizeDeposit.selector, _l1Token, _l2Token, _from, _to, _amount);
}
sendCrossDomainMessage(l2TokenBridge, message);
deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] + _amount;
emit ERC20DepositInitiated(_l1Token, _l2Token, _from, _to, _amount);
}
NOTE: Both bridge contracts are added to this gist so you can view them yourself.
When _l1Token
is equal to weth
's address, the function will send a cross-chain message to call finalizeDeposit()
on the L2ERC20Bridge
contract, with the _l2Token
address set to the L2_WETH
address.
However, when saving the deposit in the deposits
mapping, it uses the user-controlled _l2Token
address instead, so by setting _l1Token == weth
and _l2Token == L2MaliciousToken_address
(see L2MaliciousToken.sol
in this gist), an attacker can make a deposit into their malicious token and still receive WETH since the message hardcodes L2_WETH
address. If the address wasn't hardcoded, the attacker would simply receive their malicious token instead.
Subsequently, the attacker can transfer back the WETH by specifying _l2Token
as the malicious token contract, as long as the malicious token's l1Token()
function returns the L1 weth
address. This will also preserve the WETH they received on L2 because calling the burn()
function on L2MaliciousToken
doesn't do anything.
So the attack path is as follows:
- Deploy
L2MaliciousToken
onto the L2 chain. Set thel1Token()
function to return the address of theweth
contract on L1. - Bridge 2 WETH to the L2 chain, specifying
_l2Token
address as the malicious token address on L2. - Bridge the malicious token back from L2 to L1.
- Since
l1Token()
function returns L1weth
address, and because a 2 WETH deposit was made, this will succeed, but the attacker will still keep their 2 WETH on the L2 chain.
- Bridge the 2 WETH the attacker has on the L2, but legitimately using the
L2_WETH
contract.
That will drain the bridge successfully.