Curta is a CTF platform where participants solve EVM puzzles, with the opportunity to earn NFTs as rewards for solving them. Participants can receive an âAuthorship Tokenâ and a âFlag Tokenâ. The former is issued to the first solver and can be used to author one puzzle. On the other hand, the Flag Token is given to participants who solve the puzzle, regardless of the ranking/phase.
Curta Cup was a CTF competition featuring eight puzzles authored by ChainLight, Ottersec, and Zellic. The event was co-hosted by Linea and sponsored by Base and Zuzalu. It took place in person in Istanbul and online.
I competed in the JUMPDEST team, alongside ernestognw & pranav__garg_, and we got second place! It was my first time using Curta, so it was a great learning experience with elegant and complex puzzles. This write-up explains our solutions for all the puzzles and thinking process.
by sqrtrev from Zellic
In order to solve this puzzle, we needed to pass a solver check via a storage mapping called solved
. The puzzle contract contained the setSlot
and applySlot
functions, which were a quick giveaway as they essentially allowed to overwrite a value into any storage slot in the contract, including the mapping required to solve the puzzle. In order to do so, we first used the setSlot
function as an intermediary step to store the storage pointer to change, and the value to set. Then, we used the applySlot
function to fetch the stored pointer and value from the previous step, and actually apply them, as shown below:
function applySlot() external {
uint256 ptr = victim[msg.sender];
uint32 val = value[msg.sender];
assembly {
sstore(ptr, val)
}
}
We then took a look into the solve
function, which required a two step process.
function solve(uint32 key, uint256 key2) external {
// Wow you are zero address!
if (msg.sender == address(0)) {
uint256 start = generate(msg.sender);
solved[msg.sender] = start;
return;
}
require(mode[msg.sender] == Mode.Maintenance, "Cannot call with current mode");
serv[msg.sender].helper(key, key2);
revert("Nice Try!");
}
The above function checks if the msg.sender
is zero, and if not, requires that the puzzle be in maintenance mode. We focused on this path, and checked that in order pass the requirement, we needed to set the mode value to maintenance in the mode
mapping. The only way to do this is via steps previously mentioned. At this point, we knew the value to set (Mantaincince
from struct, which has a integer value of 2), but needed to get the storage pointer to the mapping through our address. To do so, we used a helper function like:
function getSlot1(address _user) public returns (bytes32 x) {
assembly {
mstore(0, _user)
mstore(32, mode.slot)
x := keccak256(0, 64)
}
}
which returns the keccak256 hash of the storage location of our address within the mode
mapping. Then, we simply used the setSlot
and applySlot
functions, like:
puzzle.setSlot(uint256(96949205805164238403981878455501696009882483724712148169815194278390637278325), 2);
puzzle.applySlot();
The larger integer is the bytes32 value returned by the getSlot1
function, converted to uint256.
With this, we fulfill the first requirement of the solve
function and can continue to the next step, which was the more difficult phase. This function called the serv
mapping and fetched a function called helper
, passing two input parameters (numbers).
serv[msg.sender].helper(key, key2);
Similarly, we also had to set a value in the the serv
mapping, but it was more challenging as it required to be a struct with a function selector. This way, the msg.sender
would also be zero, passing the if statement in the solve
functionâtherefore, the helper function in the struct needed to call back the solve
function.
The limitation was that the setSlot
function only allowed to set a value of type uint16, meaning a size no more than 2 bytes. A function within a struct only stores the function pointer, a JUMPDEST
opcode allowing us to jump to any logic in the contract. As mentioned earlier, we needed to jump back to the solve
function logic starting point to reach the if statement. To do so, we used the Foundry debugger to find the JUMPCODE
value that was tied to the solve
function, which was 515
. Then, we got the storage pointer for the serv
mapping with our address:
function getSlot2(address _user) public returns (bytes32 x) {
assembly {
mstore(0, _user)
mstore(32, serv.slot)
x := keccak256(0, 64)
}
}
We converted the output, bytes32
, to uint256
and called the setSlot
and applySlot
functions:
puzzle.setSlot(uint256(23566275326459084232999747468395654632023765854764997275103124170317809043218), uint16(515));
puzzle.applySlot();
Finally, the solve
storage mapping contained our address, passing the solver check. Solved! đ
by Robert Chen from OtterSec
đ¶
by ChainLight
This puzzle is composed by two contracts, Submerged
and TxHashSimulator
. In order to pass the solver check, we needed to store keccak256(abi.encode(seed, solution))
in the submergedSeeds
mapping within Submerged
âwe already know that the seed parameter is uint256(keccak256(abi.encode(addr)))
. Within the proveSubmergedSeed
function in the same contract, we noticed that we can store a value in the mapping; thus, we can assume that the final step to solve this puzzle is to get access to this function. However, it contains a check for the submergedTxs
mapping. In order to pass this check, the proveSubmergedTx
function allows us to set a value in that mapping. Still, to access this last function, the TXHASH
parameter in the TxHashSimulator
contract needs to not be empty.
TxHashSimulator
doesnât have any external or public functions, so we can only access it via the fallback, which calls another function, getCurrentTxHash
. This function takes the first 32 bytes of our calldata for a parameter called seed
and the next 32 bytes for a parameter called gasLimit
. Then, it builds v, r, s signature parameters: v is fixed to 27, r is the keccak256 hash of seed, and s is the keccak256 hash of r. With this, it uses recursive-length prefix (RLP) serialization to build a transaction, including the transaction gas price, gas limit, address, msg.value
, msg.data
, and the signature parameters. It hashes the transaction for the return value, and then removes the signature data from it to hash it again and store it in parameter signingHash
. This last hash is finally passed into ecrecover
alongside the v, r, and s parameters, and calculates an output address which should match the msg.sender
. How to pass this check? With a single use address, explained by Nick Johnson 7 years ago.
// truncate the tx fields to exclude the signature data
assembly {
mstore(txList, 6)
}
bytes32 signingHash = keccak256(RLP.encodeList(txList));
require(ecrecover(signingHash, v, r, s) == msg.sender, "could not verify tx hash");
In Ethereum, signatures are validated by the ecrecover
function, available as an EVM precompile and used in the puzzle function mentioned before. ecrecover
takes a message and its signature in order to return the public key that signed it (from which we can get the address as it is the last 20 bytes of the public key). When sending a transaction, the from
address isnât not explicitly included in it, rather Ethereum uses ecrecover
to get the from
address. Typically, the private key of the from
address is needed to generate a signature that returns that address. However, itâs possible to skip this and generate a random signature that returns a valid address 50% of the time. Since the signature generation is random, itâs not possible to generate another signature that returns the same address unless you have the private key of itâhence, we can generate and use such address once. To utilize a single-use address, we need to make sure itâs funded so that it can pay gas for the one and only transaction.
Going back to the puzzle, we know the message to sign is the signingHash
parameter, so we can generate a random signature using the seed
parameter. However, as noted before, half of all signatures are valid in the sense that ecrecover
can return a public key and thus an address. Due to this, the puzzle allows to include a solution
parameter when calculating the seed
parameter, so that we are able to generate a valid signature:
function verify(uint256 seed, uint256 solution) external view returns (bool) {
return submergedSeeds[keccak256(abi.encode(seed, solution))];
}
This step requires is a trial-and-error process as we have to generate a random signature for the hash using seed
and solution
, check itâs valid, and, if not, change solution
until we find a valid combination (keep in mind that seed
is constant as it represents us).
Once we have a valid signature, we are able to pass the ecrecover
check as the msg.sender
will be the single use address. At this point, the first 64 bytes of our transaction calldata to TxHashSimulator
are already being used, so the puzzle uses the next 32 bytes to get an address for the target
parameter. The remaining bytes are used for a callâs data, so we need to use this to call the proveSubmergedTx
function from the Submerged
contract, which address should be the target
parameter. Itâs not possible to do it separately because the TXHASH
variable will then not be stored, failing the first check of the proveSubmergedTx
function:
function proveSubmergedTx() external {
bytes32 txHash = simulator.TXHASH();
require(txHash != bytes32(0), "must use TxHashSimulator");
require(address(tx.origin).balance == 0, "not fully submerged");
submergedTxs[txHash] = true;
}
After passing the first check, we reach another one that requires the balance of the single use address to be zero. When calling a function with the call
opcode, the defined gas limit is forwarded to the target
address, so the cost of the call is calculated by gas limit * gas price
, which should be exactly equal to the Ether balance of the single use address so that by the time we reach the check, our balance will be 100% used (i.e. be zero); keep in mind that in the EVM, remaining gas out of the gas limit is returned back to the caller (not spent).
As mentioned before, we need to fund the single-use address before sending its transaction. If we fund it with 0.1 Ether, we know that gas limit * gas price = 0.1
, so we need to make either gas limit or gas price constant. Itâs much easier to set the gas limit as constant as itâs not dependent on a volatile factor such as gas price of the network. Thus, the gas limit should be at least the amount of gas that it takes to reach the final line of the proveSubmergedTx
function (submergedTxs[txHash] = true
). To get it, we can comment out the balance check, and check the gas consumptionâthe gas limit should be the gas consumption plus a small margin to make sure it doesnât run out of gas when we bring back the second check. With this gas limit, we need to recalculate the single use address as it was part of the hash used to generate the signature :s.
At this point, the transaction from the single use address should successfully store its txHash
parameter in the submergedTxs
mapping. To finish, we need to call the proveSubmergedSeed
function (from any caller address) and pass a rawTx
bytes parameter. This parameter is the RLP encoded list generated in the getCurrentTxHash
function within the TxHashSimulator
contract:
bytes[] memory txList = new bytes[](9);
txList[0] = RLP.encodeUint(0); // nonce
txList[1] = RLP.encodeUint(tx.gasprice); // gas price
txList[2] = RLP.encodeUint(gasLimit); // claimed gasLimit
txList[3] = RLP.encodeUint(uint256(uint160(address(this)))); // to address
txList[4] = RLP.encodeUint(msg.value); // tx value
txList[5] = RLP.encodeBytes(msg.data); // tx data
txList[6] = RLP.encodeUint(uint256(v)); // v
txList[7] = RLP.encodeUint(uint256(r)); // r
txList[8] = RLP.encodeUint(uint256(s)); // s
// txHash = keccak256(RLP.encodeList(txList));
bytes memory rawTx = RLP.encodeList(txList);
Solved! đ
by ChainLight
This puzzle is composed by two contracts: a DAO
and a Throne
, which the DAO
controls. In order to solve the puzzle, we needed to write into the storage of two mappings, usurpers
and thrones
in the Throne
contract. This contract contained two key functions that were only callable by the DAO
: addUsurper
and forgeThrone
.
However, the DAO
was only able to call the forgeThrone
function, but not the addUsurper
function. These two functions were key, as they are the only functions that allowed us to set storage in the mappings and pass the solver check.
First, we passed one of the solver checks by writing into the thrones
mapping by creating a normal proposal:
dao.createProposal(id, address(puzzle), _forgeThroneData(), "");
However, each proposal needed three votes to pass the quorum and be executed. One in favor vote was automatically added when creating the proposal, and the two other votes can be generated by using two random EOAs or contracts. We decided to go with the latter case, and create two contracts that voted in favor. At this point, we executed the proposal and passed the first check.
Then, we started taking a look deeper into how proposals are stored. The DAO
contract uses an optimized library to store proposal data, called CheapMap. The data and description of proposals are stored via two functions:
function setDescription(uint256 id, string memory desc) internal {
CheapMap.write(descriptionKey(id), bytes(desc));
}
function setData(uint256 id, bytes memory data) internal {
require(validData(data), "payload data not allowed");
CheapMap.write(bytes32(id), data);
}
This was a big red flag as it allowed us to store a description
value into a data
storage of a proposal, and vice versa. This would allow us to bypass the check that was enforced in the DAO
contract that limited the callable functions to only forgeThrone
. The problem was that the CheapMap library created contracts to store data, and if we tried to override the data of another proposal, it was going to revert because the contract with the data already existed. Therefore, we realized that it was possible to pass a SELFDESTRUCT
opcode as the data
of a proposal, allowing us to self destruct the data storage contract, and overwrite it via a description
passing the addUsurper
function. Hence, we needed to execute two proposals, a malicious one with self destruct logic, and a overwrite one with calldata to the addUsurper
function (passing our address to it).
To do so, we first need to make sure that the proposals have matching IDs. Afterwards, we generate the self destruct construction bytecode using the SELFDESTRUCT
opcode, which is 0xff
:
bytes memory maliciousData = hex"6d2cd781ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
Then, we create the malicious proposal combined the previous parameters:
dao.createProposal(_maliciousDescriptionKey(), address(puzzle), maliciousData, "");
At this point, instead of voting and executing this proposal, we simply call the data CheapMap contract, triggering the self destruction. Then, we create the second proposal with the overwrite calldata as the description
parameter by converting it to string:
bytes memory maliciousDescription = abi.encodeCall(
puzzle.addUsurper,
addr
);
dao.createProposal(
_dataKey(),
address(puzzle),
_forgeThroneData(),
string(maliciousDescription)
);
With this proposal created, we just overwritten the data of the previous proposal, so now we can vote for it and execute it. This will call the addUsurper
function with our address, and set the usurpers
mapping so that we can pass the solver check. Solved! đ
by ChainLight
This puzzle seemed complex at first as it involved ChainLightâs Relic Protocol, a provably secure source of historical data on-chain that leverages zero knowledge proofs. However, not very in-depth understanding of the protocol was needed. The puzzle is composed by two main contracts: FrenPool
and FrenCoin
. The latter is a ERC20 token that can be minted by calling the showFrenship
function, which calls the FrenPool
.
In order to solve the puzzle, our balance of FrenCoin
needed to be exactly the number calculated with the following formula:
uint256(uint128(uint256(keccak256(abi.encode(addr)))))
The showFrenship
function has a try-catch statement: it tries to deposit into the pool, but if it fails, it mints a huge amount of tokens to the user. We needed to make the deposit fail to receive a huge amount of tokens, and then we could burn tokens so that our balance is exactly the number calculated with the formula above, passing the solver check.
FrenCoin
calls the join
function from the FrenPool
, which checks that we pass a valid proof of a DepositEvent
. After confirming the validity of the proof, it deposits the msg.value
into the Ethereum deposit contract.
In order to get the huge amount of tokens, the join
function needed to revert with a very specific reason: DepositContract: deposit value too high
. This error is only possible if someone tries to deposit 2^64 Gwei, which is 18.4 billion ETH. Of course, this is impossible so we needed to find another way of reverting the join
function. When checking the verifyDeposit
function, which is called in the join
function to check the proof, we realized that it didnât have any checks on the contract that emitted the DepositEvent
, a big red flag. That same function was calling the get_deposit_root
function from the contract that emitted the event. This allows us to make an arbitrary contract emit DepositEvent
, and have a function with the get_deposit_root
selector that reverts with the specific errorâthis will revert before calling the Ethereum deposit contract, and will be caught by the try statement, minting the huge amount of tokens.
The arbitrary contract, named emitooor
, needs to have two functions: emitEvent
and get_deposit_root
. The challenge is that the pool requires that the selector of the event emitted by the emitEvent
function be the same as the one emitted by the Ethereum deposit contract, which has the following parameters:
event DepositEvent(bytes pubkey, bytes withdrawal_credentials, bytes amount, bytes signature, bytes index);
However, the pool tries to decode the data of the event using a defined struct:
DepositEventData memory eventData = abi.decode(logData.Data, (DepositEventData));
Therefore, we needed to have the exact same event, but instead group the parameters into a struct so that the pool can decode the data without reverting. The struct looks like:
struct DepositEventData {
bytes pubkey;
bytes withdrawal_credentials;
bytes amount;
bytes signature;
bytes index;
}
Another key aspect is that the withdrawal_credentials
parameter needed to match the credentials of our pool, which is different for each user deployment. Therefore, we first needed to deploy the setup (FrenCoin
and FrenPool
), get the withdrawal credentials of the pool, and pass them in the event to emit. Additionally, the amount
parameter needed to be exactly the value of DEPOSIT_AMOUNT_KECCAK
from the pool (hardcoded, i.e. same for every pool). This allowed us to bypass the checks after the function validated the proof:
function verifyDeposit(address prover, DepositProof calldata proof) internal returns (bytes memory pubkey) {
// Validate proof...
CoreTypes.LogData memory logData = abi.decode(fact.data, (CoreTypes.LogData));
require(logData.Topics[0] == DepositEvent.selector, "incorrect log event proven");
DepositEventData memory eventData = abi.decode(logData.Data, (DepositEventData));
require(keccak256(eventData.amount) == DEPOSIT_AMOUNT_KECCAK, "incorrect deposit amount");
require(
keccak256(eventData.withdrawal_credentials) == keccak256(eth1WithdrawalCredentials()),
"incorrect withdrawal credentials"
);
require(
IDepositContract(fact.account).get_deposit_root() == proof.expectedRoot,
"unexpected deposit root, potential frontrun"
);
return eventData.pubkey;
}
To recap, the steps so far are:
Call the deploy
function of the puzzle to setup our FrenCoin
and FrenPool
smart contracts
Get the withdrawal credentials of the FrenPool
by calling the eth1WithdrawalCredentials
function
Pass the withdrawal credentials to the emitooor
contract, deploy it, and call the emitEvent
function to emit the deposit event.
Now, we move to Relic Protocol. In order to get and verify a proof, we needed to choose a valid proverâin this case, we needed one that focused on logs and events: the LogProver
(source, Etherscan link). At this point, we already emitted a valid event on-chain, so we just needed to get the proof for it. To do so, we used the Relic SDK:
import { ethers } from 'ethers'
import { RelicClient } from '@relicprotocol/client'
const provider = new ethers.providers.JsonRpcProvider('MAINNET_RPC')
async function main() {
const relic = await RelicClient.fromProvider(provider)
const logs = await provider.getLogs({
fromBlock: 18607267, // block of emition - 1
toBlock: 18607269, // block of emition + 1
address: "EMITOOOR_ADDRESS",
});
console.log(logs[0])
console.log(await relic.logProver.getProofData(logs[0]));
}
main()
This script basically fetches the log for our event, and passes it to Relic to get a valid proof. The log looks like:
{
blockNumber: 18607268,
blockHash: '0x79e660850d17e812d88cdc4f2a3fc3509c9a4813d8a6fd1e3c3128751bc30720',
transactionIndex: 16,
removed: false,
address: '0xA9397839b4f5B734A2A551Db39308610aC19520C',
data: '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200100000000000000000000008a2f191aed6996d25fe46f8407a77baf7e283008000000000000000000000000000000000000000000000000000000000000000800a0acb90300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
topics: [
'0x649bbc62d0e31342afea4e5cd82d4049e7e1ee912fc0889aa790803be39038c5'
],
transactionHash: '0xbef4a9ffce5d0ae9e6242eb37a65e48b697820d760e986343c9ae9b03b9f0d26',
logIndex: 45
}
And Relic returns the proof and the signature.
Itâs not possible to reuse the
emitooor
contract or the event of another user because eachemitooor
contract needs to include the withdrawal credentials of the user pool in the event.
With the proof, we can build the data that we pass in the FrenCoin
âs function showFrenship
:
FrenPool.DepositProof memory proof = FrenPool.DepositProof({
blockNum: 18607268,
txIdx: 16,
logIdx: 0,
expectedRoot: bytes32(0),
proof: bytes(hex"proof")
});
The blockNum
and txIdx
should be exactly the same from the log. An interesting detail is that the log may have any logIndex
number, but the logIdx
parameter in this DepositProof
struct should be zero.
With this, we are almost ready to solve the puzzle. However, the join
function in the FrenPool
smart contract required that the msg.value
be at least 16 ether. Hence, we needed to find a way to get Ether for this function: a flash loan! Balancer is the perfect candidate as they donât charge fees for flashloans :)
To get the 16 Ether, we request a flash loan from the Balancer pool asking for 16 WETH, and when received, our solver smart contract should withdraw from WETH to get 16 Ether, execute the solution, deposit back to WETH to get 16 WETH, and return the flash loan.
If we execute at this point, the proof will be validated and the pool will try to call our emitooor
smart contract. However, it will revert with the specific error, making the FrenCoin
mint as a huge amount of tokens. However, we will still not pass the solver checks as our balance needs to be the exact number described before. Therefore, we needed to burn the difference to get to the right balance.
Finally, we are ready to execute the solve. To recap, here are the steps, highlighted by EOA and solver contract calls:
[EOA] Call the deploy
function of the puzzle to setup our FrenCoin
and FrenPool
smart contracts
[EOA] Get the withdrawal credentials of the FrenPool
by calling the eth1WithdrawalCredentials
function
[EOA] Pass the withdrawal credentials to the emitooor
contract, deploy it, and call the emitEvent
function to emit the deposit event.
[SMART CONTRACT] Get 16 WETH flashloan from Balancer and withdraw to ether.
[SMART CONTRACT] Build the DepositProof
struct data, and call the showFrenship
function of the FrenCoin
, sending the 16 ether, our seed, prover address, and DepositProof
data
[SMART CONTRACT] Burn x amount of tokens so that the balance matches the number needed
[SMART CONTRACT] Transfer tokens to user who solves
[SMART CONTRACT] Deposit back to WETH smart contract and repay flashloan
Solved! đ
by ChainLight
This puzzle was all about bruteforcing a salt to generate a specific address using CREATE2. As we can see from the puzzle, in order to solve it we needed to pass a series of checks:
uint256[16] memory c;
for (uint256 i = 0; i < 40; i++) {
uint256 step = (solution >> (i * 4)) & 0xf;
c[step] += 1;
}
{
require(((c[0xa] + c[0xe]) & 1) == box(seed, 0) % 2, "#vowels");
require(((c[0xb] + c[0xc] + c[0xd] + c[0xf]) % 3) == box(seed, 1) % 3, "#consonant");
}
{
uint256 _sum = 0;
for (uint256 i = 1; i < 10; i++) {
_sum += c[i] * i;
}
require(_sum == 25 + seed % 50);
}
return IBox(address(uint160(solution))).isSolved();
}
The first check, named vowels
, required that uint256 numbers in the 10 and 14 slots of the c
array be equal to box(seed, 0) % 2
â this last value is a constant, so we can calculate it beforehand. As with the other checks, this value depends on the address of the solver, so itâs not possible to reuse the solutions from other users. In our case, this value was 0 (even or odd). For the second check, consonant
, we can also calculate the matching value beforehand. This check requires that the sum of the numbers in slots 11, 12, 13, and 15 of the c
array be equal to box(seed, 1) % 3
(i.e. be a multiple of 3 or not). For us, this value was 1. The final check requires that the sum first 10 numbers of the c
array multiplied by the index in the loop is be equal to 25 + seed % 50
âa value of 68 in our case.
Lastly, the puzzle required to pass a uin256 value called solution
. However, the puzzle converts the solution
to an IBox
contract/address and can calls its isSolved
function, which should return true. Now, we have all of the constrains to generate a uint256 number to pass all the checks, but it should also be a valid contract that has the isSovled
function implemented.
There are two main ways of generating such vanity address; for EOAs, the private key of the address and the nonce decide a new contract address, so we need to brute force either. Alternatively, we can use CREATE2 with custom salt. Brute forcing the ECDSA private key and the public key is quite slower than just hashing it, so itâs evidently much faster to use CREATE2. This approach was previously showcased by ChainLight in their Curta puzzle 2 write up, which can be found here. The CREATE2 method generates an address with the formula:
keccak256(0xff || sender_address | salt | keccak256(creation_code)) & 2^160-1
Therefore, we can either bruteforce using the creation code, or the salt. The creation code should deploy the contract with at least the isSolved
function, so we have flexibility to add additional code and functions. However, this approach is costly as we will add more bytecode, increasing the gas required to deploy (thus increasing cost).
In order to bruteforce salt of CREATE2, there are a bunch of libraries that offer such functionality. Some are solidity-selector-miner (philogyâs adaption), create2crunch, ethereum-vanity, eth-create2-calculator, eth-create2, profanity2, and more. We wrote our own in Rust :) Some calculations by philogy indicate that Rust + CREATE2 is the fastest way of bruteforcing a vanity address:
Python (EOA) ~300/s -> Rust (EOA) ~15k/s -> Rust (create2) ~10M/s
With the right salt, we can deploy our IBox contract using the factory and pass the uint256 value of the contract address to the puzzle. Solved! đ
by ChainLight
This puzzle was my favorite one as it involved a scenario which I studied before: the Compound v2 rounding errors. Since the discovery of the bugs, a bunch of Compound forks got drained due to this, most notably Hundred Finance. In order to be exploitable, the fork needed to have the rounding issues and a cToken
that had a valid collateral factor but with a totalSupply
of 0, i.e. empty/unused.
In order to get started, the puzzle required to setup all of the Compound contracts and tokens. Initially, I suspected the solution was around the rounding errors, but it wasnât evident until I saw this commented out line from the setup:
comptroller._setCollateralFactor(CToken(CCUSD), 0.9 ether);
comptroller._setCollateralFactor(CToken(CCStUSD), 0.9 ether);
comptroller._setCollateralFactor(CToken(CCETH), 0.7 ether);
comptroller._setCollateralFactor(CToken(CCWETH), 0.7 ether);
comptroller._setCloseFactor(0.5 ether);
comptroller._setLiquidationIncentive(1 ether);
CCUSD.mint(10000 ether);
CCStUSD.mint(10000 ether);
CCETH.mint(10000 ether);
// CCWETH.mint(10000 ether);
CWETH.mint(_caller, 10000 ether);
Originally, the commented out line was // CWETH.mint(10000 ether);
but I believe CWETH
was a typo and instead should have been CCWETH
as shown in the code block above.
In any case, understanding this subtle code block is key. As we can see, the setup set the collateral factor of CCWETH
to 70%, but didnât not mint CCWETH
(cToken
), leaving the totalSupply
of it to 0. Additionally, we can see that the caller (us) gets 10000 ether of the underlying collateral of the CCWETH
cToken, the CWETH
token.
Now, we can simply build the exploit following the Hundred Finance hack or any other previous incident. The steps are the following (draining the CCUSD
market as an example):
[MASTER CONTRACT] Deploy a drainer contract and transfer to it minted CWETH
[DRAINER CONTRACT] Mint a small amount of CCWETH
using the received CWETH
[DRAINER CONTRACT] Redeem almost all minted CCWETH
from previous step, leaving 2 wei (so, minted amount - 2).
[DRAINER CONTRACT] Donate all remaining CWETH
to the CCWETH
contract, inflating the collateral value of our 2 wei amount of CCWETH
. This happens as Compound calculates the cash available by getting the itâs own balance of the underlying (to be specific, the getAccountSnapshot
calls exchangeRateMantissa
, which relies on the balance.
[DRAINER CONTRACT] The 2 wei CCWETH
is worth a lot, so the contract has a collateral value that allows it to borrow any amount of assets in any market (thereâs an exception for CCETH
which I will get into later). In this example, the drainer contract borrows all CUSD
available in the CCUSD
market.
[DRAINER CONTRACT] Now, we get into the key step to exploit the rounding issue: the drainer contract can now redeem all but 1 wei of the previously donated CWETH
by calling the redeemUnderlying
function of the CCWETH
market. However, due to the rounding error, the market only burns half the drainer contractâs 2 wei CCWETH
. A calculation is shown below as an example of the issue. The protocol only removes 50% of the "shares" , while sending the whole requested underlying.
[DRAINER CONTRACT] After redeeming the underlying, the drainer smart contract is left with all but 1 wei of CWETH
, and all CUSD
drained from the CCUSD
market. With this balances, it transfers to the exploiter the CUSD
, and CWETH
to the master contract.
[MASTER CONTRACT] After receiving the CWETH
from the drainer contract, the borrow position is underwater, so the master contract is able to liquidate it. The liquidation requires a extremely small amount of CWETH
, which sends the remaining 1 share of CCWETH
to the master contract.
[MASTER CONTRACT] Redeems the received 1 share of CCWETH
to receive back the small amount of CCWETH
used to repay the borrowâat this point, the CCWETH
market is empty again, thus allowing the process to be repeated, draining each remaining market (CCstUSD
and CCETH
).
Draining the CCstUSD
market is exactly the same as for CCUSD
as they have the same price, which is much less than the price of CCWETH
(used to inflate prices); the price of CCUSD
is 1e18, and CCWETH
is 200e18. However, per the setup, the price of CCETH
is the same as CCWETH
, 200e18. CCWETH
also had a 70% collateral factor, so draining the CCETH
market was a bit different.
As the prices are the same, we were not able to inflate the value of the collateral by a lot, so we could only borrow a small amount of CCETH
. We decided to borrow 25% of the underlying CETH
from the CCETH
market three times, allowing us to reach the minimum amount of funds drained to pass the solver check. Note that this puzzle required to deploy drainer contracts as itâs not possible to re-use one single contract because the position of the borrower gets affected. Solved! đ
by ChainLight
After focusing on other challenges, we arrived to this one and it was already solved. So, we decided to take a look into the solution of the solver on-chain: they basically deployed the puzzle setup, and then deployed their solver smart contract. We realized that after setting up the puzzle, they passed their puzzle address to the solver contract and it performed the logic needed to solve it.
Easily enough, we deployed our puzzle setup, re-deployed their solver contract by using the same init bytecode, and passed our puzzle address as a parameter of the go
function. Solved! đ (will eventually update with actual solution :P)
I decided to not include Lana as Iâm working on a in-depth explanation about itâwill update this write-up when ready. If thereâs interest, Iâm happy to share our tests and scripts for the all puzzles as well :)
Curta Cup was a lot of fun! Thanks to the Curta team for organizing, and ChainLight, Ottersec, and Zellic for authoring the puzzles.
See you around in the leaderboard đ„
I hope this was an insightful read and that the explanations were clear! Follow me (@cairoeth) on X to get notified of future interesting posts and articles. This work is licensed under CC BY-SA.