Presiona ESC para cerrar

Fallos de Reentrancy en Solidity por ChatGPT: 3 Ejemplos

Saben, cuando yo me desvelaba en los hackathons... bueno, qué desvelos ni qué nada, en esa época dormíamos tres horas bajo los racks de servidores, la verdad creía que cualquier analizador estático podía detectar las vulnerabilidades básicas en smart contracts. ¿Qué podía tener de difícil? El reentrancy (ataque de reentrancia) es un clásico, todo el mundo ha escrito sobre eso. El patrón Checks-Effects-Interactions, modifiers por aquí y por allá... Y llega un junior al equipo hace unos meses y suelta bien orgulloso: «¡Miren, ChatGPT me generó un contrato de staking perfecto, ya lo revisé y está seguro!»

Por poco escupo el café en el teclado mecánico.

Seamos honestos: las LLMs escriben un Solidity bien chulo. La sintaxis brilla, los imports están impecables y OpenZeppelin viene configurado con las últimas tendencias. Pero cuando la cosa toca la lógica fina de distribución de estados, la IA empieza con una seguridad ciega y tonta a generar fallos que luego, en las auditorías, se tiran millones de dólares. Analicemos tres casos poco obvios donde ChatGPT mete la pata hasta el fondo mientras te asegura que tu código pasaría una auditoría en CertiK con los ojos cerrados.

Caso n.° 1: Staking asíncrono con recompensas por bloque y llamada externa oculta

Miren, le piden a la IA que escriba un contrato que distribuya tokens según el tiempo que pasas en el pool. ¿Qué hace el modelo? Te clava con confianza el modifier nonReentrant en cualquier parte donde vea una transferencia de fondos al usuario. Pero se olvida de un detalle arquitectónico clave.

Échenle un ojo a este fragmento que saqué de un code review real después de la gracia del junior:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
interface IERC20Mintable {
    function mint(address to, uint256 amount) external;
}
contract SecureStakingVault {
    IERC20Mintable public rewardToken;
    mapping(address => uint256) public balances;
    mapping(address => uint256) public lastUpdateBlock;
    
    uint256 public rewardRatePerBlock = 10 * 10**18;
    bool private locked;
    // ¡Ya lo blindamos, hombre! ¿Qué más quieres, paranoico?
    modifier noReentry() {
        require(!locked, "LOCKED");
        locked = true;
        _;
        locked = false;
    }
    constructor(address _token) {
        rewardToken = IERC20Mintable(_token);
    }
    function stake() external payable {
        require(msg.value > 0, "Zero stake");
        balances[msg.sender] += msg.value;
        lastUpdateBlock[msg.sender] = block.number;
    }
    // La obra maestra de ChatGPT que se ve segurísima
    function harvestAndUnstake(uint256 _amount) external noReentry {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        uint256 blocksPassed = block.number - lastUpdateBlock[msg.sender];
        uint256 reward = blocksPassed * rewardRatePerBlock * _amount / balances[msg.sender];
        // Ojo: la actualización de estado pasa ANTES de la llamada externa de mint
        balances[msg.sender] -= _amount;
        lastUpdateBlock[msg.sender] = block.number;
        // La emisión de recompensas dispara un constructor del token o un hook, y ¡pásala bien!
        rewardToken.mint(msg.sender, reward);
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");
    }
}

¿Dónde está la trampa? El modelo cree firmemente que como las variables de saldo se limpiaron antes de rewardToken.mint(), el ataque es imposible. Pero resulta que rewardToken es un ERC-20 custom, cuyo contrato de mint trae un hook _beforeTokenTransfer o un callback tipo ERC-777 (si el token es complejo o usa proxy). En el momento de llamar a mint, la ejecución salta a código de terceros antes de que el ETH llegue al usuario, pero en plena sesión activa cuando el estado del pool ya se alteró de forma unilateral y el recálculo de recompensas para los demás usuarios se fue al diablo. Esto es un reentrancy entre contratos de nueva generación del que los manuales estándar ni pío dicen.

Caso n.° 2: Secuestro multifuncional mediante oráculos de liquidez

Lo traicionero de los exploits modernos es que el reentrancy suele darse no dentro de una sola función, sino entre dos puntos de entrada completamente distintos que comparten un mismo mapping. A la IA le encanta seccionar la lógica en módulos "limpios", pasando por alto por completo el orden en que se alteran los invariantes globales.

  • Parámetro de vulnerabilidad: Evaluación del analizador estático
  • Situación real: Modifiers
  • Incluidos (nonReentrant): Inútiles contra llamadas multifuncionales
  • Orden de operaciones: Checks-Effects cumplidos localmente
  • Equilibrio global del protocolo roto: Respuesta de ChatGPT
  • «El código está totalmente protegido contra reentrancy»: Deja pasar manipulación cruzada de estados

Caso n.° 3: Contratos con duración flotante y bóvedas ERC-4626

ChatGPT está obsesionado con las plantillas estandarizadas de OpenZeppelin para bóvedas de rendimiento (ERC-4626). Agarra sus ejemplos listos, le mete sus matemáticas custom para calcular shares y te bota un código que compila a la primera. El detalle es que la función de conversión de acciones a activos (convertToAssets) bajo ciertas condiciones de llamada mediante un contrato intermedio permite disparar un retorno de control multinivel mientras calcula la liquidez virtual.

Hace tres semanas me quedé media madrugada en la testnet buscando un bug, mientras un bot simulador le drenaba toda la liquidez al pool siguiendo exactamente esta receta. La IA me recetaba: «Usa escala de multiplicadores para mayor precisión». ¿Y qué te llevas al final? Un clásico read-only reentrancy, donde un sistema externo lee el estado intermedio y aún no confirmado de una bóveda justo al momento de hacer un depósito.

Sigamos adentrándonos en este tétrico teatro del absurdo que nos generan los modelos de lenguaje cuando les pedimos que escriban un "smart contract simple y seguro".

¿Saben qué es lo más divertido de trabajar como exauditor de seguridad? Ver a los devs confiar ciegamente en los comentarios del propio ChatGPT. La IA te suelta una línea arriba tipo // Safe against reentrancy attacks thanks to Checks-Effects-Interactions — y zácata, se le apaga el sentido crítico al dev. Piloto automático encendido, cerebro en modo standby, y listo: ya tenemos un hermoso fuego artificial en la mainnet.

Analicemos un par de ejemplos más que las IAs insisten en catalogar como el estándar de oro en seguridad, a pesar de que en la práctica se rompen con la mirada.

Caso n.° 4: Arbitraje multi-paso con reembolso mediante flash loans

Este es el clásico ejercicio que le tiran a los juniors en los hackathons: escribir un contrato que pida una flash-crypto prestada, la mueva por un DEX, se quede con la ganancia, devuelva el principal y asegure las comisiones en un pool de liquidez. ¿Qué hace ChatGPT? Te acomoda felizmente unos checks con require para los balances al inicio y al cierre de la transacción, bien convencido de que si el delta cuadra, el sistema es indestructible.

Pero el diablo, como siempre, se esconde en los detalles al invocar la función callback uniswapV2Call (o su equivalente en otros protocolos).

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
interface IUniswapV2Pair {
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
}
contract AIArbitrageBotHolder {
    address private immutable owner;
    bool private unlocked = true;
    // La IA cree que este lock basta para asegurar toda la lógica
    modifier lock() {
        require(unlocked, "LOCKED");
        unlocked = false;
        _;
        unlocked = true;
    }
    constructor() {
        owner = msg.sender;
    }
    // El supuesto método "seguro" según la IA
    function executeFlashLoan(address _pair, uint256 _amountA, bytes calldata _data) external lock {
        // Pidiendo liquidez al pool
        IUniswapV2Pair(_pair).swap(_amountA, 0, address(this), _data);
    }
    // Callback donde el pool devuelve el control
    function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external {
        // Error garrafal: la IA suele omitir la validación de msg.sender por "limpieza" de código
        // Y voilà, aquí puede caer cualquier contrato malicioso a detonar un loop de reentrancy
        
        uint256 fee = amount0 * 3 / 997 + 1;
        uint256 repayment = amount0 + fee;
        // Lógica de arbitraje armada por ChatGPT
        _executeInternalArbitrage(amount0);
        // Devolviendo la guita al pool
        // (Olvidando por completo que el state de los pools externos aún no está consolidado)
        bool success = IERC20(msg.sender).transfer(msg.sender, repayment);
        require(success, "Repayment failed");
    }
    function _internalArbitrage(uint256 amount) internal {
        // Mock de lógica de swap compleja
    }
}

¿Ven la brecha de seguridad? La IA le clava el modificador lock al entry point externo executeFlashLoan, pero se olvida por completo de que el callback uniswapV2Call debe aislarse por separado o validar a capa y espada quién lo llama (msg.sender). Un atacante puede pegarle a este callback directo desde su propio contrato a mitad de los cálculos intermedios —cuando los balances del contrato wrapper todavía no son consistentes— y vaciar el colateral por completo. ChatGPT jamás te va a advertir sobre esto; solo te tirará una sonrisita con emojis en el chat y te soltará: «Código optimizado para el estándar ERC».

Caso n.° 5: Contracts de batch minting para ERC-721 optimizados con caché

El mercado cripto actual exige gas barato. Obviamente la IA capta esto al vuelo y arranca a "optimizar" metiendo mapeos de owners a medida y contadores cacheados directo en la memoria del contract.

  • Métrica de auditoría: Veredicto de ChatGPT — Estado real de la vulnerabilidad
  • Gasto de gas: Ultra bajo (Gas Optimized) — Logrado a costa de romper el aislamiento de state
  • Patrón de storage: Uso de structs locales en memoria — Vulnerable a manipulaciones vía hooks onERC721Received
  • Estabilidad: Pasa los tests estándar de Hardhat — Explota si se encuentra con un call stack profundo de reentrancy

Cuando un contract emite NFTs mediante safeMint, el estándar exige invocar una función de confirmación en el smart contract receptor. Si hacés un batch mint (digamos, 10 tokens de un saque) y actualizás el contador del balance global después de enviar cada token (o mediante un loop dentro de una llamada externa), el atacante te intercepta el control en la tercera iteración, justo cuando el array interno de tokens está a medio llenar pero el contador de transacciones cree que el proceso recién arranca. Resultado de todo esto: colisión de índices y creación infinita de activos de la nada misma.

¿Qué deberíamos hacer nosotros, ustedes y toda la industria al respecto?

Miren, yo mismo escribo código todos los días, pero confiar ciegamente en los modelos generativos en cripto es básicamente jugar a la ruleta rusa con el tambor totalmente cargado. Si usan IA para armar protocolos, grabénse a fuego estas tres reglas:

  • Nunca le crean a los modificadores que vienen en los templates. Si la IA les metió un nonReentrant, solo significa que conoce la palabra pero no entiende un pomo el contexto asincrónico de su blockchain.
  • Validen siempre el msg.sender en los callbacks. Interfaces como uniswapV2Call, onERC721Received o tokensReceived tienen que estar más blindadas que la entrada a un búnker militar.
  • Escriban sus propios fuzz tests en Foundry. Ningún test unitario estático va a cazar un reentrancy cross-function con tanta efectividad como un par de millones de corridas aleatorias basadas en invariantes.

Mientras me desato hablando de estas vulnerabilidades, no me puedo quitar una idea de la cabeza: al final, fuimos nosotros mismos los que ablandamos el mercado. Nos desacostumbramos a pensar y empezamos a delegarle tareas rutinarias de código a la IA, olvidando por completo que blockchain no perdona errores de compilación lógica. En el desarrollo web tradicional puedes hacer rollback de un commit, sacar un parche al vuelo y pedirle perdón a los usuarios por cinco minutos de downtime. Pero en el entorno EVM, tu bug se queda grabado para siempre en un ledger inmutable como un monumento a la negligencia humana y al triunfo de la ceguera algorítmica.

Vamos a darle cierre a este tema y analicemos otro vector poco obvio que ChatGPT escupe con una constancia que da miedo.

Caso N.° 6: Agregadores de liquidez y shares virtuales en pools de stablecoins

Esta es la última moda entre los devs de DeFi: armar pools de swap customizados con cálculo dinámico de pesos de tokens basados en curvas de producto constante. A las redes neuronales les encanta esta matemática porque internet está lleno de terabytes de código open source de Curve y Uniswap v2. El modelo agarra la fórmula, la adapta a tus requerimientos, le inyecta una función addLiquidity y te reporta orgulloso que el producto ya está listo para el deploy.

El problema es que el modelo no tiene la más puta idea de cómo funciona el cálculo asíncrono de balances virtuales cuando llamas a tokens externos con comisiones flotantes (como USDT o tokens deflationary customizados con impuesto por transferencia).

Mírale el parche a este patrón que la IA considera infalible:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
interface ITokenWithFee {
    function transferFrom(address sender, address recipient, amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}
contract AIWarpPool {
    mapping(address => uint256) public userShares;
    uint256 public totalShares;
    
    // La IA cree que si primero calculamos el balance y después escribimos en el mapping, estamos a salvo
    function deposit(address token, uint256 amount) external {
        uint256 balanceBefore = ITokenWithFee(token).balanceOf(address(this));
        
        // Llamada externa de transferencia de token
        bool success = ITokenWithFee(token).transferFrom(msg.sender, address(this), amount);
        require(success, "Transfer failed");
        
        uint256 balanceAfter = ITokenWithFee(token).balanceOf(address(this));
        uint256 realReceived = balanceAfter - balanceBefore;
        // Emisión de shares en base a los fondos que cayeron realmente
        uint256 shares = totalShares == 0 ? realReceived : (realReceived * totalShares) / balanceBefore;
        
        userShares[msg.sender] += shares;
        totalShares += shares;
    }
    function withdraw(uint256 shares) external {
        require(userShares[msg.sender] >= shares, "Not enough shares");
        
        uint256 amountToReturn = (shares * ITokenWithFee(msg.sender).balanceOf(address(this))) / totalShares;
        
        // Primero achicamos el state...
        userShares[msg.sender] -= shares;
        totalShares -= shares;
        // ...y después largamos la guita. ¡El clásico Checks-Effects-Interactions!
        // Pero, un momento...
        (bool success, ) = msg.sender.call{value: 0}(""); // Llamada externa condicional de transferencia de token
        require(success, "Withdraw failed");
    }
}

¿Viste la trampa? La red neural cumplió religiosamente con el orden de Checks-Effects-Interactions dentro de la función withdraw. Pero dejó totalmente de lado que el cálculo de amountToReturn se amarra al balanceOf(address(this)) actual justo en el momento de la llamada. Si un atacante arma un contrato wrapper que, al recibir el control (o mediante el callback del token), dispara una reentrancy en withdraw antes de que cambie el totalShares global en otro pool conectado o a través de una llamada cross-contract de un oráculo, termina calculando la proporción con un valor de liquidez obsoleto.

El checklist definitivo para revisar lo que te escribió la IA

Ya basta de tropezar con la misma piedra creyendo que una red neural sabe más que vos de arquitectura de sistemas distribuidos. Antes de mandar ese código generado a producción o incluso a una testnet con plata de verdad, pasale este filtro:

  • Aisla cualquier función de callback como si adentro tuviera sentado a un hacker con un exploit fresquito listo para correr.
  • Revisa cada llamada externa para ver si puede devolverle el control al contrato en un punto donde los invariantes globales todavía no cierran.
  • Olvídate de la fe ciega en los modifiers — solo te salvan de una reentrancy directa en la misma función, pero son completamente inútiles frente a cadenas cross-contract retorcidas.

FAQ

El modelo se basa en la coincidencia de patrones estáticos y falla al rastrear cambios de estado asíncronos entre contratos, dejando los invariantes globales rotos antes de que se ejecuten las llamadas externas.

Los desarrolladores deben implementar estrictamente patrones checks-effects-interactions en todos los módulos interconectados, aislar las funciones de devolución de llamada internas y desplegar fuzzing de invariantes automatizado mediante Foundry en lugar de confiar en modificadores básicos como nonReentrant.

Los grandes modelos de lenguaje no tienen en cuenta los cálculos de saldo virtual ni las actualizaciones de estado prematuras que permiten a los contratos maliciosos reingresar a la lógica central durante las devoluciones de llamada de tokens intermedias.
Oleg Filatov

As the Chief Technology Officer at EXMON Exchange, I focus on building secure, scalable crypto infrastructure and developing systems that protect user assets and privacy.

With over 15 years in cybersecurity, blockchain, and DevOps, I specialize in smart contract analysis, threat modeling, and secure system architecture.

At EXMON Academy, I share practical insights from real-world...

...

Escribe una opinión

Tu correo electrónico no será publicado. Los campos obligatorios están marcados *