Ethernaut: Levels 19 to 21

The Ethernaut is a Web3/Solidity based wargame inspired from overthewire.org, played in the Ethereum Virtual Machine. Each level is a smart contract that needs to be 'hacked'.

goto:
Alien Codex
Shop

Level 19 - Denial

Target

This is a simple wallet that drips funds over time. You can withdraw the funds slowly by becoming a withdrawing partner.
If you can deny the owner from withdrawing funds when they call withdraw() (whilst the contract still has funds) you will win this level.

Contract

import 'openzeppelin-solidity/contracts/math/SafeMath.sol';

contract Denial {

    using SafeMath for uint256;
    address public partner; // withdrawal partner - pay the gas, split the withdraw
    address payable public constant owner = address(0xA9E);
    uint timeLastWithdrawn;
    mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances

    function setWithdrawPartner(address _partner) public {
        partner = _partner;
    }

    // withdraw 1% to recipient and 1% to owner
    function withdraw() public {
        uint amountToSend = address(this).balance.div(100);
        // perform a call without checking return
        // The recipient can revert, the owner will still get their share
        partner.call.value(amountToSend)("");
        owner.transfer(amountToSend);
        // keep track of last withdrawal time
        timeLastWithdrawn = now;
        withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);
    }

    // allow deposit of funds
    function() external payable {}

    // convenience function
    function contractBalance() public view returns (uint) {
        return address(this).balance;
    }
}

Weakness

The withdraw function uses call to send ETH to an unknown address. This poses two threats:

  1. Reentrancy (see Level 10 - Reentrancy: the recipient can implement a malicious fallback that will call back ('reenter') the withdraw function
  2. Out Of Gas (OOG) error: call forwards all gas. The recipient may consume it all to prevent the execution of the following instructions. ## Solidity Concepts: error handling
expression syntax effect OPCODE
throw if (condition) { throw; } reverts all state changes and deplete gas version<0.4.1: INVALID OPCODE - 0xfe, after: REVERT- 0xfd deprecated in version 0.4.13 and removed in version 0.5.0
assert assert(condition); reverts all state changes and depletes all gas INVALID OPCODE - 0xfe
revert if (condition) { revert(value) } reverts all state changes, allows returning a value, refunds remaining gas to caller REVERT - 0xfd
require require(condition, "comment") reverts all state changes, allows returning a value, refunds remaining gas to calle REVERT - 0xfd

So the main difference is that assert depletes all gas while revert and require don't. require is a less verbose version of revert.
When to use which error handling method? According to the solidity documentation

The assert function should only be used to test for internal errors, and to check invariants. Properly functioning code should never reach a failing assert statement; if this happens there is a bug in your contract which you should fix.
The require function should be used to ensure valid conditions that cannot be detected until execution time. This includes conditions on inputs or return values from calls to external contracts.

Hack

We want to make the owner.transfer(amountToSend); instruction fail right after the partner.call.value(amountToSend)(""); instruction. As call forwards all gas, we will cause an Out Of Gas error.

  1. Deploy a malicious contract and set it as withdraw partner with setWithdrawPartner
  2. Cause an Out Of Gas Error by implementing a malicious fallback (that receive the ETH sent by the partner.call.value(amountToSend)("") instruction)
    • Option 1: reenter in denial.withdraw()
    • Option 2: assert a false condition

Takeaways

See Level 10 - Reentrancy takeaways.

Level 20 - Alien Codex

Target: claim ownership of the contract.

Contract

pragma solidity ^0.5.0;

import 'openzeppelin-solidity/contracts/ownership/Ownable.sol';

contract AlienCodex is Ownable {

  bool public contact;
  bytes32[] public codex;

  modifier contacted() {
    assert(contact);
    _;
  }

  function make_contact() public {
    contact = true;
  }

  function record(bytes32 _content) contacted public {
    codex.push(_content);
  }

  function retract() contacted public {
    codex.length--;
  }

  function revise(uint i, bytes32 _content) contacted public {
    codex[i] = _content;
  }
}

Weakness

codex is stored as a dynamic array. retract() reduces codex length without checking against underflow. So it is actually possible to set the codex array length to 2²⁵⁶ -1, which gives power to modify all storage slots.

Solidity Concepts: storage layout of dynamically sized variables

Each smart contract running on the Ethereum Virtual Machine maintains its own state using a key:value storage mapping. The number possible of keys is so huge that most keys actually contain empty values. Each key is called a slot. They are 2²⁵⁶ - 1 slots. Each slot can contain 32 bytes of data.
In the Level 8 -Vault, I listed the basic storage layout rules. Each statically sized variable gets a reserved slot which is defined at compilation time. But what about dynamically sized variables? As their size is not fixed beforehand, how to know which slots to reserve?
With regular hard drive space or RAM an allocation step to find free space to use exists, which is followed by a release step to put that space back into the pool of available storage. The number of storage locations of a smart contract is so huge that it manages its storage differently. It just needs to figure a way to define a storage location to start from. Indeed the likelihood of having location clashes is (not rigorously) 0.

Due to their unpredictable size, mapping and dynamically-sized array types use a Keccak-256 hash computation to find the starting position of the value or the array data. These starting positions are always full stack slots.
For dynamic arrays, [the] slot stores the number of elements in the array (byte arrays and strings are an exception, see below). For mappings, the slot is unused (but it is needed so that two equal mappings after each other will use a different hash distribution). Array data is located at keccak256(p) and the value corresponding to a mapping key k is located at keccak256(k . p) where . is concatenation.

storage of dynamic array
storage of mapping

Hack

  1. Analyse storage layout

    Slot # Variable
    0 contact bool(1 bytes] & owner address (20 bytes), both fit on one slot
    1 codex.length
    keccak256(1) codex[0]
    keccak256(1) + 1 codex[1]
    2²⁵⁶ - 1 codex[2²⁵⁶ - 1 - uint(keccak256(1))]
    0 codex[2²⁵⁶ - 1 - uint(keccak256(1)) + 1] --> can write slot 0!
  2. call make_contact to be able to pass the contacted modifer

  3. call retract: this provokes and underflow which leads to code.length = 2^256 - 1

  4. Compute codex index corresponding to slot 0: 2²⁵⁶ - 1 - uint(keccak256(1)) + 1 = 2²⁵⁶ - uint(keccak256(1))

  5. Call reverse passing it index and your address left padded with 0 to total 32 bytes as content

Takeaways

Modifying a dynamic array length without checking for over/underflow is very dangerous as it can expand the array's bounds to the entire storage area of 2256 - 1. This can possibly enable modifying the whole contract storage.

Level 21 - Shop

Target: get the item from the shop for less than the price asked.

Contract

pragma solidity ^0.5.0;

interface Buyer {
  function price() external view returns (uint);
}

contract Shop {
  uint public price = 100;
  bool public isSold;

  function buy() public {
    Buyer _buyer = Buyer(msg.sender);

    if (_buyer.price.gas(3000)() >= price && !isSold) {
      isSold = true;
      price = _buyer.price.gas(3000)();
    }
  }
}

Weakness

Like for the Level 11 - Elevator, Shop never implements the price() function from the Buyer interface. An attacker can create a contract that implements its own version of this function.

Solidity Concepts

  • Interfaces
  • Inheritance
  • External function call with gas() option

    When calling functions of other contracts, you can specify the amount of Wei or gas sent with the call with the special options .value() and .gas(), respectively.

  • Gas cost to modify storage
    Ethereum Yellow Paper screenshot - Fee Schedule

    Hack

    buy() is calling price() twice:

  • In the conditional check: the price returned must be higher than 100 to pass

  • To update the price: here is the opportunity to return a value lower than 100.

So we need to implement a malicious price function that:

  • returns a value higher than 100 on its first call
  • returns a value lower than 100 on its second call
  • costs less than 3000 gas to execute. So we can't write in storate. We will read isSold instead to perform a conditinal check: isSold() ? 1: 101

Security Takeaways

  • Don't let interface function unimplemented.
  • It is unsafe to approve some action by double calling even the same view function.

Solutions on GitHub


You'll only receive email when Gauthier Riou publishes a new post

More from Gauthier Riou