I played with the Blue Water team in the Real World CTF 2024. We managed to secure the fourth place. A huge shoutout and thanks to my awesome teammates for their fantastic teamwork during it!
During playing this CTF, I got first blood in Real World CTF 2024 by solving the blockchain challenge called safebridge.
This challenge offers a refreshing and realistic perspective, standing out from the usual blockchain scenarios in CTFs, as it closely aligns with actual blockchain vulnerabilities encountered in the real world. Below is the writeup for it.
Blockchain
SafeBridge
Description
I’ve crafted what I believed to be an ultra-safe token bridge. Don’t believe it?
nc 47.251.56.125 1337
Initial Analysis
In this challenge, we were provided with a zip file containing the necessary setup. The file includes numerous documents, but I’ll focus on explaining only the key ones. Let’s begin by examining Challenge.sol to grasp the objective of this challenge.
From the Challenge.sol file, it’s clear that our goal is to reduce the BRIDGE balance to 0. For those unfamiliar with the concept of a bridge in this context, it refers to a mechanism that allows the transfer of assets and information between two different blockchain networks. This functionality is crucial in a decentralized environment where interoperability between different blockchains is needed.
Now, let’s examine the challenge.py file to understand the details of the challenge setup.
From the code in challenge.py, we can see that the challenge is designed to create two blockchain networks, referred to as L1 and L2. The setup involves deploying various contracts on each chain, which we will explore in more detail later. By analyzing the deploy function from utils.py, we notice that it executes another deployment script located in Deploy.s.sol. Next, let’s delve into the contents of the Deploy.s.sol file.
pragma solidity^0.8.20;import{Script}from"forge-std/Script.sol";import"src/L1/WETH.sol";import"src/L1/L1CrossDomainMessenger.sol";import"src/L1/L1ERC20Bridge.sol";import"src/Challenge.sol";import{Lib_PredeployAddresses}from"src/libraries/constants/Lib_PredeployAddresses.sol";import{ERC20}from"@openzeppelin/contracts/token/ERC20/ERC20.sol";contractDeployisScript{functionsetUp()public{}functionrun()public{addresssystem=getAddress(1);addresschallenge=deploy(system);vm.writeFile(vm.envOr("OUTPUT_FILE",string("/tmp/deploy.txt")),vm.toString(challenge));}functiondeploy(addresssystem)internalreturns(addresschallenge){vm.createSelectFork(vm.envString("L1_RPC"));vm.startBroadcast(system);addressrelayer=getAdditionalAddress(0);L1CrossDomainMessengerl1messenger=newL1CrossDomainMessenger(relayer);WETHweth=newWETH();L1ERC20Bridgel1Bridge=newL1ERC20Bridge(address(l1messenger),Lib_PredeployAddresses.L2_ERC20_BRIDGE,address(weth));weth.deposit{value:2ether}();weth.approve(address(l1Bridge),2ether);l1Bridge.depositERC20(address(weth),Lib_PredeployAddresses.L2_WETH,2ether);challenge=address(newChallenge(address(l1Bridge),address(l1messenger),address(weth)));vm.stopBroadcast();}functiongetAdditionalAddress(uint32index)internalreturns(address){returngetAddress(index+2);}functiongetPrivateKey(uint32index)privatereturns(uint256){stringmemorymnemonic=vm.envOr("MNEMONIC",string("test test test test test test test test test test test junk"));returnvm.deriveKey(mnemonic,index);}functiongetAddress(uint32index)privatereturns(address){returnvm.addr(getPrivateKey(index));}}
Upon closely examining the Deploy.s.sol file, it’s revealed that Challenge.sol is deployed on the L1 chain. Furthermore, as part of its setup, it deposits 2 ETH into the L1 bridge. This means our primary objective is to drain the bridge contract on L1. To provide more context on the bridge’s implementation, essentially, each chain has its own bridge contract. There’s also an off-chain relayer (relayer.py). This relayer typically processes bridge requests and relays messages between the bridges on each chain.
For your information, off-chain refers to activities or processes that take place outside of the blockchain network. The necessity for off-chain mechanisms in this scenario arises because contracts on different chains cannot directly interact with each other. In the context of blockchain technology, each chain operates in its own isolated environment. This isolation means that a contract on one chain cannot natively see, access, or trigger functions in a contract on another chain. To bridge this gap, off-chain relayers are employed. These relayers monitor events on one chain and then execute corresponding actions on another, effectively enabling communication and interaction between the two distinct blockchain networks.
Now, as mentioned before that the challenge.py try to setup the L1 and L2 chain by deploying some contracts. Let’s take a look on each of it one-by-one.
// 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.
*
*/contractL1ERC20BridgeisIL1ERC20Bridge,CrossDomainEnabled{usingSafeERC20forIERC20;addresspublicl2TokenBridge;addresspublicweth;// Maps L1 token to L2 token to balance of the L1 token deposited
mapping(address=>mapping(address=>uint256))publicdeposits;constructor(address_l1messenger,address_l2TokenBridge,address_weth)CrossDomainEnabled(_l1messenger){l2TokenBridge=_l2TokenBridge;weth=_weth;}/**
* @inheritdoc IL1ERC20Bridge
*/functiondepositERC20(address_l1Token,address_l2Token,uint256_amount)externalvirtual{_initiateERC20Deposit(_l1Token,_l2Token,msg.sender,msg.sender,_amount);}/**
* @inheritdoc IL1ERC20Bridge
*/functiondepositERC20To(address_l1Token,address_l2Token,address_to,uint256_amount)externalvirtual{_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);bytesmemorymessage;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;emitERC20DepositInitiated(_l1Token,_l2Token,_from,_to,_amount);}/**
* @inheritdoc IL1ERC20Bridge
*/functionfinalizeERC20Withdrawal(address_l1Token,address_l2Token,address_from,address_to,uint256_amount)publiconlyFromCrossDomainAccount(l2TokenBridge){deposits[_l1Token][_l2Token]=deposits[_l1Token][_l2Token]-_amount;IERC20(_l1Token).safeTransfer(_to,_amount);emitERC20WithdrawalFinalized(_l1Token,_l2Token,_from,_to,_amount);}/**
* @inheritdoc IL1ERC20Bridge
*/functionfinalizeWethWithdrawal(address_from,address_to,uint256_amount)externalonlyFromCrossDomainAccount(l2TokenBridge){finalizeERC20Withdrawal(weth,Lib_PredeployAddresses.L2_WETH,_from,_to,_amount);}}
The contract we are looking at is the bridge contract for the L1 chain. Analyzing the functions it offers, our primary action seems to be the deposit function. The key parameters for this function are the address of the token on the L1 chain, the address of the corresponding token on the L2 chain, and the deposit amount. We can break down the deposit function into four main actions:
It transfers the specified amount of _l1Token from the sender to the contract itself.
It encodes a message to be sent to the other chain, which in this case is L2. This message is essentially the encoded version of a call to the finalizeDeposit function (which we can deduce that available in the bridge contract of L2).
It sends this encoded message by triggering the sendCrossDomainMessage function. We will explore this function in more detail shortly.
It updates the deposits[_l1Token][_l2Token] record with the amount that has just been deposited.
These steps provide a foundational understanding of how the deposit function operates within the bridge contract on the L1 chain. Now, let’s delve into the workings of the sendCrossDomainMessage function.
// SPDX-License-Identifier: MIT
pragma solidity>0.5.0<0.9.0;import{ICrossDomainMessenger}from"./ICrossDomainMessenger.sol";contractCrossDomainEnabled{// Messenger contract used to send and recieve messages from the other domain.
addresspublicmessenger;/**
* @param _messenger Address of the CrossDomainMessenger on the current layer.
*/constructor(address_messenger){messenger=_messenger;}/**
* Enforces that the modified function is only callable by a specific cross-domain account.
* @param _sourceDomainAccount The only account on the originating domain which is
* authenticated to call this function.
*/modifieronlyFromCrossDomainAccount(address_sourceDomainAccount){require(msg.sender==address(getCrossDomainMessenger()),"messenger contract unauthenticated");require(getCrossDomainMessenger().xDomainMessageSender()==_sourceDomainAccount,"wrong sender of cross-domain message");_;}/**
* Gets the messenger, usually from storage. This function is exposed in case a child contract
* needs to override.
* @return The address of the cross-domain messenger contract which should be used.
*/functiongetCrossDomainMessenger()internalvirtualreturns(ICrossDomainMessenger){returnICrossDomainMessenger(messenger);}/**
* Sends a message to an account on another domain
* @param _crossDomainTarget The intended recipient on the destination domain
* @param _message The data to send to the target (usually calldata to a function with
* `onlyFromCrossDomainAccount()`)
*/functionsendCrossDomainMessage(address_crossDomainTarget,bytesmemory_message)internal{getCrossDomainMessenger().sendMessage(_crossDomainTarget,_message);}}
Upon examining this function, we observe that sendCrossDomainMessage attempts to call the sendMessage function, which is implemented in the CrossDomainMessenger. Let’s focus on the CrossDomainMessenger.
// SPDX-License-Identifier: MIT
pragma solidity^0.8.20;import{ICrossDomainMessenger}from"./ICrossDomainMessenger.sol";contractCrossDomainMessengerisICrossDomainMessenger{addresspublicrelayer;uint256publicmessageNonce;mapping(bytes32=>bool)publicrelayedMessages;mapping(bytes32=>bool)publicsuccessfulMessages;mapping(bytes32=>bool)publicsentMessages;addressinternalxDomainMsgSender=0x000000000000000000000000000000000000dEaD;addressinternalconstantDEFAULT_XDOMAIN_SENDER=0x000000000000000000000000000000000000dEaD;constructor(address_relayer){relayer=_relayer;}modifieronlyRelayer(){require(msg.sender==relayer,"not relayer");_;}functionxDomainMessageSender()publicviewreturns(address){require(xDomainMsgSender!=DEFAULT_XDOMAIN_SENDER,"xDomainMessageSender is not set");returnxDomainMsgSender;}/**
* Sends a cross domain message to the target messenger.
* @param _target Target contract address.
* @param _message Message to send to the target.
*/functionsendMessage(address_target,bytesmemory_message)public{bytesmemoryxDomainCalldata=encodeXDomainCalldata(_target,msg.sender,_message,messageNonce);sentMessages[keccak256(xDomainCalldata)]=true;emitSentMessage(_target,msg.sender,_message,messageNonce);messageNonce+=1;}/**
* Relays a cross domain message to a contract.
* @param _target Target contract address.
* @param _sender Message sender address.
* @param _message Message to send to the target.
* @param _messageNonce Nonce for the provided message.
*/functionrelayMessage(address_target,address_sender,bytesmemory_message,uint256_messageNonce)publiconlyRelayer{// anti reentrance
require(xDomainMsgSender==DEFAULT_XDOMAIN_SENDER,"already in execution");bytesmemoryxDomainCalldata=encodeXDomainCalldata(_target,_sender,_message,_messageNonce);bytes32xDomainCalldataHash=keccak256(xDomainCalldata);require(successfulMessages[xDomainCalldataHash]==false,"Provided message has already been received.");xDomainMsgSender=_sender;(boolsuccess,)=_target.call(_message);xDomainMsgSender=DEFAULT_XDOMAIN_SENDER;// Mark the message as received if the call was successful. Ensures that a message can be
// relayed multiple times in the case that the call reverted.
if(success==true){successfulMessages[xDomainCalldataHash]=true;emitRelayedMessage(xDomainCalldataHash);}else{emitFailedRelayedMessage(xDomainCalldataHash);}}/**
* Generates the correct cross domain calldata for a message.
* @param _target Target contract address.
* @param _sender Message sender address.
* @param _message Message to send to the target.
* @param _messageNonce Nonce for the provided message.
* @return ABI encoded cross domain calldata.
*/functionencodeXDomainCalldata(address_target,address_sender,bytesmemory_message,uint256_messageNonce)internalpurereturns(bytesmemory){returnabi.encodeWithSignature("relayMessage(address,address,bytes,uint256)",_target,_sender,_message,_messageNonce);}}
The sendMessage function in the contract primarily serves to emit the SentMessage(_target, msg.sender, _message, messageNonce); event. Based on our analysis of both CrossDomainEnabled and CrossDomainMessenger, it seems that the protocol for a chain to communicate with another is through the emission of an event. To deepen our understanding, examining the off-chain relayer as defined in relayer.py is essential.
Additionally, it’s noteworthy that there is another function named relayMessage. This function is responsible for decoding the relayed message and executing the call that is encoded within that message.
Reviewing the code in relayer.py, it becomes evident that the worker consistently monitors for the SentMessage event. Upon detection of this event, the worker takes action to relay the message contained within the event, executing the relayMessage function on the destination chain.
Let’s now turn our attention to the bridge contract on the L2 chain.
// 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.
*/contractL2ERC20BridgeisIL2ERC20Bridge,CrossDomainEnabled{addresspublicl1TokenBridge;constructor(address_l2messenger,address_l1TokenBridge)CrossDomainEnabled(_l2messenger){l1TokenBridge=_l1TokenBridge;}/**
* @inheritdoc IL2ERC20Bridge
*/functionwithdraw(address_l2Token,uint256_amount)externalvirtual{_initiateWithdrawal(_l2Token,msg.sender,msg.sender,_amount);}/**
* @inheritdoc IL2ERC20Bridge
*/functionwithdrawTo(address_l2Token,address_to,uint256_amount)externalvirtual{_initiateWithdrawal(_l2Token,msg.sender,_to,_amount);}function_initiateWithdrawal(address_l2Token,address_from,address_to,uint256_amount)internal{IL2StandardERC20(_l2Token).burn(msg.sender,_amount);addressl1Token=IL2StandardERC20(_l2Token).l1Token();bytesmemorymessage;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);emitWithdrawalInitiated(l1Token,_l2Token,msg.sender,_to,_amount);}/**
* @inheritdoc IL2ERC20Bridge
*/functionfinalizeDeposit(address_l1Token,address_l2Token,address_from,address_to,uint256_amount)externalvirtualonlyFromCrossDomainAccount(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);emitDepositFinalized(_l1Token,_l2Token,_from,_to,_amount);}else{emitDepositFailed(_l1Token,_l2Token,_from,_to,_amount);}}}
We start by examining the finalizeDeposit function in this contract. Essentially, this function is responsible for minting a new _l2Token on the L2 chain. In summary, whenever a deposit is made in the L1 chain’s bridge, an equivalent amount of _l2Token is minted on the L2 chain.
In the L2 bridge, there is also a withdraw function, which we can break down into three main steps:
It burns the specified amount of _l2Token.
It encodes a message to be sent to the other chain, L1 in this case. This message is an encoded version of a call to the finalizeERC20Withdrawal function.
It sends this encoded message by again triggering the sendCrossDomainMessage function.
Up to this point, we haven’t delved into the finalizeERC20Withdrawal function in the L1 bridge code. Here is the function definition:
This function essentially reduces the stored amount from the deposits map and transfers back the _l1Token that was initially deposited.
To summarize, in the L1 Bridge, users can deposit tokens, which triggers the bridge’s relayer to relay a message to the L2 Bridge, resulting in the minting of new tokens there. Conversely, when withdrawing in the L2 Bridge, the bridge’s relayer again comes into play, relaying a message back to the L1 Bridge, which then facilitates the transfer of the originally deposited tokens back to the user.
Now that we have a comprehensive understanding of the challenge’s general flow, the next step is to identify where the bug might be located.
Finding the Bug
We understand that the objective is to drain the bridge, and from the initial setup, we know that the bridge already has 2 ETH deposited in it. My approach here was to start by looking for a bug in the most basic aspect, which is how to drain the bridge. Naturally, the first thing that must be triggered is for the bridge to perform an ETH transfer. This can only happen if we initiate a withdrawal from the L2 bridge.
However, it’s apparent that for the withdrawal to take place, we must already have a balance in the deposits map. Therefore, it’s highly likely that there is a bug in the deposit function of the L1 bridge, causing the states of the L1 and L2 bridges to be unsynchronized.
Based on this backtracking thought process, we can start searching for the bug by focusing on the deposit function in the L1 bridge.
Indeed, upon closer examination, a bug was identified. The flaw lies in the deposit process, specifically when depositing with a pair of (WETH, randomToken). When such a deposit is made, the L2 bridge is instructed to mint WETH on the L2 chain instead of the random token, but the balance updated in the L1 records the pair as (WETH, randomToken). This discrepancy leads to a state misalignment between L1 and L2. Here’s what happens when we execute depositERC20(WETH, randomToken, 2 ETH):
On the L1 side, the stored state reflects a deposit of 2 ETH corresponding to the (WETH, randomToken) pair.
Contrarily, on the L2 side, instead of the randomToken, 2 of WETH tokens are minted and credited to you. Essentially, this process results in receiving free WETH.
Due to the aforementioned bug, it becomes possible to withdraw WETH from the L2 chain, which then results in receiving it on the L1 chain. An important observation here is that the balance of the pair (WETH, randomToken) is not reduced as a result of this deposit. Instead, the balance reduction occurs for the pair (WETH, L2_WETH). Now, let’s consider a scenario where we control the randomToken, have set randomToken.l1Token to WETH, and can control the burn() function to ensure it doesn’t revert for any amount we specify.
In such a situation, executing withdraw(randomToken, 2 ETH) will trigger a message relay to the L1 chain, instructing it to transfer additional WETH to us on L1. This withdrawal will be successful because the L1 chain still recognizes a balance in the pair (WETH, randomToken). As a result, we end up receiving an extra amount of ETH.
Now that the bug has been pinpointed, we can progress to the exploitation phase.
Exploitation
To start our exploit, we first initiate the challenge to retrieve the necessary information.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
╰─❯ nc 47.251.56.125 1337
team token? <REDACTED>
1 - launch new instance
2 - kill instance
3 - get flag
action? 1
creating private blockchain...
deploying challenge...
your private blockchain has been set up
it will automatically terminate in 1440 seconds
---
rpc endpoints:
- http://47.251.56.125:8545/AvvTHxbggxudgUKnrMpQhdRU/l1
- http://47.251.56.125:8545/AvvTHxbggxudgUKnrMpQhdRU/l2
private key: 0xb308373bfa60a8e22f7e38c2824a3095e3fbc086613a41d4620e80b057ac9e52
challenge contract: 0xbf1da21516b8975941638E0c8CD791713c88B15B
We aim to get the addresses of L1Bridge and WETH on the L1 chain with the help of foundry CLI tools.
For the L2 contracts, their predetermined addresses can be found in Lib_PredeployAddresses.sol.
1
2
3
4
5
6
7
8
// SPDX-License-Identifier: MIT
pragma solidity^0.8.20;libraryLib_PredeployAddresses{addressinternalconstantL2_CROSS_DOMAIN_MESSENGER=0x420000000000000000000000000000000000CAFe;addressinternalconstantL2_ERC20_BRIDGE=0x420000000000000000000000000000000000baBe;addressinternalconstantL2_WETH=payable(0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000);}
We also retrieve our address using the given private key.
1
cast wallet address --private-key <PRIVATE_KEY>
We proceed by creating our own token, which we name FakeToken. This token implements the IL2StandardERC20 interface defined in the challenge. In the FakeToken, we don’t need to fully implement the mint and burn functions; they just need to ensure they don’t revert. When deploying FakeToken, it’s crucial to set the _l1Token to the WETH address deployed on the L1 bridge, so that FakeToken represents a pair of (WETH, FakeToken).
This action will change the L1 bridge state of deposits[WETH][L2_WETH] to 0. However, the deposits[WETH][FakeToken] will still show 2 ETH. Next, we make another withdrawal with withdraw(FakeToken, 2 ETH).
This withdrawal will succeed, despite not having minted any FakeToken on the L2 chain (which would generally cause the burn() call to fail). This is because we own this token and our burn() function is essentially an empty function that does nothing. Next, the L2 bridge will relay a message to the L1, and the L1Bridge will finalize the withdrawal by reducing the deposits[WETH][FakeToken] to 0 and transferring another 2 ETH to us. As a result, the L1 bridge is successfully drained due to this bug.
1
2
3
4
5
6
7
╰─❯ nc 47.251.56.125 1337
team token? <REDACTED>
1 - launch new instance
2 - kill instance
3 - get flag
action? 3
rwctf{yoU_draINED_BriD6E}
I want to express my sincere thanks for presenting such a realistic bug in this challenge. Kudos to the author, @0xiczc, for crafting an experience that brilliantly mimics real-world scenarios.