Ethernaut(Lvl 12): Is it really private ?

Ethernaut(Lvl 12): Is it really private ?

How Data and Storage is optimized and vulnerabilities in a contract

Ethereum employs strategies to optimize data storage, as outlined in the Solidity documentation:

Variables with static sizes, excluding mappings and dynamically-sized array types, are arranged in storage contiguously, starting from position 0. To maximize efficiency, smaller variables are combined into a single storage slot whenever possible. For instance, consider boolVar and bytes4Var, which are allocated separate slots despite their smaller sizes. By arranging them sequentially, the Ethereum Virtual Machine (EVM) can pack them into a single storage slot, reducing wasted space.

Similarly, in the Object struct, it's more efficient to group uint8 variables together. This compact arrangement reduces the storage footprint of each Object instance, optimizing storage usage. It's crucial to note that storage slots index from right to left, meaning variables are stored in reverse order of initialization.

However, there are exceptions to these storage optimization techniques. Constants, for example, are not stored in storage slots. The compiler does not reserve storage slots for constant variables, ensuring they do not contribute to storage consumption.

Mappings and dynamically-sized arrays deviate from these conventions and require special consideration, which will be explored in more detail in subsequent discussions.

Understanding these storage optimization principles is essential for effectively managing data storage in Ethereum contracts.

The Challenge

The challenge highlight how the contract creator has undergone some extra methods to protect sensitive information in the contract , yet you are still expected to unlock the contract in order to beat the level.

// 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);
  bytes32[3] private data;

  constructor(bytes32[3] memory _data) {
    data = _data;
  }

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

  /*
    A bunch of super advanced solidity algorithms...

      ,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
      .,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
      *.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^         ,---/V\
      `*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.    ~|__(o.o)
      ^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'  UU  UU
  */
}

In this scenario, we aim to access a "private" storage variable. While the private visibility modifier restricts access to other contracts, it does not prevent access using web3.js. Despite its private designation, we can still retrieve the storage variable with web3.js.

Essentially, storage in Solidity operates akin to a mapping function, where storage(slot): 2^256 -> 2^256 maps 32-byte integers to 32-byte integers. Each primitive variable resides within a storage "slot" (an index within the 256-bit domain) where it stores its respective data. Multiple consecutive variables that collectively occupy less than a 256-bit storage space can be consolidated into a single slot. However, for more complex data structures like mappings or dynamically-sized arrays whose size isn't known at compile-time, hash functions are utilized to determine the storage location for each entry. In summary, the contract's storage structure adheres to this mapping scheme.

0:       0x0000000000000000000000000000000000000000000000000000000000000001 (1)
1:       0x000000000000000000000000000000000000000000000000000000005ff64a80 (1609976448)
2:       0x000000000000000000000000000000000000000000000000000000004a80ff0a (now=0x4a80, 255=0xff, 10=0xa)
3:       0x8de895aac60c474d0d1bd8fdb7fdc79a6f069101c77f810605e4520f72566874 (data[0])
4:       0xe514d3b3085d092bcc598cc8357dbadc60b34b4cd6af88333e9e8aad9af81c9a (data[1])
5:       0x35c226031a34414845c12c4d7a7e817d3e639e98f9a89258e3a249c3427da3e4 (data[2])
6:       0x0000000000000000000000000000000000000000000000000000000000000000 (0)

We're seeking the bytes16 value stored at data[2], which is situated at storage slot data[0] + 2, equivalent to slot 5. When converting from bytes32 to bytes16, only the first 16 bytes (most significant bits) are utilized, denoted as data[2][0..15].

This value can then be passed to unlock the contract.

Remember it is being passed into the unlock() function.

Developer TidBits

  1. Efficient storage allocation is crucial for minimizing gas consumption. Avoid excessive slot usage, particularly when dealing with structs that generate numerous instances. Optimize your storage strategy to conserve gas.

  2. Opt for memory storage instead of contract state persistence if it's not necessary. Operations involving SSTORE and SLOAD are notably gas-intensive. Prioritize memory storage to mitigate gas costs.

  3. It's important to note that all storage data is publicly accessible on the blockchain, even supposedly private variables. Exercise caution and implement appropriate security measures to protect sensitive information.

  4. Never store passwords and private keys directly without proper encryption or hashing mechanisms in place. Prioritize security by hashing sensitive data before storing it in the contract.