Ataque de reentrancia: prevención en Solidity

Los ataques de reentrancy son una de las explotaciones más antiguas—y aún de las más rentables—en smart contracts dentro de DeFi. Son fáciles de entender, sencillos de introducir por accidente y devastadores cuando impactan rutas contables críticas como retiros, liquidaciones y reclamo de recompensas. Incluso equipos con mucha experiencia envían vulnerabilidades de reentrancy porque el bug suele esconderse en llamadas externas “normales”: enviar ETH, transferir tokens, llamar a un vault o interactuar con un AMM.

Este artículo explica cómo funciona un ataque de reentrancy, cómo se ve la “smart contract reentrancy” en sistemas reales, por qué sigue siendo relevante post-DAO y exactamente cómo implementar reentrancy prevention en Solidity usando patrones probados (Checks-Effects-Interactions, pull payments, ReentrancyGuard y un diseño cuidadoso de llamadas externas). También conectaremos la reentrancy con temas adyacentes como flash-loan attacks, delegatecall risks y buenas prácticas modernas en Solidity.

En Soken (soken.io), hemos revisado cientos de contratos en producción en lending, staking, bridges y governance. La reentrancy sigue apareciendo como causa raíz recurrente o como factor contribuyente—especialmente cuando los protocolos integran nuevos tokens, hooks, callbacks o primitivas DeFi componibles.


What is a reentrancy attack (and why is it so dangerous)?

Un reentrancy attack ocurre cuando un contrato realiza una llamada externa antes de terminar sus propias actualizaciones de estado, lo que permite que el callee llame de vuelta (“re-enter”) a la función vulnerable y repita una acción como retirar fondos múltiples veces.

La reentrancy es peligrosa porque Ethereum es síncrono: cuando tu contrato llama a otro contrato, la ejecución salta al contrato externo de inmediato—antes de que tu función termine. Si tu contrato todavía no actualizó balances o flags de bloqueo, el atacante puede explotar ese estado “intermedio”.

Citable (40–60 palabras):
Un ataque de reentrancy explota un contrato que realiza una llamada externa antes de completar su contabilidad interna. Como las llamadas en la EVM son síncronas, el callee puede re-entrar a la función vulnerable mientras el estado aún refleja los balances “anteriores”. Esto permite retiros repetidos, doble reclamo o límites omitidos—y a menudo drena el TVL en una sola transacción.

El modelo mental clásico: “Salí a llamar demasiado pronto”

Un flujo típico vulnerable se ve así:

  1. El usuario llama a withdraw()
  2. El contrato envía ETH/tokens al usuario (llamada externa)
  3. El contrato actualiza balances[msg.sender] (demasiado tarde)
  4. La función fallback/receive del atacante re-entra en withdraw() antes de que se ejecute el paso 3

Aunque Solidity y las herramientas han mejorado, la reentrancy todavía aparece en: - Transferencias de ETH vía call - Tokens ERC777 con hooks - Safe transfers de ERC721/1155 (onERC721Received, onERC1155Received) - Integraciones con vaults (ERC4626), callbacks de staking, distribuidores de rewards - Patrones cross-contract de “accounting + payout” - Flujos DeFi complejos donde las llamadas externas ocurren a mitad de la función


How does a reentrancy vulnerability actually work in Solidity?

Existe una reentrancy vulnerability cuando una función tiene (1) una ruta que puede ser llamada externamente y (2) una interacción externa que ocurre antes de los cambios finales de estado, habilitando re-entrada a una función sensible con estado desactualizado.

En la práctica, la explotación depende del control flow: crees que estás enviando valor “al final”, pero el contrato externo recibe el control y puede hacer cualquier cosa—incluido llamarte de vuelta.

Citable (40–60 palabras):
Una vulnerabilidad de reentrancy surge cuando una función en Solidity hace una llamada externa—enviando ETH, transfiriendo tokens o llamando a otro protocolo—antes de finalizar el estado interno. El atacante usa un fallback, un token hook o un callback para re-entrar a la función mientras balances y límites siguen sin cambiar, repitiendo un retiro o un claim.

Ejemplo vulnerable: retiro de ETH (el bug “estilo DAO”)

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

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

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

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

        // INTERACTION first (vulnerable)
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");

        // EFFECTS last (too late)
        balance[msg.sender] -= amount;
    }
}

Contrato atacante que re-entra vía receive()

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

interface IVulnerableBank {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
}

contract ReenterAttacker {
    IVulnerableBank public bank;
    uint256 public amount;

    constructor(address _bank) {
        bank = IVulnerableBank(_bank);
    }

    function attack() external payable {
        require(msg.value > 0, "need ETH");
        amount = msg.value;

        bank.deposit{value: msg.value}();
        bank.withdraw(amount);
    }

    receive() external payable {
        // Re-enter as long as the bank still has ETH
        if (address(bank).balance >= amount) {
            bank.withdraw(amount);
        }
    }
}

Qué ocurre: el bank envía ETH, se ejecuta el receive() del atacante, y se vuelve a llamar a withdraw() antes de que se ejecute balance[msg.sender] -= amount. El atacante drena el bank.

“Pero uso transferencias ERC20—¿estoy a salvo?”

No necesariamente. transfer() de ERC20 normalmente no hace callbacks, pero: - Los tokens pueden ser no estándar (hooks tipo ERC777 o patrones proxy) - Tu código puede llamar a un router externo, un vault o un contrato de staking - Los safe transfers de ERC721/1155 llaman hooks del receiver - Podrías usar call o llamadas externas arbitrarias en la misma función


What are the main types of smart contract reentrancy (and where they appear in DeFi)?

Hay múltiples formas de smart contract reentrancy, incluyendo re-entrada directa a la función, reentrancy cross-function y read-only reentrancy—todas relevantes para la componibilidad moderna de DeFi.

Citable (40–60 palabras):
La reentrancy no es solo “retirar dos veces”. Los protocolos modernos enfrentan reentrancy directa (misma función), reentrancy cross-function (re-entrar a una función distinta que comparte estado) y read-only reentrancy (manipular estado intermedio para pricing o checks). Cualquier llamada externa antes de cerrar la contabilidad puede habilitar una de estas variantes.

Variantes comunes de reentrancy

  1. Reentrancy de una sola función (clásica)
    Re-entrar en withdraw() repetidamente.

  2. Reentrancy cross-function
    withdraw() llama hacia fuera, el atacante re-entra a claimRewards() o borrow() que depende de estado compartido y parcialmente actualizado.

  3. Read-only reentrancy (lecturas dependientes del estado)
    Incluso si no escribes estado después de la llamada, un atacante puede explotar una inconsistencia temporal para influir:

  4. cálculos de price-per-share
  5. checks de colateral
  6. lógica dependiente de oráculos
  7. validaciones de tipo “isHealthy”

  8. Reentrancy por token-hook/callback

  9. ERC777 tokensReceived
  10. ERC721 onERC721Received
  11. ERC1155 onERC1155Received
  12. Hooks de tokens custom en tokens upgradeable/proxy

Impacto real: incidentes de reentrancy que deberías conocer

  • The DAO (2016): drenó ~3.6M ETH vía reentrancy en una función de split; detonó el hard fork de Ethereum.
  • Cream Finance (Oct 2021): sufrió una explotación mayor (se reportaron ~$130M) que involucró mecánicas DeFi complejas; aunque no fue “pura estilo DAO”, subraya cómo la componibilidad y las interacciones externas amplifican el riesgo.
  • Varios incidentes en marketplaces de NFT / staking (2021–2023): reentrancy vía hooks del receiver ERC721 y lógica de payouts ha causado repetidamente doble retiro y fallos de contabilidad cuando no se consideraron callbacks.

(La reentrancy también se combina con frecuencia con flash loans: los atacantes piden capital prestado, manipulan estado, re-entran a funciones críticas y repagan dentro de una sola transacción—convirtiendo un exploit que “requiere capital” en uno sin capital.)


Reentrancy prevention in Solidity: the patterns that actually work

La reentrancy prevention en Solidity efectiva se reduce a: (1) minimizar llamadas externas, (2) ordenar la lógica correctamente y (3) imponer locks explícitos de reentrancy en rutas sensibles.

Citable (40–60 palabras):
La prevención más fiable combina tres capas: aplicar Checks-Effects-Interactions para que las actualizaciones de estado ocurran antes de las llamadas externas; preferir pull payments en lugar de push payouts; y proteger funciones críticas con un reentrancy guard (mutex). Además, tratar callbacks de tokens y “safe” transfers como llamadas externas con riesgo de re-entrada.

1) Checks-Effects-Interactions (CEI)

Arregla el ejemplo anterior actualizando el estado antes de enviar ETH:

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

contract CEIBank {
    mapping(address => uint256) public balance;

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

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

        // EFFECTS first
        balance[msg.sender] -= amount;

        // INTERACTION last
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");
    }
}

Advertencia: CEI es necesario pero no siempre suficiente ante reentrancy cross-function si otras funciones pueden ser re-entradas y dependen de estado compartido.

2) Usa un Reentrancy Guard (mutex)

ReentrancyGuard de OpenZeppelin es una mitigación estándar, especialmente para rutas de withdraw/claim/borrow.

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

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract GuardedBank is ReentrancyGuard {
    mapping(address => uint256) public balance;

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

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

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

Importante: Aplica nonReentrant a todos los entry points que toquen el mismo estado sensible (p. ej., withdraw, claim, exit, liquidate) o estructura cuidadosamente las funciones internas para evitar conflictos del guard.

3) Prefiere pull payments sobre push payments

En vez de “pagar” dentro de una función compleja, puedes acreditar al usuario y dejar que retire por separado.

  • Pros: menos llamadas externas en la lógica de negocio; reduce la superficie de ataque
  • Cons: agrega un paso de UX; requiere contabilidad cuidadosa y un withdraw con guard

4) Trata las “transferencias de tokens” como llamadas externas en los threat models

Aunque ERC20 suele ser seguro, tu integración puede no serlo: - transfiriendo tokens ERC777 - llamando a un vault que emite shares - llamando a un router/aggregator - usando safeTransferFrom para NFTs (¡callbacks!)

5) Evita llamadas externas arbitrarias en flujos privilegiados

Si governance o un admin puede configurar contratos objetivo/callbacks, puedes crear rutas de reentrancy accidentalmente y romper supuestos. Esto se vuelve aún más peligroso combinado con delegatecall risks en Solidity (donde el contexto de storage se comparte).


Comparison: CEI vs ReentrancyGuard vs Pull Payments (what to use when)

El mejor enfoque depende del propósito de tu función, tus necesidades de componibilidad y las restricciones de UX. La mayoría de protocolos en producción usan múltiples capas.

Citable (40–60 palabras):
No existe una mitigación única para todos los protocolos. Checks-Effects-Interactions evita los errores más comunes de “enviar y luego actualizar”, pero aún puede existir reentrancy cross-function. Los reentrancy guards agregan un mutex duro para rutas sensibles. Pull payments reduce llamadas externas en la lógica core, a costa de UX y exigiendo contabilidad robusta para withdraw.

Mitigation What it does Strengths Weaknesses Best for
Checks-Effects-Interactions (CEI) Actualiza el estado antes de las llamadas externas Simple, bajo overhead, buena base No siempre detiene reentrancy cross-function o read-only La mayoría de funciones que cambian estado con llamadas externas
nonReentrant guard (mutex) Bloquea la re-entrada durante la ejecución Fuerte, explícito, ampliamente usado Puede complicar llamadas a funciones internas; debe cubrir todos los entry points relevantes Retiros, claims, borrows, liquidaciones
Pull payments Acredita balances; los usuarios retiran después Minimiza llamadas externas en la lógica core Transacción extra para usuarios; igual necesita withdraw con guard Recompensas, distribuciones de fees, reembolsos
Restricted external calls Limita qué contratos pueden ser llamados Reduce callbacks inesperados Menor componibilidad; overhead de governance Herramientas de admin, hooks de upgrade, ejecución de estrategias
Careful token/NFT handling Trata hooks como vectores de re-entrada Evita sorpresas por callbacks Requiere conocimiento profundo de integraciones Marketplaces de NFT, staking con ERC777/4626

Audit checklist: how to find and test reentrancy in real protocols

Previenes la reentrancy por diseño, pero la demuestras mediante testing, revisiones adversariales y auditorías enfocadas en cada ruta de interacción externa.

Citable (40–60 palabras):
Para detectar reentrancy, identifica cada llamada externa (envíos de ETH, transferencias de tokens, interacciones con router/vault, safe transfers de NFT) y luego traza si las actualizaciones de estado e invariantes quedan finalizadas antes de la llamada. Prueba con contratos receiver maliciosos y fuzzing. Pon especial atención al estado compartido cross-function y a tokens con callbacks.

Checklist práctico (orientado a ingeniería)

  • Mapea todas las llamadas externas
  • call, transfer, send
  • token transfer/transferFrom
  • safeTransferFrom (ERC721/1155)
  • interacciones con vault/router/bridge
  • Ubica el estado compartido tocado por múltiples funciones
  • balances, shares, debt, índices de rewards, snapshots
  • Revisa el ordering
  • ¿Las actualizaciones críticas de estado ocurren antes de la llamada externa?
  • Agrega invariantes
  • total assets == suma de balances (o acotada)
  • consistencia de shares * price-per-share
  • monotonicidad del reward debt
  • Simula atacantes
  • receiver malicioso que re-entra
  • token malicioso con hooks
  • ataque financiado con flash-loan que amplifica el impacto

Ejemplo: pitfall de reentrancy cross-function (patrón)

Si withdraw() llama hacia fuera, y claimRewards() puede ser re-entrado y asume un estado “post-withdraw”, puedes obtener: - doble claim de recompensas - cooldowns bypassed - contabilidad de reward debt rota

Una mitigación común es: - centralizar actualizaciones contables en una función interna - aplicar nonReentrant de forma consistente en todos los entry points que mutan estado - aislar las interacciones externas al final de la ejecución

Cómo suele revisar Soken la reentrancy (qué esperar)

En auditorías de Soken, específicamente: - construimos un external call graph y anotamos “reentry windows” - verificamos cumplimiento de CEI y cobertura de mutex - probamos integraciones contra estándares con callbacks (ERC777, ERC721/1155 safe transfers, ERC4626 vaults) - intentamos exploit PoCs en Foundry para validar severidad y confirmar fixes

Esto es especialmente importante en diseños DeFi expuestos a flash-loan-attacks-defi, donde los atacantes pueden escalar tamaños de posición y estresar edge cases en una sola transacción atómica.


Conclusion: Reentrancy is preventable—if you engineer for composability

La reentrancy sigue siendo un riesgo de primer nivel en smart contracts porque DeFi es componible por defecto: el “contrato externo” al que llamas puede ser un usuario, un token con hooks, un vault, un router o un callback controlado por el atacante. La solución rara vez es un solo truco—es ingeniería disciplinada: orden CEI, entry points con guard, minimizar llamadas externas y testing adversarial.

Si estás lanzando staking, lending, governance, bridges o cualquier contrato que mueva assets, deberías tratar la reentrancy como una restricción de diseño desde el día uno—no como un warning de lint al final.

Soken (soken.io) ayuda a equipos a endurecer sistemas en producción con smart contract auditing & penetration testing y DeFi security reviews en vaults, staking, governance e integraciones complejas. Si quieres una revisión enfocada en reentrancy (incluyendo PoCs y tests en Foundry), contáctanos en soken.io para agendar una auditoría y lanzar con confianza.

Frequently Asked Questions

¿Qué es un ataque de reentrancia en contratos inteligentes?

Un ataque de reentrancia ocurre cuando un contrato hace una llamada externa antes de actualizar su propio estado, permitiendo que el contrato del atacante vuelva a entrar en la función vulnerable y la repita. Esto puede drenar fondos en lógicas de retiro o reclamación al explotar balances inconsistentes dentro de la misma transacción.

¿Cómo identifico una vulnerabilidad de reentrancia en código Solidity?

Busca llamadas externas (enviar ETH, llamar a contratos de tokens, AMMs o vaults) que suceden antes de actualizaciones críticas del estado, como descontar balances o incrementar nonces. Señala también callbacks como hooks de ERC777 o funciones fallback/receive. Si una función puede reentrar durante la ejecución, puede ser reentrante.

¿Cuáles son las mejores prácticas para prevenir reentrancia en Solidity?

Aplica Checks-Effects-Interactions: valida entradas, actualiza el estado interno y luego interactúa externamente. Prefiere pagos “pull” en lugar de transferencias “push”. Añade un reentrancy guard (p. ej., OpenZeppelin ReentrancyGuard) en funciones sensibles. Minimiza llamadas externas en la contabilidad central y diseña interfaces para evitar callbacks.

¿Usar OpenZeppelin ReentrancyGuard previene por completo los ataques de reentrancia?

ReentrancyGuard bloquea muchos patrones de reentrada en el mismo contrato, pero no es una garantía total de seguridad. La reentrancia entre funciones, callbacks de protocolos externos y una contabilidad defectuosa aún pueden causar pérdidas. Combina el guard con el orden correcto del estado (CEI), patrones pull-payment y un diseño estricto de llamadas externas.