The following article is part two of our Capture the Ether series. In it, we take a look at the math vulnerabilities that commonly plague Ethereum smart contracts. If you missed part one, don’t fret. You can check it out here.
For now, let’s get started on part two.
WARNING: The remainder of this article contains solutions to numerous Capture the Ether challenges. If you’re planning on completing Capture the Ether yourself, you may want to do so before finishing this article.
Part 2: Exploiting Math Vulnerabilities in Smart Contracts
Whether its during token sales, through wallet logic, or in general funds, the opportunity for math-based vulnerabilities in Ethereum smart contracts is prevalent. Part two brings us six challenges to tackle, each with the goal of capitalizing on some type of math mistake. Follow along and see if you can get the same results.
Challenge 1: Token Sale
Our first challenge requires that we steal some ether from a token sale contract. The contract has a starting balance of one ether, and it allows us to trade tokens at an exchange rate of one ether per token.
As always, we begin by looking through the contract code to get a better understanding of how it operates and see if we have any attack vectors.
There are two components we can exploit here to obtain the contract’s ether – the PRICE_PER_TOKEN constant and the buy() function. Looking at the former:
We see that PRICE_PER_TOKEN is a uint256 constant that equals one ether.
According to the Solidity documentation, ether units are actually represented as a multiple of wei. So, our one ether constant actually equals 10**18, or 1000000000000000000, wei.
Looking at our buy() function, notice that the contract multiplies the amount we pass in (numTokens) by 10**18 (PRICE_PER_TOKEN).
The result, msg.value, is also a uint256 variable, giving it the potential to overflow. If we input a large enough value for numTokens, we can overflow the msg.value, enabling us to buy a token for less than the one ether price.
A uint256 variable has a maximum value of 2**256 – 1. So to make msg.value overflow, we need to ensure that numTokens multiplied by 10**18 is greater than 2**256 – 1. To get that value, we divide 2**256-1 by 10**18, giving us: 115792089237316195423570985008687907853269984665640564039457.584007…
Rounding that figure up to the nearest integer (115792089237316195423570985008687907853269984665640564039458) pushes us into overflow territory.
We now know what value we need to input into the buy() function, but how much ether should we send along with it?
We need to figure out the amount that msg.value overflows so we can send the correct amount of ether with the call. To calculate the amount of it overflows, subtract 2**256 – 1 from the incredibly large number we just calculated. Doing so leaves us with 415992086870360064 wei, the amount of wei we need to send when we call buy().
To finish off this challenge, initiate a buy() call for 115792089237316195423570985008687907853269984665640564039458 tokens and include 415992086870360064 wei with the transaction. Then, simply sell one of the tokens you receive using the sell() function to receive one ether in return.
In the end, you’ve spent ~0.416 ether to receive one in return, a profit of ~0.584 ether. Not too shabby.
Challenge 2: Token Whale
In our second math challenge, we’re the proud owner of 1000 SET tokens, which encompass the total fixed supply of SET. We need to, somehow, acquire at least 1,000,000 SET to pass the challenge. Let’s take a look at the code.
This contract is significantly longer than the ones we’ve analyzed in previous challenges. However, it contains two exploitable functions that you should focus on.
The first, approve(), effectively allows a token holder to set a spending allowance for another address. So, even though we hold all of the SET tokens in existence, we can allow our spouse, for example, to spend, say, 100 SET tokens from our wallet address.
The second notable function, _transfer(), is an internal function which updates address balances whenever someone makes a transfer. Notice anything peculiar here?
The _transfer() function deducts the transfer amount from the address of whomever initiates the transfer (msg.sender). It doesn’t matter whether the initiator is the wallet owner or an approved spender.
Continuing our example: If our spouse attempts a transfer, the _transfer() function incorrectly attempts to withdraw tokens from her wallet instead of the wallet for which she got approval (i.e., our wallet). It effectively tries to withdraw funds from a wallet that likely has no balance.
Next, check out the address balance variables. They’re unsigned integers, meaning they can’t be negative. Therefore, if the approved wallet (i.e., our spouse) has a balance of 0 when it initiates a transfer on behalf of the wallet that gave it approval, the contract will run the approved wallet balance into an underflow state.
To explain further, because balances can’t go below 0, when the contract attempts to deduct the transfer amount from the empty approved wallet, the balance underflows, filling the approved wallet with a bunch of tokens.
To take advantage of this exploit, we first need to call the approve() function, giving a second wallet (Wallet B) a token allowance. The amount of this allowance ultimately doesn’t matter; just make sure it’s a positive integer well below the uint256 upper limit of 2**256 – 1.
Now, switch to Wallet B. Initiate a transaction from Wallet B to a random third wallet (Wallet C). Once again, the amount of this transaction is arbitrary; it only needs to be less than the amount you approved Wallet B to spend.
Because Wallet B is empty, the balance will underflow, giving it a massive number of tokens. To finish off the challenge, send 1,000,000 of those tokens back to Wallet A.
Challenge 3: Retirement Fund
The third challenge requires that we drain a retirement fund. The retirement fund contains one ether which is locked up for ten years. If the owner withdraws any ether before ten years has elapsed, we receive ten percent of the fund as a penalty. By this point, you should know our first step – code analysis.
Let’s take a closer look at the collectPenalty() function:
The function first checks that the beneficiary (us) is the person initiating the call. Then, it sets the withdrawn variable to the startBalance variable (one ether) minus the current balance of the retirement fund (address(this).balance).
Next, it checks that no money has been withdrawn to determine if the owner has withdrawn early (require(withdrawn > 0)). If the owner has withdrawn any amount, the current balance would be less than the starting balance, making withdrawn greater than zero.
An early withdrawal allows the benefactor (us) to withdraw the remaining balance. We see this action in the final line of the function – msg.sender.transfer(address(this).balance);.
Remember how we exploited the attributes of an unsigned integer in the second challenge by forcing it to underflow? While, we’re doing something similar here as well.
Notice that withdrawn is a uint256 variable, or in order words, an unsigned integer. Once again, as an unsigned integer, withdrawn cannot be negative. If we could somehow increase the balance in the retirement fund, startBalance minus address(this).balance would equal a negative.
However, when setting withdrawn to that figure, the contract would cause it to underflow, leaving us with a large positive amount instead. But without any other payable functions, how can we increase the balance?
Similar to numerous solutions from part one, we’re going to implement an exploit contract.
Our exploit contract, CashOut, is relatively straightforward. When we deploy it, it requires that we send one ether along with it.
Following the deployment, we can call the destroy() function, setting the address of the retirement fund as the input address. The destroy() function implements Solidity’s selfdestruct() function. Let’s take a closer look at how it works.
When we call destroy(), our contract removes itself from the blockchain and forces the one ether balance to the retirement fund contract.
Now, the retirement fund balance equals two ether. Calculating withdrawn, we get:
withdrawn = startBalance – address.balance
withdrawn = 1 ether – 2 ether
withdrawn = -1 ether
Because withdrawn is an unsigned integer, though, it can’t be negative. So, it underflows, leaving us with a positive balance. Now that we’re confident that withdrawn is positive, we can call collectPenalty() and receive our ether.
Challenge 4: Mapping
In challenge four, our goal is to manipulate the isComplete boolean in the following contract to become ‘true’ (i.e., 1).
To accomplish this task, we need to take advantage of the intricacies of mappings, dynamic arrays, and memory placement in the stack.
Right away, we see that isComplete is right next to the map uint256 dynamic array in the stack. Additionally, through the set() function, we can change the values of the map array.
So, by butting up against, and overflowing, the memory location of isComplete through the map array, we can overwrite the value of isComplete to be 1. Here’s how we do it:
To begin, call the set() function to set an arbitrary value at a key (i.e., the index) of 2**256 – 2.
There are a couple things to understand about this step.
First, the value is arbitrary because we only need to create a location at that particular index in memory; it doesn’t matter what’s at that location.
Secondly, we’re placing our value at 2**256 – 2 rather than 2**256 – 1 because the set() function adds 1 to the key when updating the length of the array (map.length). Adding 1 to our key input of 2**256 – 2 gets us to our overflow value of 2**256 – 1.
Now, we need to hop over to the transaction history of the MappingChallenge contract. There, we can see the state changes of the contract and figure out the location of our array’s memory pointer by adding 2 to the storage location of the isComplete boolean.
Doing so gives us the hex value 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6. (Note: Your value will likely be different than ours.)
To figure out the array index of the isComplete boolean, we simply subtract 2**256 from that hex value. You don’t want to do this manually. Instead, utilize python to perform the calculation:
To complete the challenge, call the set() function with the key as the number you calculated and the value as 1. In this case, our key is 0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a. Doing so sets isComplete to 1, completing the challenge.
Challenge 5: Donation
Challenge five has us stealing campaign contributions from a candidate via his or her donation smart contract. Whether we’re acting as a force for good or evil is up to you. Here’s the contract:
Right away, we see that the contract declares two global variables – the donations array and the address of the owner (the candidate).
The donations array is improperly declared, creating a pointer to memory in the contract rather than temporary memory. Doing so gives us an attack vector from which we can overwrite the owner address variable by sending a crafted donation. Normally, this action would be fairly challenging; however, there’s another vulnerability of which we can take advantage.
The donate() function improperly calculates the scale variable when converting ether donations to wei. Remember from challenge one that one ether equals 10**18, so in this function:
scale = 10**18 * 1 ether
scale = 10**18 * 10**18
scale = 10**36
As you can see, the donate() function divides the ether donations by 10**36 when it should be dividing by 10**18.
To take advantage of this flaw, divide your wallet address by 10**36. Then, donate that value in wei while setting your wallet address as the etherAmount value. The contract should accept the donation and overwrite the owner address with your own in decimal form.
With control of the contract, simply call withdraw() to drain the donation contract and complete the challenge.
Challenge 6: Fifty Years
The sixth, and final, challenge of Capture the Ether Part Two is by far the most involved. In this challenge, we’re given a contract the locks away ether. The initial ether in the contract is locked up for 50 years, and subsequent ether that we add is locked away for even longer. Our goal is to unlock the ether (without waiting 50 years, hopefully). Let’s take a look at the code.
When we deploy the contract, we need to send along one ether. The contract then takes that ether and creates an initial Contribution, setting the amount to one ether (msg.value) and unlockTimestamp to 50 years from now. It then adds the Contribution to the end of an array of Contributions, called queue. Because this Contribution is the first one, it’s the only one in the array. You can see this process below:
This contract includes a fair amount of additional code. However, there are only two functions we need to worry about – upsert() and withdraw().
The upsert() function works as follows:
We call upsert(), inputting an index and a timestamp and sending along some ether.
The contract either
Adds the ether to the Contribution amount at an existing index of queue (if the index we inputted already holds a Contribution), or
Pushes a new Contribution to the end of the queue array, setting the ether we send as the amount and the timestamp we input as the unlockTimestamp.
In the first scenario, the contract ignores the timestamp that we input.
In the second scenario, it checks that the timestamp we input is at least one day greater than the unlockTimestamp of the previous Contribution in the queue array. Basically, the lock up periods get longer with each new Contribution we add.
The unlockTimestamp attribute is a uint256 variable, so we should be able to overflow it and reset it to zero. To do so, though, we need to take advantage of another vulnerability.
The queue dynamic array isn’t initialized. So, when we reference it in upsert(), the contribution.amount (msg.value) overwrites slot 0 of the array when the contract calls queue.push(contribution). Slot 0 represents the length of the array, so we’re effectively overwriting the array length value each time we add a Contribution to the queue.
By sending specific amounts of wei, we can overwrite the length variable and control what the smart contract’s logic reads as the array length.
Remember that the overflow point of a uint256 variable is 2**256 – 1. So, unlockTimestamp will overflow at 2**256 – 1 day, or 2**256 – 86400 seconds.
To kick off the process of exploiting this contact, call upsert() with an index of 1, timestamp of 2**256 – 86400, and send along 1 wei with the call.
Next, call upsert() again but with an index of 2, timestamp of 0, and 2 wei.
Then, upsert() with an index of 3, timestamp of 86400, and 3 wei.
Subsequently, upsert() with an index of 4, timestamp of 2**256-86400, and 4 wei.
And finally, call upsert() with an index of 5, timestamp of 0, and 5 wei.
At this point, the contract holds the original one ether plus the 15 wei we added. Now, we can call the withdraw() function on index 3 to receive an ether and 9 wei.
This amount is a fine return, but we’re looking to drain the whole contract. Let’s grab the last 6 wei.
To do so:
Call upsert() with an index of 0, timestamp of 2**256-86400, and 0 wei.
Again, call upsert() with an index of 1, timestamp of 0, and 0 wei.
Call withdraw() on index 0.
Repeat steps 1-3 until the contract is empty. It should take you six iterations.
On to the Next Challenges
At this point, you should have a better understanding of the potential math-based vulnerabilities in Ethereum smart contracts and the difficulty of avoiding them. In the final part of our Capture the Ether series, we’re going to dig into common account errors as well as a couple other miscellaneous vulnerabilities. Stay tuned!