Curta Cup CTF Write-Up 📝

What is Curta?

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.

What was Curta Cup?

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.

JUMPDEST

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.

Puzzles

The Fundamental

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! 🎉

Lana

by Robert Chen from OtterSec

đŸ˜¶

Submerged

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 proveSubmergedTxfunction:

    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! 🎉

Usurper's Throne

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! 🎉

Stake Frens

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:

  1. Call the deploy function of the puzzle to setup our FrenCoin and FrenPool smart contracts

  2. Get the withdrawal credentials of the FrenPool by calling the eth1WithdrawalCredentials function

  3. 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 each emitooor 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:

  1. [EOA] Call the deploy function of the puzzle to setup our FrenCoin and FrenPool smart contracts

  2. [EOA] Get the withdrawal credentials of the FrenPool by calling the eth1WithdrawalCredentials function

  3. [EOA] Pass the withdrawal credentials to the emitooor contract, deploy it, and call the emitEvent function to emit the deposit event.

  4. [SMART CONTRACT] Get 16 WETH flashloan from Balancer and withdraw to ether.

  5. [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

  6. [SMART CONTRACT] Burn x amount of tokens so that the balance matches the number needed

  7. [SMART CONTRACT] Transfer tokens to user who solves

  8. [SMART CONTRACT] Deposit back to WETH smart contract and repay flashloan

Solved! 🎉

AddressGame

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! 🎉

LatentRisk

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):

  1. [MASTER CONTRACT] Deploy a drainer contract and transfer to it minted CWETH

  2. [DRAINER CONTRACT] Mint a small amount of CCWETH using the received CWETH

  3. [DRAINER CONTRACT] Redeem almost all minted CCWETH from previous step, leaving 2 wei (so, minted amount - 2).

  4. [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.

  5. [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.

  6. [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.

by Daniel Von Fange, https://twitter.com/danielvf/status/1647330696438882310/photo/1
by Daniel Von Fange, https://twitter.com/danielvf/status/1647330696438882310/photo/1
  1. [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.

  2. [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.

  3. [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! 🎉

PairAssetManager

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)

Wrap Up

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.

Subscribe to Cairo
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.