Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Говорят, что процедура является re-entrant, если ее выполнение может быть прервано в середине, инициировано заново, и оба запуска могут завершиться без каких-либо ошибок при выполнении. В контексте смарт-контрактов Ethereum повторный вход может привести к серьезным уязвимостям.
Самым известным примером этого был взлом DAO, в ходе которого был выведен эфир на сумму 70 миллионов долларов.
Так что же такое уязвимость повторного входа? Как это работает и как это предотвратить?
Механизм
Примером повторно входящего процесса может быть отправка электронной почты. Пользователь может начать вводить электронное письмо, сохранить черновик, отправить другое электронное письмо и закончить сообщение позже. Это безобидный пример. Однако представьте плохо построенную систему онлайн-банкинга для выдачи банковских переводов, в которой баланс счета проверяется только на этапе инициализации. Пользователь может инициировать несколько переводов, фактически не отправляя ни один из них. Банковская система подтвердит, что на счету пользователя имеется достаточный баланс для каждого отдельного перевода. Если во время фактической отправки не было дополнительной проверки, пользователь мог затем отправить все транзакции и потенциально превысить свой баланс. Это основной механизм эксплойта с повторным входом, который использовался в известном взломе DAO.
Пример из реальной жизни - взлом DAO
DAO это популярный децентрализованный инвестиционный фонд, основанный на смарт-контрактах. В 2016 году смарт-контракт DAO накопил эфир на сумму более 150 000 000 долларов (на тот момент). Если проект, запросивший финансирование, получил достаточную поддержку со стороны сообщества DAO, адрес Ethereum этого проекта мог вывести эфир из DAO. К сожалению для DAO, механизм перевода переводил эфир на внешний адрес, прежде чем обновлять его внутреннее состояние и отмечать, что баланс уже переведен. Это дало злоумышленникам возможность для вывода большего количества эфира, чем они имели право, путем повторного входа.
В взломе DAO воспользовались резервной функцией Ethereum для повторного входа. Каждый байт-код смарт-контракта Ethereum содержит так называемую резервную функцию по умолчанию, которая имеет следующую реализацию.
contract EveryContract {
function () public {
}
}
Эта функция по умолчанию может содержать произвольный код, если разработчик переопределяет реализацию по умолчанию. В случае переопределения функции, как payable, смарт-контракт может принимать эфир. Функция выполняется всякий раз, когда эфир передается в контракт (методы send(), transfer() и call()).
Помимо вызова payable методов, Solidity поддерживает три способа передачи эфира между кошельками и смарт-контрактами. Поддерживаемые методы передачи эфира — send(), transfer() и call.value(). Методы различаются тем, сколько газа они передают на передачу для выполнения других методов (в случае, если получателем является смарт-контракт), и тем, как они обрабатывают исключения. send() и call().value() просто вернут false в случае сбоя, но transfer() вызовет исключение, которое вернет всё в исходное состояние, до вызова функции. Эти методы кратко описаны ниже.
В случае со смарт-контрактом DAO (базовая версия которого представлена ниже) эфир передавался с помощью метода call.value().
contract BasicDao {
mapping (address => uint) public balances;
...
//transfer the entire balance of the caller of this function to the caller
function withdrawBalance() public {
bool result = msg.sender.call.value(balances[msg.sender]) ();
if(!result) {
throw;
}
//update balance of the withdrawer
balances[msg.sender] = 0;
}
}
Это позволяло передаче использовать максимально возможный лимит газа, а также предотвращало возврат состояния при возможных исключениях. Таким образом, злоумышленники смогли создать последовательность рекурсивных вызовов для вывода средств из DAO с помощью смарт-контракта, аналогичного представленному ниже.
contract Proxy {
//Owner's address
address public owner;
//Constructs the contract and stores the owner
constructor() public {
owner = msg.sender;
}
//Initiates the balance withdrawal
function callWithdrawBalance(address _address) public {
BasicDAO(_address).withdrawBalance();
}
//Fallback function for this contract.
//If the balance of this contract is less then 999999 Ether,
//triggers another withdrawal from the DAO.
function () public payable {
if (address(this).balance < 999999 ether) {
callWithdrawBalance(msg.sender);
}
}
//Allows the owner to get Ether from this contract
function drain() public {
owner.transfer(address(this).balance);
}
}
В результате получилась следующая последовательность действий (также изображенная ниже на рисунке 1):
1) Смарт-контракт прокси потребует законного вывода средств.
2) Переход с BasicDAO на смарт-контракт прокси вызвал резервную функцию.
3) Резервная функция прокси-смарт-контракта запросит у BasicDAO еще один вывод средств.
4) Переход с BasicDAO на смарт-контракт прокси вызвал резервную функцию.
5) Резервная функция прокси-смарт-контракта запросит у BasicDAO еще один вывод средств.
. . .
Обратите внимание, что баланс смарт-контракта прокси никогда не обновлялся (это происходит после переноса). Кроме того, если передача на прокси-контракт не завершается неудачей, исключение никогда не генерируется, и состояние никогда не возвращается.
Профилактика
Атаку повторного входа в контракт DAO можно было избежать несколькими способами. Использование функций send() или transfer() вместо call.value() не позволит выполнять рекурсивные вызовы на снятие средств из-за низкой стоимости за газ. Ручное ограничение количества газа, передаваемого в call.value(), приведет к тому же результату.
Тем не менее, существует гораздо более простая практика, которая делает любую повторную атаку невозможной. Обратите внимание, что контракт DAO обновляет баланс пользователя после передачи эфира. Если бы это было сделано до перевода, любой рекурсивный вызов вывода средств попытался бы перевести баланс в 0 эфиров. Этот принцип применяется в общем случае — если после передачи эфира или вызова внешней функции внутри метода не происходит обновления внутреннего состояния, метод защищен от уязвимости повторного входа.