Solving Ethernaut - Part 4 - Levels 16 to 20

Overview

Fourth part of the solutions to Ethernaut. This time the challenges get harder and more interesting.

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 16: Preservation


A contract that relies on two libraries to keep track of time in two different timezones. The goal is to become the owner of the contract.

Contract analysis

The Preservation contract implements two functions to set the time for each one of the libraries. setFirstTime(uint) and setSecondTime(uint) are almost identical, they make a delegatecall to each library that calls the setTime(uint256) function of the target contract.

There are four storage slots used:

  • Slot 0: address timeZone1Library
  • Slot 1: address timeZone2Library
  • Slot 2: address owner
  • Slot 3: uint storedTime
 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4contract Preservation {
 5
 6  // public library contracts 
 7  address public timeZone1Library;
 8  address public timeZone2Library;
 9  address public owner; 
10  uint storedTime;
11  // Sets the function signature for delegatecall
12  bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
13
14  constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) public {
15    timeZone1Library = _timeZone1LibraryAddress; 
16    timeZone2Library = _timeZone2LibraryAddress; 
17    owner = msg.sender;
18  }
19 
20  // set the time for timezone 1
21  function setFirstTime(uint _timeStamp) public {
22    timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
23  }
24
25  // set the time for timezone 2
26  function setSecondTime(uint _timeStamp) public {
27    timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
28  }
29}
30
31// Simple library contract to set the time
32contract LibraryContract {
33
34  // stores a timestamp 
35  uint storedTime;  
36
37  function setTime(uint _time) public {
38    storedTime = _time;
39  }
40}

The LibraryContract implements the setTime(uint) function that will be called by the setXxxTime(uint) functions of the Preservation contract.

Vulnerability / Attack vector

The use of delegatecall on an untrusted address can have terrible consequences. Remember that delegatecall executes code of another contract in the current contract context. Level 06 - Delegation was a simpler challenge with a similar exploit.

Solution

Since the libraries are delegated, the context is preserved and so they can modify the caller's storage. The goal is to modify the owner variable in storage slot 2, and set it to the player's address.

The solution comes in three stages:

  • Create a contract that implements a setTime(uint) function, with the code needed to modify storage slot 2.
  • Modify timeZone1Library address by calling setSecondTime(uint), and set it to the contract deployed in the previous stage
  • Call setFirstTime(uint) to overwrite the owner variable

For the first step, the attacker contract used was the following:

 1contract PreservationAttack {
 2
 3  uint storedTime;  
 4  uint not_important;
 5  address owner;
 6
 7  function setTime(uint _time) public {
 8    storedTime = _time;
 9    owner = msg.sender;
10  }
11}

It creates enough dummy variables to be able to overwrite storage slot 2. As a bonus, it implements the expected behavior of the library.

Next step, call setSecondTime(uint) with the address of the deployed attacker as argument. This will effectively overwrite storage slot 0 of the Preservation instance, and allow the player to call the attacker in the next stage.

Finally, call setFirstTime(uint). The argument is not really important, but whatever is sent will be set as the address timeZone1Library.

Conclusions

This level was a review of the delegated calls, caller contexts and storage. An interesting fact of the level is the way that the delegatecall is made, by crafting the function signature in the same contract (specifically in line 12 of the Preservation contract listing).


Level 17: Recovery


A simple token factory. The problem is that the deployed contract address was lost, so apparently there is no way to recover the lost half ether. The goal is to recover the 0.5 ethers, or remove them from the contract somehow.

Contract analysis

The interesting part of the contract, and the one that is needed to solve the level is the destroy(address) function. It calls selfdestruct with the supplied argument, so it can transfer the contract balance to a user-supplied address.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4import '@openzeppelin/contracts/math/SafeMath.sol';
 5
 6contract Recovery {
 7
 8  //generate tokens
 9  function generateToken(string memory _name, uint256 _initialSupply) public {
10    new SimpleToken(_name, msg.sender, _initialSupply);
11  
12  }
13}
14
15contract SimpleToken {
16
17  using SafeMath for uint256;
18  // public variables
19  string public name;
20  mapping (address => uint) public balances;
21
22  // constructor
23  constructor(string memory _name, address _creator, uint256 _initialSupply) public {
24    name = _name;
25    balances[_creator] = _initialSupply;
26  }
27
28  // collect ether in return for tokens
29  receive() external payable {
30    balances[msg.sender] = msg.value.mul(10);
31  }
32
33  // allow transfers of tokens
34  function transfer(address _to, uint _amount) public { 
35    require(balances[msg.sender] >= _amount);
36    balances[msg.sender] = balances[msg.sender].sub(_amount);
37    balances[_to] = _amount;
38  }
39
40  // clean up after ourselves
41  function destroy(address payable _to) public {
42    selfdestruct(_to);
43  }
44}

Vulnerability / Attack vector

Not a vulnerability. The goal is to find the contract address, that is hidden somewhere in the blockchain. "Hidden".

Solution

The contract deployment receipt shows two interactions with other contracts, as shown in the image below:

Deployment receipt

The underlined address has the 0.5 ethers, so calling its destroy(address) function with the player's address as argument should get the funds back and solve the level.

Conclusions

Another proof that there are no secrets on the blockchain. Everything is visible.


Level 18: MagicNumber


This level implements a new form of challenge. You should deploy a contract whose whatIsTheMeaningOfLife() function returns 0x42, but with the caveat that the deployed code size must be less than or equal to 10 bytes. Welcome to low level programming, enjoy your ride.

Contract analysis

Nothing special, the setSolver(address) should be called to tell the contract where the solver is, and the Ethernaut logic will check if the conditions are met.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4contract MagicNum {
 5
 6  address public solver;
 7
 8  constructor() public {}
 9
10  function setSolver(address _solver) public {
11    solver = _solver;
12  }
13
14  /*
15    ____________/\\\_______/\\\\\\\\\_____        
16     __________/\\\\\_____/\\\///////\\\___       
17      ________/\\\/\\\____\///______\//\\\__      
18       ______/\\\/\/\\\______________/\\\/___     
19        ____/\\\/__\/\\\___________/\\\//_____    
20         __/\\\\\\\\\\\\\\\\_____/\\\//________   
21          _\///////////\\\//____/\\\/___________  
22           ___________\/\\\_____/\\\\\\\\\\\\\\\_ 
23            ___________\///_____\///////////////__
24  */
25}

Vulnerability / Attack vector

None. Not a vulnerability, just another concept-teaching level.

Solution

This will be a rather long explanation, because it involves some interesting concepts and learning paths.

The first approach was to deploy a contract that implements the required logic, but obviously, without the size limitation. The goal is to create a function that returns 42 (or 0x2A in hex) in Remix, and iterate from there. The shortest contract I came up with was the following:

 1pragma solidity ^0.8.0;
 2
 3contract Test {
 4    fallback() external payable {
 5        assembly {
 6            mstore(0x80, 0x2A)
 7            return(0x80, 0x20)
 8        }
 9    }
10}

This code is the equivalent of a return 0x2A; statement in Solidity. According to Yul documentation the return(p, s) instruction finishes the execution of the current function and returns to the caller the data values located in memory positions p to p+s. This requires the return value to be stored somewhere in memory, and that's where the mstore(p, v) instruction comes in, as it stores the value v to memory positions p to p+32 (or p+0x20).

The Test contract stores the uint256 value 0x2A to memory positions 0x80 to 0x100 (that is, 32 bytes), and then returns to caller the 0x20 bytes starting at 0x80, exactly what was stored in the previous instruction. This is as close as I could get to EVM assembly from Remix, however this still needs some tweaking.

Instead of implementing the whatIsTheMeaningOfLife() function, I coded the solution in fallback(). This allows the compiler to optimize out the function signature hash comparison, and react to any function called. The interesting part of the compiler output for this Test contract is shown below:

 1.data
 2  0:
 3    .code
 4      PUSH 80
 5      PUSH 40
 6      MSTORE 
 7      PUSH 2A
 8      PUSH 80
 9      MSTORE 	
10      PUSH 20	
11      PUSH 80
12      RETURN 	
13    .data

Lines 7 to 12 are the ones that solve the challenge. However, the PUSH instruction can be compiled to a family of instructions depending on the length (in bytes) of the argument. In this case, we need the arguments to be of 1 byte, so the corresponding instruction would be PUSH1. This is done to minimize the contract code length (otherwise we would need a longer constant for the memory address and value).

Looking up the instructions at Ethereum Virtual Machine opcodes, the ones needed for this challenge are:

Assembly instruction EtherVM opcode
PUSH1 xx 0x60 xx
MSTORE 0x52
RETURN 0xF3
CODECOPY 0x39

So, the code for the solver contract should be:

1PUSH1 2A
2PUSH1 80
3MSTORE 	
4PUSH1 20	
5PUSH1 80
6RETURN 	

And its opcodes:

160 2A
260 80
352
460 20
560 80
6F3

Great, now the contract fits in the required 10 bytes. However, this is impossible to deploy as-is.

The code written so far is the runtime code of the contract. This doesn't include the code for a constructor (if there was one) or the creation code for the EVM. This post by monokh talks about the different bytecodes and explains the purpose and workings of the initialization code, an alias for the creation code. More detailed information can be found at this medium

The goal of this initialization code is to return the contract code. Pretty much like the function coded above returns 42 as an integer, this code should return the runtime code of the to-be-constructed contract. The good part is that, since this initialization code will not be part of the deployed contract, we don't have a 10-byte size limit.

The first step is to copy the runtime code, that comes as calldata on the deployment transaction, to memory. The CODECOPY instruction does exactly this, given 3 arguments in the stack:

  • destOffset, the memory address where the code will be copied
  • offset, the calldata position where the code to be copied starts
  • length, how many bytes of code are going to be copied

Now that the runtime code is in memory, it has to be returned. This is the same code used above to return the constant 42. The RETURN instruction with offset and length as parameters. These values will be the same as the previous CALLCODE instruction arguments.

Let's mock the initialization code:

1PUSH <runtime code length>
2PUSH <runtime code start>
3PUSH <memory destination>
4CODECOPY
5PUSH <runtime code length>
6PUSH <memory destination>
7RETURN

The <runtime code length> is 10 (0x0A), as stated above. <memory destination> can be an arbitrary value, since there's no restriction on location, so I chose 0x00. <runtime code start> must be calculated as the position where the runtime code starts in the calldata sent with the transaction. Since the runtime code will come after the initialization code, <runtime code start> will be the length of the initialization code, 12 or 0x0C (positions start at 0).

Replacing all the values and concatenating both codes, the complete solution for this level is then:

 1PUSH1 0A
 2PUSH1 0C
 3PUSH1 00
 4CODECOPY
 5PUSH1 0A
 6PUSH1 00
 7RETURN
 8
 9PUSH1 2A
10PUSH1 80
11MSTORE 	
12PUSH1 20	
13PUSH1 80
14RETURN 

And so the whole bytecode is: 0x600A600C600039600A6000F3602A60805260206080F3.

To deploy it, I used a variation of the Deployer contract from CTE's Fuzzy identity challenge:

 1pragma solidity ^0.8.0;
 2
 3contract Deployer {
 4    
 5    function deployContract(bytes32 salt) public returns(address) {
 6        uint176 bytecode = 0x600A600C600039600A6000F3602A60805260206080F3;
 7        bytes memory code = abi.encodePacked(bytecode);
 8        address addr;
 9
10        assembly {
11          addr := create2(0, add(code, 0x20), mload(code), salt)
12        }
13
14        return(addr);
15    }
16
17    function testDeployed(address solver) public returns (uint256) {
18        (, bytes memory result) = solver.call("");
19
20        // This should return 42.
21        return uint256(bytes32(result));
22    }
23
24}

The deployContract(bytes32) deploys the solver and returns the address. The testDeployed(address) calls the fallback function of solver to check its correct return value.

Conclusions

An amazing challenge whose goal is for the user to know the details of contract creation process, EVM assembly instructions and low level calls. There are many great resources online to learn from.


Level 19: Alien Codex


An Alien contract that expects the player to become the new owner. This time with a little twist.

Contract analysis

The contract is quite simple. It implements a bytes32[] codex array that is used as data storage, and a bool contact flag that indicates if the player made contact with the contract.

Two interesting facts come to light upon inspection: the contract was meant to be compiled with Solidity 0.5, and it inherits from Ownable. The old version of the compiler can be a telltale sign of integer overflows, because it doesn't use SafeMath library. The Ownable contract is an OpenZeppelin implementation of a simple access control, defining an owner address and a modifier to allow certain functions to be executed only by the owner.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.5.0;
 3
 4import '../helpers/Ownable-05.sol';
 5
 6contract AlienCodex is Ownable {
 7
 8  bool public contact;
 9  bytes32[] public codex;
10
11  modifier contacted() {
12    assert(contact);
13    _;
14  }
15  
16  function make_contact() public {
17    contact = true;
18  }
19
20  function record(bytes32 _content) contacted public {
21  	codex.push(_content);
22  }
23
24  function retract() contacted public {
25    codex.length--;
26  }
27
28  function revise(uint i, bytes32 _content) contacted public {
29    codex[i] = _content;
30  }
31}

Vulnerability / Attack vector

Integer overflow at line 25 of AlienCodex, combined with a full storage access as consequence.

Solution

Let's check the contract storage layout:

  • Slot 0: address owner, inherited, and bool contact, as stated by Solidity storage packing rules.
  • Slot 1: codex.length
  • Slot keccak256(1): codex[0]
  • Slot keccak256(1) + 1: codex[1], and so on.

The retract() function does not check for overflow, so it can make codex.length overflow. This gives full read and write storage access to any slot, because every slot is part of the array now. So, the revise(uint, bytes32) function will allow the attacker to overwrite any storage slot with any value.

However, to execute retract(), the bool contact flag must be set to true. This is done by calling make_contact() in the first place.

The full procedure to exploit this contract is, then:

  • Call make_contact(), to set the contact flag.
  • Call retract() to overflow codex.length.
  • Call revise(uint, bytes32) to overwrite the owner address at slot 0.

The last call bytes32 argument should be the player's address converted to byte32 (that is, padded with zeros). The uint argument, on the other hand, is used as index to the codex array, so it should be calculated to overwrite storage slot 0.

Element n of the codex array is mapped to storage slot keccak256(1) + n. The 1 comes from the fact that the codex.length variable is at slot 1. So, to overwrite storage slot 0, the correct index to be accessed should be calculated to satisfy keccak256(1) + k = 0, or according to integer overflow rules, keccak256(1) + k = 2**256. Therefore, k = 2**256 - keccak256(1).

Conclusions

Overflowing an array length value is a dangerous thing to happen, since it can give full access to every storage slot of the contract. This can be particularly devastating in contracts that keep accounting of token balances, or monetary values stored.

Keep an eye out for integer overflows, either by using SafeMath library, or a Solidity version higher than 0.8.


Level 20: Denial


A wallet contract that allows the withdrawing partner and the contract owner to receive 1% of the balance everytime a function is called. The goal is to deny the owner to receive funds.

Contract analysis

This contract implements some helper functions, such as setWithdrawPartner(address) that changes the current partner, and contractBalance() that returns the current balance of the contract.

The withdraw() function implements the value transfer from the contract to the partner and the owner. The first transfer is done via a call to the fallback function, and the second via a transfer().

There's some logic after sending the amounts, but it's not relevant to the implementation.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.6.0;
 3
 4import '@openzeppelin/contracts/math/SafeMath.sol';
 5
 6contract Denial {
 7
 8    using SafeMath for uint256;
 9    address public partner; // withdrawal partner - pay the gas, split the withdraw
10    address payable public constant owner = address(0xA9E);
11    uint timeLastWithdrawn;
12    mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances
13
14    function setWithdrawPartner(address _partner) public {
15        partner = _partner;
16    }
17
18    // withdraw 1% to recipient and 1% to owner
19    function withdraw() public {
20        uint amountToSend = address(this).balance.div(100);
21        // perform a call without checking return
22        // The recipient can revert, the owner will still get their share
23        partner.call{value:amountToSend}("");
24        owner.transfer(amountToSend);
25        // keep track of last withdrawal time
26        timeLastWithdrawn = now;
27        withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);
28    }
29
30    // allow deposit of funds
31    receive() external payable {}
32
33    // convenience function
34    function contractBalance() public view returns (uint) {
35        return address(this).balance;
36    }
37}

Vulnerability / Attack vector

The contract doesn't check the return value for the external call to the partner's address. This allows the call to fail and not affect the rest of the execution, however, if this call consumes up all the gas, the transaction would fail anyway.

Solution

An external call to a function can't really use up all the gas of the transaction. According to EIP-150, the call can use at most 63/64ths of the gas available. This makes an interesting bug: if the remaining 1/64th of the gas is enough to finish executing the caller's function, it will succeed, else it will fail. This is effectively a bug that can be hard to spot.

Now, let's talk about the solution. The attacker contract below uses an assert instruction with a false argument, so when it is called, it consumes all remaining gas (well, almost, 63/64ths of the forwarded gas, as stated above).

1contract DenialAttack {
2    receive() external payable {
3        assert(1 == 2);
4    }
5}

Setting an instance of DenialAttack as the withdrawing partner will cause the transaction to fail.

Conclusions

External calls should have a fixed gas limit, enforced by the caller: partner.call{value: amountToSend, gas: 1000000}(""). This ensures the caller will have enough gas to finish its execution safely.

Posts in this Series

comments powered by Disqus