Это 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 (кошелек):
- Оператор if проверяет, равен ли баланс отправителя в контракте или больше, чем сумма, которую он хочет снять.
- Выполните вызов функции низкого уровня для отправки эфира.
- Проверяет успешность перевода средств, если верно:
- Обновить баланс отправителя в договоре
Если вызывающий объект был контрактом с резервной функцией:
- Оператор if проверяет, равен ли баланс отправителя в контракте или больше, чем
amount
, который он хочет снять. - Выполните вызов функции низкого уровня для отправки эфира.
- Это самое интересное, функция не следует за исполнением, из-за этой передачи Эфира в контракт будет вызвана фолбэк-функция, и давайте разберем ситуацию:
На этом этапе контракт получил эфир, который мы хотели, но контракт с уязвимостью еще не обновил наш баланс, и контракт вызовет 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: решение, которое не взаимодействует с учетной записью получателя, последнее считается лучшей практикой, когда речь идет об отправке эфира с точки зрения безопасности. Это не позволяет получателям блокировать выполнение и устраняет проблемы с повторным входом.
Я надеюсь, что вы узнали что-то сегодня, не стесняйтесь задавать вопросы, мы все здесь, чтобы учиться :)