Ethernaut: Levels 13 to 15

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:
Gatekeeper Two
Naught Coin

Level 13 - Gatekeeper One

Target: make it past the gatekeeper.

Contract

pragma solidity ^0.5.0;

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

contract GatekeeperOne {

  using SafeMath for uint256;
  address public entrant;

  modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
  }

  modifier gateTwo() {
    require(gasleft().mod(8191) == 0);
    _;
  }

  modifier gateThree(bytes8 _gateKey) {
      require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
      require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
      require(uint32(uint64(_gateKey)) == uint16(tx.origin), "GatekeeperOne: invalid gateThree part three");
    _;
  }

  function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
    entrant = tx.origin;
    return true;
  }
}

Weakness

  • Contract relies on tx.origin.
  • - Being able to read the public contract logic teaches how to pass gateTwo and gateThree.

Solidity Concepts: explicit conversions and masking

Explicity type conversions

Be careful, conversion of integers and bytes behave differently!

conversion to uint bytes
shorter type left-truncate: uint8(273 = 0000 0001 0001 0001) = 00001 0001 = 17 right-truncate: bytes4(0x1111111122222222) = 0x11111111
larger type left-padded with 0: uint16(17 = 0001 0001) = 0000 0000 0001 0001 = 17 right-padded with 0: bytes8(0x11111111) = 0x1111111100000000

Masking

Masking means using a particular sequence of bits to turn some bits of another sequence "on" or "off" via a bitwise operation.
For example to "mask off" part of a sequence, we perform an AND bitwise operation with:

  • 0 for the bits to mask
  • 1 for the bits to keep
    10101010
AND 00001111
 =  00001010

Hack

  1. Pass gateOne: deploy an attacker contract that will call the victim contract's enter function to ensure msg.sender != tx.origin. This is similar to what we've accomplished for the Level 4 - Telephone
  2. Pass gateTwo
  3. Pass gateThree Note that we need to pass a 8 bytes long _gateKey. It is then explicitly converted to a 64 bits long integer.
    1. Part one
      • uint16(uint64(_gateKey)): uint64 _gateKey is converted to a shorter type (uint16) so we keep the last 16 bits of _gateKey.
      • uint32(uint64(_gateKey)): uint64 _gateKey is converted to a shorter type (uint32) so we keep the last 32 bits of _gateKey
      • uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)): we convert uint16 to a larger type (uint32), so we pad the last 16 bits of gateKey with 16*0 on the left. This concatenation should equal the last 32 bits of gateKey.
      • Mask to apply on the last 32 bits of _gateKey: 0000 0000 0000 0000 1111 1111 1111 1111 = 0x0000FFFF
    2. Part two
      • uint32(uint64(_gateKey): last 32 bits of _gateKey
      • uint32(uint64(_gateKey)) != uint64(_gateKey): the last 32 bits of gateKey are converted to a larger type (uint64), so we pad them with 320 on the left. This concanetation (320-last32bitsofGateKey) should not equal _gateKey: so we need to keep the first bits of _gateKey
      • Mask to apply to keep the first 32 bits: 0xFFFFFFFF
    3. We then concatenate both masks: 0xFFFF FFFF 0000 FFFF Requires keeping the first 32 bits, mask with 0xFFFFFFFF. Concatenated with the first part: mask = 0xFFFF FFFF 0000 FFFF
    4. Part three: uint32(uint64(_gateKey)) == uint16(tx.origin)
      • we need to take _gatekey = tx.origin
      • we then apply the mask on tx.origin to ensure part one and two are correct

Takeaways

  • Abstain from asserting gas consumption in your smart contracts, as different compiler settings will yield different results.
  • Be careful about data corruption when converting data types into different sizes.
  • Save gas by not storing unnecessary values.
  • Save gas by using appropriate modifiers to get functions calls for free, i.e. external pure or external view function calls are free!
  • Save gas by masking values (less operations), rather than typecasting

Level 14 - Gatekeeper Two

Target: make through the gatekeeper.

Contract

pragma solidity ^0.5.0;

contract GatekeeperTwo {

  address public entrant;

  modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
  }

  modifier gateTwo() {
    uint x;
    assembly { x := extcodesize(caller) }
    require(x == 0);
    _;
  }

  modifier gateThree(bytes8 _gateKey) {
    require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1);
    _;
  }

  function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
    entrant = tx.origin;
    return true;
  }
}

Weakness

  • gateOne relies on tx.origin.
  • Being able to reading the public contract logic teaches how to pass gateTwo and gateThree.

Solidity Concepts: inline assembly & contract creation/initialization

From the Ethereum yellow paper section 7.1 - subtleties we learn:

while the initialisation code is executing, the newly created address exists but with no intrinsic body code⁴.
4.During initialization code execution, EXTCODESIZE on the address should return zero [...]

Hack

  1. gateOne: similar to the gateOne of Level 13 - Gatekeeper One or to the hack of Level 4 - Telephone
  2. gateTwo: call the enter function during contract initialization, i.e from within constructor to ensure EXTCODESIZE = 0
  3. gateThree
    • uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) noted a ^ b means a XOR b
    • uint64(0) - 1: underflow, this is equals to uint64(1) So we need to take _gatekey = ~a (Bitwise NOT) to ensure that the XOR product of each bit of a and b will be 1.

Takeaways

During contract initialization, the contract has no intrinsic body code and its extcodesize is 0.

Level 15 - Naughtcoin

Target: transfer your tokens to another address.

Contract

pragma solidity ^0.5.0;

import 'openzeppelin-solidity/contracts/token/ERC20/ERC20Detailed.sol';
import 'openzeppelin-solidity/contracts/token/ERC20/ERC20.sol';

 contract NaughtCoin is ERC20, ERC20Detailed {

  // string public constant name = 'NaughtCoin';
  // string public constant symbol = '0x0';
  // uint public constant decimals = 18;
  uint public timeLock = now + 10 * 365 days;
  uint256 public INITIAL_SUPPLY;
  address public player;

  constructor(address _player) 
  ERC20Detailed('NaughtCoin', '0x0', 18)
  ERC20()
  public {
    player = _player;
    INITIAL_SUPPLY = 1000000 * (10**uint256(decimals()));
    // _totalSupply = INITIAL_SUPPLY;
    // _balances[player] = INITIAL_SUPPLY;
    _mint(player, INITIAL_SUPPLY);
    emit Transfer(address(0), player, INITIAL_SUPPLY);
  }

  function transfer(address _to, uint256 _value) lockTokens public returns(bool) {
    super.transfer(_to, _value);
  }

  // Prevent the initial owner from transferring tokens until the timelock has passed
  modifier lockTokens() {
    if (msg.sender == player) {
      require(now > timeLock);
      _;
    } else {
     _;
    }
  } 
} 

Weakness

NaughCoin inherits from the ERC20 contract.
Looking at this contract, we notice that transfer() is not the only function to transfer tokens.

Indeed transferFrom(address sender, address recipient, uint256 amount) can be used instead: provided that a 3rd user (spender) was allowed beforehand by the owner of the tokens to spend a given amount of the total owner's balance, spender can transfer amount to recipient in the name of owner.

Successfully executing transferFrom requires the caller to have allowance for sender's tokens of at least amount. The allowance can be set with the approve or increaseAllowance functions inherited from ERC20.

Concepts: ERC20 token contract

The ERC20 token contract is related to the EIP 20 - ERC20 token standard. It is the most widespread token standard for fungible assets.

Any one token is exactly equal to any other token; no tokens have special rights or behavior associated with them. This makes ERC20 tokens useful for things like a medium of exchange currency, voting rights, staking, and more.

Hack

Architecture

transferFrom calls _transfer and _approve. _approve calls allowance and checks whether the caller was allowed to spend the amount by sender.

architecture diagram

Workflow

We want to set the player's allowance for the attack contract. For this we need to callapprove() which calls _approve(msg.sender, spender, amount). In this call we need msg.sender == player, so we can't call victim.approve() from the attacker contract. If we would, then msg.sender == attackerContractAddress. This would set the attack contract's allowance instead of the player's one.
Finally we let the attacker call transferFrom() to transfer to itself the player's tokens.

Hack workflow diagram

Security Takeaways

Get familiar with contracts you didn't write, especially with imported and inherited contracts. Check how they implement authorization controls.

Solutions on GitHub


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

More from Gauthier Riou