Ethernaut: Levels 10 to 12

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

Level 10 - Reentrancy

Target: steal all funds from the contract.

Contract

pragma solidity ^0.5.0;

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

contract Reentrance {

  using SafeMath for uint256;
  mapping(address => uint) public balances;

  function donate(address _to) public payable {
    balances[_to] = balances[_to].add(msg.value);
  }

  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }

  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      (bool result, bytes memory data) = msg.sender.call.value(_amount)("");
      if(result) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }

  function() external payable {}
}

Weakness

Similarly to the attack in the Level 7 - Force, when sending directly funds to an address, one does not now if it is an POA or a contract, and how the contract the contract will handle the funds.
The fallback could "reenter" in the function that triggered it.
If the check effect interaction pattern is not followed, one could withdraw all the funds of a contract: e.g if a mapping that lists the users' balance is updated only at the end at the function!

Solidity Concepts: "reenter", calling back the contract that initiated the transaction and execute the same function again.

Check also the differences between call, send and transfer seen in Level 7 - Force.
Especially by using call(), gas is forwarded, so the effect would be to reenter multiple times until the gas is exhausted.

Hack

  1. Deploy an attacker contract
  2. Implement a payable fallback that "reenter" in the victim contract: the fallback calls reentrance.withdraw()
  3. Donate an amount donation
  4. "Reenter" by withdrawing donation: call reentrance.withdraw(donation) from attacker contract
  5. Read remaining balance of victim contract: remaining = reentrance.balance
  6. Withdraw remaining: call reentrance.withdraw(remaining) from attacker contract

Takeaways

To protect smart contracts against reentrancy attacks, it used to be recommended to use transfer() instead of send or call as it limits the gas forwarded. However gas costs are subject to change. Especially with EIP 1884 gas price changed.
So smart contracts logic should not depend on gas costs as it can potentially break contracts.
Therefore transfer is then no longer recommended. Source 1 Source 2
Use call instead. As it forwards all the gas, execution of your smart contract won't break.
But if we use call and don't limit gas anymore to prevent ourselves from errors caused by running out of gas, we are then exposed to reentrancy attacks, aren't we?!
This is why one must:

  • Respect the check-effect-interaction pattern.
    1. Perform checks
      • who called? msg.sender == ?
      • how much is send? msg.value == ?
      • Are arguments in range
      • Other conditions...
    2. If checks are passed, perform effects to state variables
    3. Interact with other contracts or addresses
      • external contract function calls
      • send ethers ...
  • or use a use a reentrancy guard: a modifier that checks for the value of a locked bool

Level 11 - Elevator

Target: reach the top of the Building.

Contract

pragma solidity ^0.5.0;


interface Building {
  function isLastFloor(uint) external returns (bool);
}


contract Elevator {
  bool public top;
  uint public floor;

  function goTo(uint _floor) public {
    Building building = Building(msg.sender);

    if (! building.isLastFloor(_floor)) {
      floor = _floor;
      top = building.isLastFloor(floor);
    }
  }
}

Weakness

The Elevator never implements the isLastFloor() function from the Building interface. An attacker can create a contract that implements this function as it pleases him.

Solidity Concepts: interfaces & inheritance

Interfaces are similar to abstract contracts, but they cannot have any functions implemented.
Contracts need to be marked asabstract when at least one of their functions is not implemented.

Contract Interfaces specifies the WHAT but not the HOW.
Interfaces allow different contract classes to talk to each other.
They force contracts to communicate in the same language/data structure. However interfaces do not prescribe the logic inside the functions, leaving the developer to implement it.
Interfaces are often used for token contracts. Different contracts can then work with the same language to handle the tokens.

Interfaces are also often used in conjunction with Inheritance.

When a contract inherits from other contracts, only a single contract is created on the blockchain, and the code from all the base contracts is compiled into the created contract.
Derived contracts can access all non-private members including internal functions and state variables. These cannot be accessed externally via this, though.
They cannot inherit from other contracts but they can inherit from other interfaces.

Hack

  1. Write a malicious attacker contract that will implement the isLastFloor function of the Building interface
  2. implement isLastFloor Note that isLastFloor is called 2 times in goTo. The first time it has to return True, but the second time it has to return False
  3. invoke goTo() from the malicious contract so that the malicious version of the isLastFloor function is used in the context of our level’s Elevator instance!

Takeaways

Interfaces guarantee a shared language but not contract security. Just because another contract uses the same interface, doesn’t mean it will behave in the same way.

Level 12 - Privacy

Target: unlock contract.

Contract

pragma solidity ^0.5.0;

contract Privacy {

  bool public locked = true;
  uint256 public ID = block.timestamp;
  uint8 private flattening = 10;
  uint8 private denomination = 255;
  uint16 private awkwardness = uint16(now);
  bytes32[3] private data;

  constructor(bytes32[3] memory _data) public {
    data = _data;
  }

  function unlock(bytes16 _key) public {
    require(_key == bytes16(data[2]));
    locked = false;
  }

  /*
    A bunch of super advanced solidity algorithms...

      ,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
      .,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
      *.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^         ,---/V\
      `*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.    ~|__(o.o)
      ^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'  UU  UU
  */
}

Weakness

Similarly to the Level 8 -Vault, the contract's security relies on the value of a variable defined as private. This variable is actually publicy visible

Solidity Concepts

The layout of storage data in slots and how to read data from storage with getStorageAt were covered in Level 8 -Vault.

The slots are 32 bytes long.
1 byte = 8 bits = 4 nibbles = 4 hexadecimal digits.
In practice, when using e.g getStorageAt we get string hashes of length 64 + 2 ('0x') = 66.

Hack

  1. Analyse storage layout: |slot|variable| |--|--| |0|bool (1 bit long)| |1|ID (256 bits long)| |2|awkwardness (16 bytes) - denomination (8 bytes) - flattening (8 bytes)| |3|data0| |4|data1| |5|data2|

The _key variable is slot 5.

  1. Take the first 16 bytes of the get: take the first 2 ('0x') + 2 * 16 = 34 characters of the bytestring.

Takeaways

  • Same as for Level 8 -Vault:
    • All storage is publicly visible, even private variables
    • Don't store passwords or secret data on chain without hashing them first
  • Storage optimization
    • Use memory instead of storage if persisting data in state is not necessary
    • Order variables in such way that slots occupdation is maximized.

Less efficient storage layout
More efficient storage layout

Solutions on GitHub


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

More from Gauthier Riou