Reentrancy attack


Entre las vulnerabilidades más comunes y peligrosas que puedes tener al desarrollar smart contracts, se encuentra el ataque de reentrada. Este ataque fue famoso por el hackeo de The DAO en 2016 y sigue siendo una amenaza relevante.

¿Qué es un ataque de reentrada?

Imagina que tu contrato inteligente tiene una función para retirar fondos. Un ataque de reentrada ocurre cuando un contrato malicioso (o un atacante externo) llama recursivamente a tu función de retiro antes de que la primera llamada haya terminado completamente. Esto puede llevar a que tu contrato envíe más fondos de los previstos, ¡incluso drenando todos los fondos!

¿Cómo ocurre?

Piensa en este escenario vulnerable:

pragma solidity ^0.8.0;

contract BancoVulnerable {
 mapping(address => uint) public balances;

 constructor() {
  balances[msg.sender] = 10 ether; // Inicializamos con fondos
 }

 function retirar(uint _cantidad) public {
  require(balances[msg.sender] >= _cantidad, "Fondos insuficientes");

  // ¡VULNERABLE! Primero enviamos los fondos,
  // luego actualizamos el balance
  (bool success, ) = msg.sender.call{value: _cantidad}("");
  require(success, "Transferencia fallida");

  // Actualizamos el balance DESPUÉS del envío
  balances[msg.sender] -= _cantidad; 
 }
}

En este ejemplo, un atacante podría crear un contrato que, al recibir fondos (receive() o fallback()), vuelve a llamar a la función retirar() del contrato BancoVulnerable. Como el balance no se ha actualizado aún en la primera llamada, el contrato vulnerable volverá a enviar fondos, y así sucesivamente.

Pasos del ataque:

  1. Contrato Atacante: El atacante despliega un contrato malicioso con una función receive() o fallback().
  2. Llamada Inicial: El contrato atacante llama a retirar() en BancoVulnerable.
  3. Transferencia y Reentrada: BancoVulnerable envía Ether al contrato atacante. La función receive()/fallback() del contrato atacante se ejecuta.
  4. Llamada Recursiva: Dentro de receive()/fallback(), el contrato atacante vuelve a llamar a retirar() en BancoVulnerable.
  5. Explotación: Como el balance aún no se ha actualizado, BancoVulnerable cree que el atacante todavía tiene fondos disponibles y vuelve a enviar Ether. Este ciclo se repite hasta agotar los fondos o el gas.

¿Cómo protegerse?

Existen varias estrategias para prevenir ataques de reentrada:

  1. Patrón Checks-Effects-Interactions (CEI): Este es el método más recomendado. Organiza tu código en este orden:

    • Checks (Verificaciones): Realiza todas las validaciones (require, assert) al principio.
    • Effects (Efectos): Actualiza el estado del contrato (balances, variables, etc.).
    • Interactions (Interacciones): Realiza llamadas externas a otros contratos o envíos de Ether (call, transfer, send).

    Ejemplo CEI aplicado a BancoVulnerable:

    pragma solidity ^0.8.0;
    
    contract BancoSeguro {
     mapping(address => uint) public balances;
    
     constructor() {
      balances[msg.sender] = 10 ether;
     }
    
     function retirar(uint _cantidad) public {
     // Checks (Verificaciones)
     require(balances[msg.sender] >= _cantidad, "Fondos insuficientes");
    
     // Effects (Efectos)
     balances[msg.sender] -= _cantidad; 
    
     // Interactions (Interacciones)
     (bool success, ) = msg.sender.call{value: _cantidad}("");
     require(success, "Transferencia fallida");
     }
    }
    

    Al actualizar el balance antes de la transferencia, evitamos que la reentrada explote la lógica del contrato.

  2. Uso de transfer() o send() (con precaución): Estas funciones limitan la cantidad de gas que se reenvía a la llamada externa.
    Aunque históricamente se usaban, no son la solución principal hoy en día por problemas con los costos de gas y la posibilidad de que las llamadas fallen por falta de gas, incluso en escenarios legítimos. Es preferible CEI.

  3. Reentrancy Guard: Implementa un “candado” en tu contrato. Antes de ejecutar una función susceptible a reentrada, se activa el candado.
    Si se intenta volver a entrar a la función mientras el candado está activo, la llamada fallará.

    📰

    Una variante avanzada del Reentrancy Guard puede implementarse utilizando transient storage. En lugar de usar una variable de estado persistente (bool private reentrancyLock = false), se puede usar una variable en transient storage.

    Ejemplo con Reentrancy Guard:

    pragma solidity ^0.8.0;
    
    contract BancoProtegido {
     mapping(address => uint) public balances;
     bool private reentrancyLock = false; // Candado
    
     constructor() {
      balances[msg.sender] = 10 ether;
     }
    
     modifier noReentrant() { // Modificador para proteger funciones
      require(!reentrancyLock, "Reentrada detectada");
      reentrancyLock = true;
      _; // Ejecuta la función original
      reentrancyLock = false; // Libera el candado
     }
     // Aplicamos el modificador
     function retirar(uint _cantidad) public noReentrant { 
      require(balances[msg.sender] >= _cantidad, "Fondos insuficientes");
    
      balances[msg.sender] -= _cantidad;
      (bool success, ) = msg.sender.call{value: _cantidad}("");
      require(success, "Transferencia fallida");
     }
    }