In this post, I will share my explanations for the Math section of the Capture The Ether challenges. There are plenty of solutions around the web - my goal was to solve the challenges locally, avoiding Etherscan when possible, and writing code locally.
You can find the code here.
Let’s begin.
Looking at the contract, it may seem like everything is correct. However, if you look closely, you'll see that this contract does not implement SafeMath.
Remember, SafeMath is implemented on the language level since solidity version 0.8. This challenge uses an older version, so there's the possibility of overflows. This is how we game the contract.
If you look at the buy function, require(msg.value == numTokens * PRICE_PER_TOKEN)
has the potential to overflow. Overflowing here would allow us to get a gigantic amount of tokens for a low price. We then sell 1 token for 1 ether, making a profit on the way and leaving the contract with a balance < 1, which is the required condition for us to solve the challenge.
Once again, the code is explained. Still, here are the steps needed:
Get the contract abi and address;
Get the private key of the ropsten account you are using to interact with Capture The Ether. Otherwise, you can't pass the challenge as CTE doesn't know who you are;
Get the contract and connect with it using your account;
Determine the amount of tokens to send to cause an overflow;
Determine the amount of ether to send;
Buy our tokens and wait for them to arrive;
Sell 1 token;
Profit;
To keep this description short, steps 4 and 5 are detailed in the tokenSale.js
script. Take a look.
Looking at this contract, you'll hopefully notice that, once again, it is subject to over and underflows. However, this is part of the solution. Not all of it.
If you look at the _transfer
function, it spends msg.sender
tokens instead of tokens from the from
address passed to the transferFrom
function. This means that, if we successfully call transferFrom
from an account with 0 tokens, balanceOf[msg.sender] -= value
will underflow, causing the said account to receive a gigantic amount of tokens - 2**256 - 1 to be exact.
After that, all we need is to send 1000000 tokens from the helper account to our account, and we'll have successfully solved the challenge.
You may be thinking "Why do I need a second account? Couldn't I do this just using my account?". You couldn't. You'd never pass the require(balanceOf[from] >= value)
check, as you'd need value
to be 1001 to cause an underflow.
Once again, the code is explained. Still, here are the steps needed:
Get the contract abi and address;
Get the private key of the ropsten account you are using to interact with Capture The Ether. Otherwise, you can't pass the challenge as CTE doesn't know who you are;
Get a second ropsten account;
Approve the second account to spend funds from your account. Sign this transaction using your main account;
Transfer 1 token from your main account, to that same account. Sign this transaction using the second account. This will cause the underflow and give the second account an enormous amount of tokens;
Send the required 1000000 tokens from the second account back to your main account. Sign this transaction using the second account.
Wait for them to arrive, and you'll have solved the challenge.
Looking at this contract, you'll hopefully notice that, once again, it is subject to over and underflows. However, this is part of the solution. Not all of it.
If you look at the contract, you'll see that withdraw
checks that msg.sender == owner
, since the owner
is the CTE factory, this function is of no use to us. Meaning, that our solution can only make use of the collectPenalty
function.
As we've established, the contract is subject to over and underflows, meaning that uint256 withdrawn = startBalance - address(this).balance;
can be underflowed, allowing us to drain all the ETH the contract has.
To achieve this, address(this).balance
has to be > 1, which in turn means that we'll have to find a way to add some ether to the contract.
The contract has no payable functions, so how can we send ether to the contract? If you look through the Ethereum documentation, you'll hopefully realize that the easiest way to do this is to self-destruct another contract that has some ether in it, and send that contract ETH to the CTE challenge contract.
Once again, the code is explained. Still, here are the steps needed:
Get the contract abi and address;
Get the private key of the ropsten account you are using to interact with Capture The Ether. Otherwise, you can't pass the challenge as CTE doesn't know who you are;
Get the contract and connect with it using your account;
Deploy our helper contract. Send 1 eth as the msg.value. Don't forget to add the challenge address to the contract kill function;
Destroy our helper contract;
Call the challenge contract collectPenalty
function;
Wait for challenge contract to be drained and you'll have solved the challenge
To solve this challenge, we need to somehow set isComplete
to true, or 1.
First, you should read the Ethereum docs to understand how contract storage works. You'll hopefully reach the conclusion that isComplete
is at slot 0
. Then, slot 1
has the map[]
length.
From the documentation, we can also gather that: keccak256(1)
has the map[0]
value, keccak256(1) + 1
has the map[1]
value and so forth. This is because the contract array is a dynamic size array, so the EVM reserves one slot to store the array length.
From this, we can expect that if we set the map length to 2**256 - 1, the slot that contains the isComplete
value will be occupied by the array, allowing us to modify it if we know the corresponding storage address.
Since we know that map[0]
is at keccak256(1)
, we also know that map[isComplete] = 2**256 - keccack256(1)
.
Once again, the code is explained. Still, here are the steps needed:
Get the contract abi and address;
Get the private key of the ropsten account you are using to interact with Capture The Ether. Otherwise, you can't pass the challenge as CTE doesn't know who you are;
Get the contract and connect with it using your account;
Expand the array bounds to occupy the isComplete
slot;
Determine the storage address of the isComplete
value;
Change it to 1 (true);
Looking at the contract, you'll hopefully notice two things almost immediately: the donate
function calculates scale
wrong, as it results in 10**36
since 1 ether == 10**18
already, and that we'll need to somehow call withdraw
to drain the contract.
Taking a deeper look, the withdraw
function requires us to be the contract owner
, so we know what our goal is: become the contract owner.
If you read the storage layout docs in the previous challenge you'll remember that struct and array data use their own slots. Since the contract struct is only initialized when the donate
function is called, we can assume that slot 0 has the Donation[]
size and that slot 1 has the owner
address. So, we must write to slot 1 of the contract storage, but how?
This is where the donate
function is wrong again. It declares Donation donation
without using either the memory
or storage
keywords, which means that this is just an uninitialized pointer to the contract storage. Since the struct has to uint256
values, etherAmount
will write to slot 1, where the owner
address is stored! All we need to do is determine the uint256
value of our address and send that as the etherAmount
, with the needed msg.value
to pass the require
check.
Once again, the code is explained. Still, here are the steps needed:
Get the contract abi and address;
Get the private key of the ropsten account you are using to interact with Capture The Ether. Otherwise, you can't pass the challenge as CTE doesn't know who you are;
Get the contract and connect with it using your account;
Determine the uint256
value of our address;
Calculate the needed msg.value
;
Make the donation;
Withdraw the eth;
This is the challenge that awards the most points for a reason. It requires an orderly combination of transactions, with the intent to exploit the contract using the techniques we've used in the previous challenges.
First, let's try to determine our goal. Looking at the withdra
w function, particularly the second require
check, we can see that we need to send an index
for a contribution that has an unlockTimestamp
in the past and corresponds to the last contribution made, so we drain all the contributions. This is our goal. Now, how do we do this?
It should be clear that the upsert
function is key. If we look through its code, we can identify two potential issues: require(timestamp >= queue[queue.length - 1].unlockTimestamp + 1 days)
is subject to overflows, and the else
statement relies on a previous declaration of contribution
, which makes it an uninitialized storage pointer, which allows us to access storage slots 0 (queue.length
) and 1 (head
) (remember this from the previous challenge?). Knowing this, we can determine two steps needed:
Call upsert
once with a new contribution designed to prepare an overflow of the timestamp
when we make the second contribution, allowing the second contribution to have timestamp = 0
. Since the contribution.unlockTimestamp = timestamp
writes to the head
storage slot, after this first contribution head
will have a gigantic value.
Make another upsert
call that resets head
to 0, while also having an unlockTimestamp == 0
, which will work, because we've prepared the overflow in the first upsert
call.
This will total, 3 contributions (adding to the one that is made when we begin the challenge on CTE). There are three things we need to pay attention to while executing the two contributions:
Time units are parsed to seconds, so we need to prepare the overflow taking that into account;
Ether units are handled in wei
, so if we send ETH to the contract the actual queue.length
will be x ETH * 10**18
. So we need to send wei
, not ether as the msg.value
;
queue.push
increments the queue.length
before actually inserting the contribution. We've determined that the queue.length
will be manipulated by the line contribution.amount = msg.value
. This means that if we want to keep an accurate queue.length
value, we need to be wary of the msg.value
we send with each upsert
call. We know that before our first upsert
call the queue.length
is 1, because one contribution was made when we began the challenge. However, we need to send 1 wei
in the first contribution because, even though the line contribution.amount = msg.value
will maintain the queue.length
at 1 (when it's actually 2 since this is the second contribution made), the line queue.push(contribution)
will increment it by 1, which will give us the correct queue.length
of 2. We follow the same logic for the second upsert
call and send 2 wei
, since 2 + 1 = 3, which by then will be the correct queue.length
.
At first glance, we may assume that, after these steps, calling withdraw(2)
would drain the contract. However, this transaction would fail. Since queue.push
increments the queue.length
before actually pushing the contribution, contribution.amount = msg.value
will be incremented too. Visualize it this way:
- Contribution 0 (made by CTE): contribution.amount == msg.value == 1 ETH;
- Contribution 1 (us): contribution.amount == msg.value == 1 wei + `queue.push` == 2 wei;
- Contribution 2 (us): contribution.amount == msg.value == 2 wei + `queue.push` == 3 wei;
- Contract total == 1.00...03 ETH, Contributions total == 1.00...05 ETH.
Hence, the transaction will fail until we add 2 wei
to the contract, because we're trying to withdraw more ETH than the contract has. This can be done by taking a page out of the Retirement Fund challenge. Just create a contract that receives 2 wei
on deploy, and then self-destruct said contract, sending the 2 wei
to our challenge contract. After that, call withdraw(2)
and we win!
Once again, the code is explained. Still, here are the steps needed:
Get the contract abi and address;
Get the private key of the ropsten account you are using to interact with Capture The Ether. Otherwise, you can't pass the challenge as CTE doesn't know who you are;
Get the contract and connect with it using your account;
Deploy our FiftyYearsHelper contract and fund it with 2 wei
;
Call upsert
a first time to prepare the timestamp
overflow;
Call upsert
a second time to set head
to 0;
Kill our FiftyYearsHelper contract;
Call withdraw(2)
;