Каждый, кто начинает свой путь в аудите и безопасности смарт контрактов, одной из первых атак изучает Реентранси, грубо говоря, повторное вхождение в одну функцию несколько раз.
Я уже не раз писал на канале о этом, находил сам в задачах и аудитах, но что-то никогда не задумывался, как она работает "под капотом".
И вот вчера, меня немного поставили в ступор простым кодом:
contract Wallet {
mapping(address => uint) public balances;
function deposit(address _to) public payable {
balances[_to] = balances[_to] + 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}("");
require(result, "External call returned false");
balances[msg.sender] -= _amount;
}
}
receive() external payable {}
}
contract AttacWallet {
uint constant SUM = 1 ether;
Wallet walletRe;
constructor(address payable _wallet) {
walletRe = Wallet(_wallet);
}
function depositAttack() public payable {
walletRe.deposit{value: SUM}(address(this));
}
function attack() external payable {
walletRe.withdraw(SUM);
}
receive() external payable {
if(address(walletRe).balance >= SUM) {
walletRe.withdraw(SUM);
}
}
}
Есть два контракта: Wallet и AttacWalet, второй предназначен для взлома первого.Вопрос был простой: "Почему не работает реентранси?".
И код написан правильно. Но вот, почему не проходит...
Я пошел сравнивать этот код с кодом примеров данной атаки, которые были в разных статьях в поиске гугл. Достаточно большое количество ресурсов, в том числе solidity-by-example, показывали пример атаки, где в итоге баланс пользователя обнуляется, а не минусуется, как в данном примере. Другими словами, вместо:
balances[msg.sender] -= _amount;
было
balances[msg.sender] = 0;
И вот если в данном контракте также обнулять контракт, то атака работает!
Мне потребовалось некоторое время и помощь коллег, чтобы разобраться с этим.
Скажу сразу, я не правильно понимал ход выполнения атаки реентранси. До сего дня я полагал, что функция будет заходить в withdraw(), доходить до call вызова, перебрасываться в контракт хакера в receive(), а оттуда снова в withdraw() и так по кругу, пока не будет выполнено условие if(address(walletRe).balance >= SUM) {}, после чего call наконец пройдет, и исполнение функции дойдет до balances[msg.sender] -= _amount.
Короче, это не так.
На самом деле функция заходит в некую рекурсию, т.е. выполняется withdraw() полностью, но в своем программном порядке.
Лучше всего о рекурсии можно узнать из этого видео, спасибо @elawbek за наводку!
Смотрите, что получается. Допустим у нас есть 10 Эфиров на контракте Wallet, которые мы хотим увести. На контракте хакера 1 Эфир для атаки.
Сначала мы делаем депозит и переводим 1 Эфир с контракта хакера, на контракт Wallet, и так теперь 11 Эфира. После чего мы запускаем функцию attack().
Мы попадаем в withdraw(), происходит рекурсия, которая под капотом исполняет данную функцию 11 раз, так как всего там 11 Эфира лежит. И когда рекурсия начинает "сворачиваться", то оказывается, что успешная пересылка Эфира прошла всего один раз, и тогда уже сработало balances[msg.sender] -= _amount. В каждый последующий раз мы вычитали 1 Эфир из balances[msg.sender], на котором уже был ноль. Происходило переполнение и call вызов возвращал "External call returned false".
Именно поэтому атака реентранси тут и стопорилась!
Но почему в некоторых статьях также можно встретить описание атаки, где присутствует balances[msg.sender] -= _amount? Как, например, тут.
Объяснение просто: все дело в pragma и версии Solidity. До 0.8 версии в математических расчетах не было проверки на overflow. Система не выдавала ошибку, когда программа пыталась вычесть какое-либо число из 0.
Кстати, задача Ethernaut именно на этом и построена!