Reentrancy es la vulnerabilidad en la que un contrato, al llamar a otro, le entrega el control y recibe una llamada de vuelta antes de haber terminado de actualizar su propio estado. Se previene con un orden de operaciones simple.
Qué ocurre exactamente
Según la documentación de Solidity, cuando un contrato llama a otro o le envía ETH, el contrato receptor toma el control y puede volver a llamar al contrato original antes de que la primera llamada termine. Si el original todavía no actualizó su estado, el atacante lo explota con datos viejos. Y no se limita a enviar ETH: cualquier llamada a otro contrato puede dar esa oportunidad.
Una bóveda vulnerable
Una bóveda guarda saldos por dirección. Su función retirar envía el dinero y recién después pone el saldo en cero:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract BovedaVulnerable {
mapping(address => uint256) public saldo;
function depositar() external payable {
saldo[msg.sender] += msg.value;
}
function retirar() external {
uint256 monto = saldo[msg.sender];
require(monto > 0, "sin saldo");
(bool ok, ) = msg.sender.call{value: monto}(""); // interacción
require(ok, "fallo el envio");
saldo[msg.sender] = 0; // efecto, demasiado tarde
}
}
El atacante despliega un contrato cuya función receive se ejecuta cada vez que le llega ETH, y desde ahí vuelve a llamar a retirar:
interface IBoveda {
function depositar() external payable;
function retirar() external;
}
contract Atacante {
IBoveda public immutable boveda;
constructor(IBoveda _boveda) {
boveda = _boveda;
}
function atacar() external payable {
boveda.depositar{value: msg.value}();
boveda.retirar();
}
receive() external payable {
if (address(boveda).balance >= 1 ether) {
boveda.retirar();
}
}
}
La secuencia es: el atacante deposita 1 ETH y llama a retirar. La bóveda lee su saldo (1 ETH) y le envía el ETH. En ese envío se ejecuta receive, que llama a retirar otra vez. El saldo sigue siendo 1 ETH porque la línea que lo pone en cero todavía no corrió, así que la bóveda paga de nuevo. Se repite hasta vaciarla. En la prueba que escribimos, la bóveda tenía 10 ETH de otras personas y el atacante depositó 1: terminó con 11 ETH.
Checks-Effects-Interactions
La solución que recomienda la documentación de Solidity es el patrón Checks-Effects-Interactions: primero se validan las condiciones, después se actualiza el estado y recién al final se hacen las llamadas externas.
contract BovedaSegura {
mapping(address => uint256) public saldo;
function depositar() external payable {
saldo[msg.sender] += msg.value;
}
function retirar() external {
uint256 monto = saldo[msg.sender];
require(monto > 0, "sin saldo"); // 1. Checks
saldo[msg.sender] = 0; // 2. Effects
(bool ok, ) = msg.sender.call{value: monto}(""); // 3. Interactions
require(ok, "fallo el envio");
}
}
Ahora, cuando el atacante vuelve a entrar desde receive, su saldo ya es cero. El require falla, el envío interno revierte y toda la transacción se deshace: la bóveda conserva sus 10 ETH. Es lo que comprobamos con una prueba en Foundry.
Una segunda barrera: ReentrancyGuard
OpenZeppelin ofrece el módulo ReentrancyGuard, cuyo modificador nonReentrant impide que una función se llame a sí misma, directa o indirectamente, mientras se está ejecutando:
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract BovedaConGuard is ReentrancyGuard {
// ... saldo y depositar iguales ...
function retirar() external nonReentrant {
uint256 monto = saldo[msg.sender];
require(monto > 0, "sin saldo");
saldo[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: monto}("");
require(ok, "fallo el envio");
}
}
Tiene límites que conviene conocer. La documentación aclara que dos funciones nonReentrant no pueden llamarse entre sí; la solución sugerida es mover la lógica a funciones private y dejar el modificador solo en los puntos de entrada. Tampoco reemplaza al patrón: el guard es una protección adicional, no una excusa para ordenar mal el código. Existe además una variante, ReentrancyGuardTransient, que usa almacenamiento transitorio (EIP-1153), y la documentación de OpenZeppelin indica que la versión basada en storage está marcada como deprecada para la v6.0.
Reglas prácticas
- Ordená cada función pública: condiciones, estado, llamadas externas.
- Considerá "externa" cualquier llamada a otro contrato, incluso a uno que creas confiable, y también la emisión de un NFT con
_safeMint, que invoca código del receptor. - Pensá en varios contratos a la vez: la documentación advierte que un contrato puede cambiar el estado de otro del que dependés.
- Auditá con herramientas de análisis estático, como Slither, y escribí una prueba que intente el ataque.
Fuentes
Verificadas el 27/10/2026. Los ejemplos de código se compilaron y probaron con Foundry (Solidity 0.8.33, OpenZeppelin Contracts 5.7.0) y Python 3.