Это 5 строк кода, которые хакер использовал, чтобы получить контракт «DAO» почти на 50 миллионов долларов эфира, что привело к философским вопросам о будущем цепочки Ethereum и разделении сообщества.

И всего этого можно было бы избежать, изменив 3 строчки кода.

Transfer(msg.sender, 0, balances[msg.sender]);
withdrawRewardFor(msg.sender);
totalSupply -= balances[msg.sender]; 
balances[msg.sender] = 0; 
paidOut[msg.sender] = 0;

Сегодня мы узнаем об очень важной теме, которая необходима для разработки смарт-контрактов, понимания порядка выполнения кода и резервных функций, но прежде немного истории:

Взлом ДАО

(Подробнее статья об эксплойте: https://hackingdistributed.com/2016/06/18/analysis-of-the-dao-exploit/)

DAO была «DAO» (децентрализованной автономной организацией), очень известной в экосистеме Ethereum в 2016 году, которая пострадала от огромной уязвимости, которую позже назовут атакой с повторным входом, с которой Ethereum Foundation поднял вопрос о том, что делать с Это, Хакер не смог вывести все средства, группа белых шляп увидела, что происходит, и провела ту же атаку, чтобы позже передать средства их соответствующим владельцам. В тот момент, когда средства, которые были отозваны из контракта, составляли ~5% всего эфира в экосистеме, Ethereum Foundation предложил провести хард-форк и «стереть» транзакции, которые были совершены хакером.

Эти идеи вызвали у некоторых людей страх перед долгосрочной перспективой проекта, идея неизменного блокчейна, который нельзя было изменить, была отвергнута, 85% узлов согласились провести форк, создав Ethereum таким, каким мы все его знаем сегодня, и ноды, которые этого не сделали, которые остались с теми же ценностями, которые мы называем сегодня «пуристами», которые думают, что «код — это закон», создали Ethereum Classic «настоящим» Ethereum.

И всего этого можно было бы избежать, переупорядочив код:

totalSupply -= balances[msg.sender]; 
balances[msg.sender] = 0; 
paidOut[msg.sender] = 0;

Transfer(msg.sender, 0, balances[msg.sender]);
withdrawRewardFor(msg.sender);
// totalSupply -= balances[msg.sender]; 
// balances[msg.sender] = 0; 
// paidOut[msg.sender] = 0;

Давайте узнаем, почему это могло сработать и как защитить нас от этих атак в наших смарт-контрактах:

При создании какой-либо логики отправки эфира мы не можем предполагать, что получателем может быть только EOA (обычный кошелек), мы ДОЛЖНЫ предполагать, что и другие смарт-контракты будут вызывать наши функции и думать соответственно, в них есть концепция резервных функций. смарт-контракты, такие как receive() и fallback(), это диаграмма того, когда эти функции вызываются:

Это полезно, когда есть какой-то код, который вы не реализовали, например, если пользователь вызывает функцию, которой не существует, и отправляет 10 Ether, резервный вариант может быть запрограммирован на отправку этих средств обратно в msg.sender, чтобы не вызывать никаких проблем. , и в этом проблема этих функций, вы можете кодировать что угодно, и это может быть вредоносным.

Атака с повторным входом

Давайте рассмотрим пример уязвимого кода, этот код был взят с https://ethernaut.openzeppelin.com для повторного входа:

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

Порядок выполнения этой функции remove() следующий:

Если звонил обычный EOA (кошелек):

  1. Оператор if проверяет, равен ли баланс отправителя в контракте или больше, чем сумма, которую он хочет снять.
  2. Выполните вызов функции низкого уровня для отправки эфира.
  3. Проверяет успешность перевода средств, если верно:
  4. Обновить баланс отправителя в договоре

Если вызывающий объект был контрактом с резервной функцией:

  1. Оператор if проверяет, равен ли баланс отправителя в контракте или больше, чем amount, который он хочет снять.
  2. Выполните вызов функции низкого уровня для отправки эфира.
  • Это самое интересное, функция не следует за исполнением, из-за этой передачи Эфира в контракт будет вызвана фолбэк-функция, и давайте разберем ситуацию:

На этом этапе контракт получил эфир, который мы хотели, но контракт с уязвимостью еще не обновил наш баланс, и контракт вызовет fallback(), который мы специально закодировали для выполнения remove().

Это создаст цикл, вызывающий вывод каждый раз, когда мы получаем эфир:

И всякий раз, когда мы решили остановиться, мы просто даем завершиться резервной функции и позволяем функциям вывода завершить выполнение, к концу всего у нас будет весь эфир, который был в контракте жертвы.

Итак, как мы можем решить эту проблему?

в этом примере изменение места одной строки:

function withdraw(uint _amount) public {
   if(balances[msg.sender] >= _amount) {

     // We change first the balance
     balances[msg.sender] -= _amount;

     (bool result,) = msg.sender.call{value:_amount}("");

     // requiring that the transaction is succesful, if not, reverse
     require(result, "A problem ocurred")

     // if(result) {
     //   _amount;
     // }
     // balances[msg.sender] -= _amount;
   }
}

Таким образом, если есть злоумышленник, который попытается провести эту атаку, к тому времени, когда он использует смарт-контракт, баланс уменьшится, и он не сможет провести атаку.

Это не единственное решение, я бы рекомендовал использовать другие более безопасные методы:

ReentrancyGuard: решение для повторного входа.

PullPayment: решение, которое не взаимодействует с учетной записью получателя, последнее считается лучшей практикой, когда речь идет об отправке эфира с точки зрения безопасности. Это не позволяет получателям блокировать выполнение и устраняет проблемы с повторным входом.

Я надеюсь, что вы узнали что-то сегодня, не стесняйтесь задавать вопросы, мы все здесь, чтобы учиться :)