Blog About Contact  en
Blog About Contact  en

Capture The Ether writeup

Introduction

I recently achieved a perfect score (11600/11600) at the Capture the ether series of smart contract security challenges. Here are the notes I took while solving them, followed by a conclusion. I made my points a bit more explanatory after I finished, but it's still an overwiew of the steps I took rather than a tutorial. Please don't read this if you want to attempt the challenges yourself.

Preliminary notes

The challenge flow is usually the following :

  1. Begin the challenge by submitting a tx to the CTE contract. This creates an instance of the contract that you will need to attack
  2. Solve the challenge by interacting with the recently deployed contract
  3. Call the ‘Check solution' tx on the website, which will check the isComplete status of the recently deployed contract.
Ok, let's dive into it.

1. Warmup

1.1 Deploy a contract

This is a trivial blockchain interaction that can be done using Remix.

1.2 Call me

Trivial too, deploy the contract and call callme(). I did this by verifying the contract on Etherscan (which creates the UI automatically) but it is feasible without verifying it, because we have the abi and can create the encoded data.

1.3 Choose a Nickname

This one was a bit tricky. Because the setNickname() function of the CTE contract is public, we should be able to call it directly. I used the encodeFunctionData of ethers.io. Because the accepted data is of type bytes32, we cannot pass a string directly. I decided to convert the string to a hexadecimal base offchain, but a contract which takes a string as an input and returns the hex encoding can be done on chain. I failed several times because I padded the hex data on the wrong side. I'm used to having the zeros at the beginning but looking at the line of the verifying function :


return cte.nicknameOf(player)[0] != 0;
          

we see that it's the first byte that needs to be modified. So instead of passing


0x00000000000000000000000000000000000005261706861eb6c2044656b6e6f7
      

I needed to pass


0x5261706861eb6c2044656b6e6f70000000000000000000000000000000000000
      

2. Lotteries

2.1 Guess the number

It's visible on the contract code at uint8 answer = 42; so we can pass it directly.

2.2 Guess the secret number

Since the stored hash is the one of an 8 bit number (uint8), it can be bruteforced offchain.

2.3 Guess the random number

I struggled a bit with this one. The main issue I had was that I didn't pass the timestamp in the uint256() function, which caused the hash to be different. I deployed the contract and then used etherscan to retrieve the block hash of the preceding block and the timestamp. This can be done on chain (the hash of a block can only be accessed for the 256 following blocks though) or offchain with a library. In the end I deployed locally a contract containing this line:


answer = uint8(keccak256(bytes32(<block_hash_prefixed_with_0x>), uint256(<unix_timestamp>)));
      

2.4 Guess the new number

For this, a new contract must be deployed. This will calculate the answer beforehand and then call the guess function within the same transaction. (and the same block). This is my contract :


pragma solidity ^0.4.21;

interface GuessTheNewNumberChallenge {
function guess(uint8 n) external payable;
}

contract computeAnswer {

  function() payable {}
  GuessTheNewNumberChallenge gtnnc = GuessTheNewNumberChallenge(0x356Fe316C0Ad5C1A10596b17382C49a15A251b61);

  function compute() public payable {
    uint8 computedAnswer = uint8(keccak256(block.blockhash(block.number - 1), now));
    uint valueToSend = 1 ether;
    gtnnc.guess.value(valueToSend)(computedAnswer);
  }
}

There needs to be a fallback function, that's what the function() payable {} does, because the contract needs to call the other contract with a value of 1 ETH. However, I was a bit dumb and forgot to implement a withdraw function, so the eth is locked in my attacker contract… At least, the other one is drained, so I passed the challenge. I also spent some time figuring out the correct syntax to call another function and specify the value (it's pragma solidity ^0.4.21 after all).

2.5 Predict the future

For this challenge, a contract needs to be deployed. We see that we must make the prediction before the block is mined. However, since we take the uint8 of the hash modulo 10 (%10), we can attempt a brute force.

First we will set an arbitrary value between 0 and 9. We must do this to change the guesser address to our own. Then, we'll check if the result is that one and, if so, call the settle() function. First, I called the lockInGuess function directly but because the contract I deployed was calling the settle(), the msg.sender == guesser requirement was not met. I then rewrote the attacker contract in order for me to be able to call the lockInGuess function. Then I repeatedly called the compute function of my attacker contract, that computes the answer and, if it's the same as we set before (I chose 6), calls settle(). The average number of tries is 10, which is perfectly doable by hand.

I noticed there was a failure in my 1st attempt because of the number of tries that failed. I then looked at the gas spent and noticed that some were higher. By computing the value by hand for those, I saw that despite the right number being computed, there was an error. Also, since I hadn't put an error message, only the gas spent was telling me that the revert error came from the victim contract.

2.6 Predict the block hash

To solve this challenge, I made use of the limits of the block.blockhash() special function. Indeed, as written in the docs : > block.blockhash(uint blockNumber) returns (bytes32): hash of the given block - only works for 256 most recent, excluding current, blocks - deprecated in version 0.4.22 and replaced by blockhash(uint blockNumber)

So, if I set the hash to the default one (0x0000...0 for 32 bytes), and waited 256+ blocks (approx. 1h at the time of writing) to call the settle() function, the hash would be the same, as I would be looking to far back into the past and the function returns a 0x0 hash.

3. Math

3.1 Token sale

Okay, this one took me some time to figure out. One hypothesis I had at first (and was like 80% convinced that it wouldn't work) was to try to sell 0 tokens and see if the contract somehow had to pay for the gas. As I suspected, it's always the caller (EOA) that pays gas. So I had to search more. I finally thought that there should be some kind of integer overflow -or underflow- somewhere. The -=, += operations, as well as the challenge being in the math section were signs that I was on the right track. After some searching, it came to mint that an overflow can also be obtained after a multiplication. The msg.value of a tx is a uint256 after all. And 1 eth = 1018 wei. So if I managed to get an overflow that resulted in the smallest result possible, I could use it as the value, and have plenty of tokens on the balance while having spent a little. We can divide the biggest uint256 number, aka, in decimal :


115792089237316195423570985008687907853269984665640564039457584007913129639936
      

by 18, which gives :


115792089237316195423570985008687907853269984665640564039457.584007913129639936
      

If we round this to have an integer, we get :


115792089237316195423570985008687907853269984665640564039458
      

Then, by multiplying this by 1 ether (i.e. 118), we get


415992086870360064
      

which is 0.415992086870360064 ether. This isn't a huge amount to get, so it's feasible.

By calling buy() with


115792089237316195423570985008687907853269984665640564039458
          

tokens as parameter and a value of 0.415992086870360064 ether, the requirement msg.value == numTokens * PRICE_PER_TOKEN should be met. The contract balance is now 1.415992086870360064 ether. We can then sell() 1 token to get an ether back, and we have a huge balance so it's ok. The contract balance is now 0.41.. eth, meeting the requirement to pass the challenge.

3.2 Token whale

The trick for this one lays in a hardcoded value of msg.sender in the _transfer function. Generally, this wouldn't pose a problem, but because we are implementing an allowance system that allows one party to transfer tokens on behalf of another party, the _transfer function needs to account for it. Here are the steps : 1. Account player starts with 1000 tokens and sends them to account player2. (the accounts are owned by the same person) 2. player2 authorises player to spend tokens on its behalf 3. player uses the transferFrom() function to transfer a nonzero amount of tokens to any other address. This will meet the requirements. The last line calls the _transfer() function, which has the following line :


balanceOf[msg.sender] -= value;
      

and because the token balance of player is 0, we have an integer underflow, resulting in an enormous balance, meeting the requirement for the challenge.

3.3 Retirement fund

What immediately stands out is that if we manage to set


uint256 withdrawn = startBalance - address(this).balance;
      

to a nonzero value, we can meet the requirement and get all the funds from the collectPenalty() function.

We cannot change the startbalance, so if we can change the contract balance, we can have an integer underflow. However, it's not as easy as sending ETH to the contract, because it doesn't have a payable fallback function. So, we can create an other contract and forcefully send ether to our victim contract, using the selfdestruct method. (more info in this video by the channel Smart Contract Programmer).

3.4 Mapping

This challenge is confusing at first because one has to change the value of the isComplete variable where there apparently is no way to do it. One piece of documentation I found interesting was the Program the Blockchain article titled “Understanding Ethereum Smart Contract Storage”, particularly the “Locating Dynamically-Sized Values” paragraph.

So, our isComplete variable is stored at slot 0 with a value of 0. The goal is therefore to change it to a 1, corresponding to true, for a boolean. For that, we can use the provided functions. We know that map is “stored” at slot 1, and that for any element with index index of size elementSize, its storage slot will be


uint256(keccak256(slot)) + (index * elementSize);
      

where slot is the slot of our map array.

So, we'd like to store something at slot 1, so, with an elementSize of 1 (easier), which index should we use to do so ? Essentially, we can solve the equation for index, keeping the integer overflows in mind. We have that


uint256(keccak256(1)) = 80084422859880547211683076133703299733277748156566366325829078699459944778998
      

and we want to add x so that the result is 0. We can do 8008442...78998 + x = 2256 because the max value is 2256-1 since we start from 0. This would give us the index we should use in order to overflow to 0. The result is:


35707666377435648211887908874984608119992236509074197713628505308453184860938
      

and, indeed it works ! We can now call the set() to store 1 at the index found. This overwrites the value of the isComplete variable to 1.

3.5 Donation

At first I didn't exacly understand how it went. I then saw that by making a donation, the owner address changed. Then I looked at the remix warnings and saw that there was one warning that said uninitialized storage reference. This means that the donations[] array starts at slot 0, which stores the length and then uses the slots 1,2,3… By making a donation, the timestamp overwrites donations.length and donation.etherAmout overwrites the owner, stored at slot 1. Conveniently, the challenge is designed so that the amount to send isn't astronomically large. We can therefore pass our address as the etherAmount parameter and add the corresponding value to the tx.

3.6 Fifty years

The main idea is to submit 2 contributions: one that's 2256-1 days for the unlockTimestamp and a second one that is 0 for the timestamp. so that, when withdrawing, because the function assumes that the timestamp only go by increasing value, we can withdraw for index 2 (which unlocks at 0) and get the eth from indexes 0 and 1. But it's not so simple.

A bit of playing around with remix + looking at the storage and making variables public show us that > contribution.amount = msg.value overwrites queue.length. Then queue.push increments queue.length So, in the first tx, the message value needs to be 1 wei as not to overwrite the 1eth contribution, and the second tx needs to have a value of 2wei. The total is 1et + 2wei +3wei because each time, the push incremented the variable by 1. So the balance is 1ETH and 3 wei and the total amount is 1 ETH and 5 wei, so we need to force send 2 wei to the contract.

Also, the head variable changes with the timestamp so it's important we set the timestamp to 0 instead of any other value in the past because we want to loop from index 0 to 3 when we withdraw (and the head needs to be at 0 for this).

4. Accounts

4.1 Fuzzy identity

The requirements to pass this challenge are to call the authenticate() function with an address that: 1. Has a name() function which returns bytes32("smarx"), thus that address needs to be a smart contract. 2. Has badc0de somewhere in the address.

Smart contract addresses are deterministic and depend on the deployer's address and nonce (number of txs sent from the deployer). There are 2 ways to tackle this, both by bruteforce:

I chose to go with the latter, as it's easier to create a new address rather than change the nonce of an existing one.

4.2 Public Key

The account has sent a transaction so we know that we can recover the public key easily, because we know the message and the signature. There is a tool here that lets us recover it easily when providing the raw transaction hex. Otherwise, Christoph Michel has written a more detailed explaination in one blog post. I initially struggled because I forgot to remove the first byte (04) from the uncompressed public key.

4.3 Account Takeover

I was first surprised by the shortness of the victim contract. Then I noticed that it was the second biggest challenge in terms of points. After having put my knowledge in question, I concluded that there was nothing wrong in the contract itself.

One thing that reinforced the idea that the contract didn't contain any error, and that I had to look elsewhere was the fact that the address was hardcoded, and not different for each player. The problem had to do with that particular address; maybe it was a vulnerable contract, maybe the private key used wasn't properly generated or leaked somewhere, maybe I had to look on other chains.

I then decided to look at the earliest transactions made on the ropsten network. At first, nothing was wrong but at one point, in the weeds I found an oddity: these two transactions (tx1, tx2) have the same value for r, which is supposed to be different for every tx, as it results from an elliptic curve signature. This was clearly wrong and reminded me of the 2010 hack of the Sony Playstation 3. The private key of the owner address can therefore be retrieved and used to call the authenticate() function.

5.Miscellaneous

5.1 Assume ownership

This is straightforward; call AssumeOwnershipChallenge() to set the owner and then authenticate(). I believe that this was put in a challenge as the function has the same name as the contract and thus acts as a constructor upon deployment.

5.2 Token Bank

I was waiting for a reentrancy attack and we finally got one ! As samczsun put it well in his Gitcoin talk, the first thing to look for are external calls. Here, the bank makes an external call to the token contract in the requirement, before updating the value.


require(token.transfer(msg.sender, amount));
      

that function calls the more general transfer(address to, uint256 value, bytes data) function, with empty bytes for the data parameter. Then, another external call is made that checks whether the recipient (which is the caller of the bank's withdraw function) is a contract :


if (isContract(to)) {
ITokenReceiver(to).tokenFallback(msg.sender, value, data);
}
      

the vulnerability here is that it just assumes that the contract will be the bank's one and not another one. We can then create a malicious function called tokenFallback that will be called by the token contract.

To satisfy the requirements, we need to have tokens in the contract but it's not possible to send them once the contract is deployed because of that same isContract(to) check. So, we'll compute the address of the contract beforehand, send the tokens, and only after we sent them, deploy the contract.

The contract essentially does 3 things:

  1. Put the tokens it has received in the bank (IToken(token).transfer(bank,balance))
  2. Initiate a withdrawal, which will call the token transfer function that itself will call our tokenFallback function
  3. When the tokenFallback function of our contract is called, we initiate a second withdrawal but the first one hasn't finished so our balance hasn't been decreased. This lets us transfer tokens to us once more, effectively emptying the Bank.

To prevent an infinite loop, we add a hasBeenCalled bool, which only lets our tokenFallback be called once. I made a mistake for my first try and set the bool to update after the withdraw call, making it useless.

Conclusion

I very much enjoyed this challenge and must thank @smarx for creating it. I learned a lot by doing it and I'm quite proud of the progress I've made since I began learning Solidity (approximately at the time of my last post). The more I dig into the Ethereum ecosystem, the more I'm eager to learn its most complex inner workings.