Ethernaut 11: Privacy

Nothing stored on the blockchain is private …

My 11th Ethernaut challenge, Privacy

https://ethernaut.openzeppelin.com/level/0x11343d543778213221516D004ED82C45C3c8788B

Investigation

This challenge provides a contract which has been initialized with some data. The goal here is to discover what data has been stored. Here is the contract

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

Since the data variable has been marked as private, a getter function is not generated by the compiler. ie this will not work.

await contract.data();

However, we can manually view the data stored at each storage slot using the getStorageAt from the web3.eth library.

Solution

The getStorageAt function takes two parameters, the contract address and the storage slot. I first created a helper function to wrap this.

const storageAtIndex = async (index) => web3.eth.getStorageAt(contract.address, index, console.log)

The slot I am interested in here is the one that contains the third element in data. I went through each slot one by one to get a sense of how the solidity packing rules work.

await storageAtIndex(0);
// "0x0000000000000000000000000000000000000000000000000000000000000001"

This is the value of locked which is a simple boolean. No other data is fit into the first slot since the next element, ID is a uint256. uint256 is 32 bytes long and thus takes up the entire next slot.

await storageAtIndex(1);
// "0x000000000000000000000000000000000000000000000000000000006302e9b9"
web3.utils.toNumber("0x000000000000000000000000000000000000000000000000000000006302e9b9");
// 1661135289

The ID field stores the block timestamp at the time the contract was constructed. Using the toNumber util, I convert this to an integer and it lines up to when the contract was deployed. Nice 👌

await storageAtIndex(2);
// "0x00000000000000000000000000000000000000000000000000000000e9b9ff0a"

With the way EVM variable packing works, flattening (uint8), denomination(uint8) and awkwardness(uint16) are all fit into storage slot 2. In addition to this, the data is packed in reverse order, ie. flattening is the last piece of data at this location. Moving backwards, we see that flattening is 0x0a which corresponds to the integer value 10. denomination is 0xff which corresponds to 155. Finally, awkwardness is represented by e9b9.

Now to the important part, data. Since data is a fixed size array, it is stored completely in sequential order from the starting slot position. ie. data[0] is at slot 3, data[1] is at slot 4, etc. The key is derived from data[2]. The value is then clamped to the first 16 bytes by casting to a bytes16. Thus I get the key as follows

key32 = await storageAtIndex("5");
key16 = key32.substring(0, key32.length - 32);
await contract.unlock(key16);
await contract.locked();
// false

The contract is successfully unlocked

What I learned

  1. The EVM does variable packing to reduce the footprint of storage. Sequential elements that can fit into 32 bytes are slotted together into the same location in storage

  2. Elements of fixed size arrays behave like normal storage slotting. Elements of dynamic sized arrays are a bit more complex where the array location is the keccack(slotPosition)

  3. The location of elements of a mapping can be found with the keccack(slotPosition, mappingKey)