Представляем третью часть цикла, посвященного типичным уязвимостям, атакам и проблемным местам, присущим смарт-контрактам на языке Solidity, и платформе Ethereum в целом. Здесь поговорим о том, какими особенностями обладает Solidity и какими уязвимостями они могут обернуться в умелых руках.
В первой части мы обсудили front-running attack, различные алгоритмы генерации случайных чисел и отказоустойчивость сети с Proof-of-Authority консенсусом. Во второй говорили об Integer overflow, ABI encoding/decoding, Uninitialized storage pointer, Type Confusion и о том, как сделать бэкдор. А в этой части мы обсудим несколько отличительных особенностей Solidity и посмотрим на логические уязвимости, которые могут встретиться в контрактах.
Эволюция перевода ether
Начнем с того, как смарт-контракты обмениваются друг с другом ценностями и пользовательскими адресами. В начале эфиры передавались посредством вызова другого контракта:
msg.sender.call.value(42) // или вот так msg.sender.call.value(42)()
Однако, при вызове контракта без указания сигнатуры будет вызвана его fallback-функция, в которой может быть произвольный код. Такая непривычная логика работы приводила к знаменитой reentrancy, с помощью которой была взломана TheDAO.
После этого появилась функция send
, которая тоже представляет собой просто синтаксический сахар, — под капотом у нее тот же call, только количество газа ограничено, поэтому reentrancy уже провернуть не получится.
msg.sender.send(42) // msg.sender.call.value(42).gas(2300)() - намного лучше, правда?
Однако, если что-то пойдет не так, и эфир отправить не получится, то send не будет прерывать поток исполнения. Такое поведение также может быть критично. Например, эфир не отправили, а состояние контракта уже поменяли. Кто-то останется без эфиров.
Поэтому появилась transfer, и она вызовет исключение, если что-то пойдет не так.
msg.sender.transfer(42) // if (!msg.sender.send(42)) revert()
Но и она не является серебряной пулей. Представим, что у нас есть массив адресов, на которые надо разослать эфир, и если использовать transfer
, то успех целой операции будет зависеть от каждого из получателей, — если кто-то один не примет эфир, тогда все изменения полностью откатятся.
И последний момент с отправкой эфира — функция selfdestruct
.
selfdestruct(where)
На самом деле, это функция для уничтожения контракта, но весь эфир, который остался на контракте, будет отправлен на тот адрес, который указан как аргумент. Причем этого никак нельзя избежать — эфир уйдет, даже если принимающий адрес — это контракт, и fallback-функция у него не payable
(fallback попросту не вызывается). Эфир будет отправлен даже на еще не созданный контракт!
Наследование
В Solidity, для разрешения множественного наследования, используется алгоритм C3 линеаризации (тот же, что и в Python, например). И для тех, кто имел удачу не наступать на грабли множественного наследования, итоговый граф, скорее всего, покажется неочевидным. Рассмотрим на примере:
contract Grandfather {
bool public grandfatherCalled;
function pickUpFromKindergarten() internal {
grandfatherCalled = true;
}
}
contract Mom is Grandfather {
bool public momCalled;
function pickUpFromKindergarten() internal {
momCalled = true;
}
}
contract Dad is Grandfather {
bool public dadCalled;
function pickUpFromKindergarten() internal {
dadCalled = true;
super.pickUpFromKindergarten();
}
}
contract Son is Mom, Dad {
function sonWannaHome() public {
super.pickUpFromKindergarten();
}
}
Продолжите граф вызова, начиная от Son.sonWannaHome().
Будет вызван Dad, а затем Mom. Итого, наследование выглядит следующим образом.
Son -> Dad -> Mom -> Grandfather
Пример более-менее правдоподобного контракта с багом относительно множественного наследования был представлен на Underhanded Solidity Coding Contest.
Логические
Смарт-контракты пишут люди, а люди часто ошибаются… в названии переменных, конструкторов; забывают ограничить доступ к каким-то функциям (как, например, в Parity Multisig) и др. Также разработчик должен внимательно следить за возможным наступлением состояния гонки, поскольку любая функция смарт-контракта может быть вызвана с любого адреса, в любое время. Он должен сам реализовать необходимые примитивы синхронизации и модификаторы доступа, для того чтобы смарт-контракт мог контролировать очередность вызова. Кроме того, есть вещи, которые не сможет найти ни один анализатор кода, — ошибки предметной области. Поэтому в данном разделе будут рассмотрены авторские уязвимости.
Implicit math
В подавляющем большинстве контрактов, которым нужно работать с математикой, например, рассчитывать, сколько токенов получит пользователь за присланый эфир, применяется библиотека SafeMath. Однако, название может быть обманчивым — на самом деле, SafeMath заботится лишь о переполнениях. Предлагаем рассмотреть следующий кусочек контракта:
contract Crowdsale is Ownable {
using SafeMath for uint;
Token public token;
address public beneficiary;
uint public collectedWei;
uint public tokensSold;
uint public tokensForSale = 7000000000 * 1 ether;
uint public priceTokenWei = 1 ether / 200;
bool public crowdsaleFinished = false;
function purchase() payable {
require(!crowdsaleFinished);
require(tokensSold < tokensForSale);
require(msg.value >= 0.001 ether);
uint sum = msg.value;
uint amount = sum.div(priceTokenWei).mul(1 ether);
uint retSum = 0;
if(tokensSold.add(amount) > tokensForSale) {
uint retAmount = tokensSold.add(amount).sub(tokensForSale);
retSum = retAmount.mul(priceTokenWei).div(1 ether);
amount = amount.sub(retAmount);
sum = sum.sub(retSum);
}
tokensSold = tokensSold.add(amount);
collectedWei = collectedWei.add(sum);
beneficiary.transfer(sum);
token.mint(msg.sender, amount);
if(retSum > 0) {
msg.sender.transfer(retSum);
}
LogNewContribution(msg.sender, amount, sum);
}
}
Заметили что-нибудь подозрительное? Скорее всего нет, и это абсолютно нормально. Давайте разбираться. Обратите внимание на выражение sum.div(priceTokenWei).mul(1 ether)
— с точки зрения логики тут все очень гладко: "Чтобы получить объем токенов, которые нужно начислить инвестору, нужно поделить сумму эфиров на выражение, отражающее цену токена в единицах эфира, а затем умножить на 1 ether, чтобы привести к нужным единицам".
Но есть нюанс. Каждый вызов библиотеки (а их тут два), будет получать два uint и возвращать также uint, а это, в свою очередь, означает, что дробная часть первой операции совершенно легитимно будет отброшена.
// функция деления из библиотеки SafeMath
function div(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a / b;
return c;
}
Таким образом, присылая не целое число эфиров в этот crowdsale-контракт, инвестор будет терять токены, а ICO может собрать больше, чем ожидалось :D Полный контракт можно найти в solidity_tricks.
Multiple Voting Through Circular Mining History Manipulation
За таким длинным названием скрывается забавная уязвимость, обнаруженная при аудите контрактов PoA network. По правилам сети, в ней есть 12 или более валидаторов, которые могут проводить различные голосования, в том числе, на смену ключа (и, соответственно, адреса) валидатора. Для того, чтобы валидатор не смог сменить ключ и проголосовать дважды, смарт-контракт ведет историю всех ключей. И при валидации голоса проверяет, что среди проголосовавших нет его предка.
Итак, каждый раз, когда ключ меняется, он помещается в mapping, где ссылается на предыдущий ключ. Поэтому при каждой новой смене у контракта есть возможность пройтись по истории ключей. Однако в такой конфигурации, без дополнительных проверок, валидатор может зациклить историю ключей и обрезать тем самым старые ключи:
1) Валидатор с ключом A регистирует голосование X, затем запрашивает смену ключа. После этого он имеет на руках ключ B. Если он прямо сейчас попробует проголосовать своим новым ключом, то потерпит неудачу, поскольку ключ А есть в истории B:
History(B): B => A => 0x
2) Поэтому валидатор запрашивает смену ключа снова, получает ключ C. Опять же, прямо сейчас трюк не пройдет по той же причине:
History(C): C => B => A => 0x
3) Тогда валидатор запрашивает смену ключа С на ключ В. После этого история ключей зацикливается между B и С, и не содержит А:
History(B): B => C => B => C => B => ...
Теперь валидатор может использовать ключ B или С для того, чтобы проголосовать в голосовании Х второй раз. Фикс и оригинал отчета, а также другие уязвимости.
Прямо сейчас у вас могут резонно возникнуть два вопроса:
- Почему валидатор может так просто сменить свой ключ?
Ответ: На самом деле не просто, а через голосование. Однако, там проверки, что ключ уже был, не происходит (по-крайней мере, так считали авторы отчета). - Почему в теории бесконечная последовательность, полученная на этапе 3, не порождает бесконечный цикл при проверке (что должно приводить к out-of-gas исключению)?
Ответ: взгляните на функцию проверки
function areOldMiningKeysVoted(uint256 _id, address _miningKey) public view returns(bool) {
VotingData storage ballot = votingState[_id];
IKeysManager keysManager = IKeysManager(getKeysManager());
for (uint8 i = 0; i < maxOldMiningKeysDeepCheck; i++) {
address oldMiningKey = keysManager.miningKeyHistory(_miningKey);
if (oldMiningKey == address(0)) {
return false;
}
if (ballot.voters[oldMiningKey]) {
return true;
} else {
_miningKey = oldMiningKey;
}
}
return false;
}
В любом случае, размер цикла будет не более 256 повторений из-за того, что переменная i определена как uint8.
Реальная возможность эксплуатации данной уязвимости вызывает вопросы у автора, однако, она все же будет полезна тем, кто соберется хранить однонаправленный список в mapping после того, как узнает в чате ли на stackoverflow, что массивы — это дорого :)
Generous refund
Следующая уязвимость относится, скорее, к незнанию/непониманию значений глобальных переменных. Предлагаем самостоятельно взглянуть на одну из возможных имплементаций схемы commit-reveal:
pragma solidity ^0.4.4;
import 'common/Object.sol';
import 'token/Recipient.sol';
/**
* @title Random number generator contract
*/
contract Random is Object, Recipient {
struct Seed {
bytes32 seed;
uint256 entropy;
uint256 blockNum;
}
/**
* @dev Random seed data
*/
Seed[] public randomSeed;
/**
* @dev Get length of random seed data
*/
function randomSeedLength() constant returns (uint256)
{ return randomSeed.length; }
/**
* @dev Minimal count of seed data parts
*/
uint256 public minEntropy;
/**
* @dev Set minimal count of seed data
* @param _entropy Count of seed data parts
*/
function setMinEntropy(uint256 _entropy) onlyOwner
{ minEntropy = _entropy; }
/**
* @dev Put new seed data part
* @param _hash Random hash
*/
function put(bytes32 _hash) {
if (randomSeed.length == 0)
randomSeed.push(Seed("", 0, 0));
var latest = randomSeed[randomSeed.length - 1];
if (latest.entropy < minEntropy) {
latest.seed = sha3(latest.seed, _hash);
latest.entropy += 1;
latest.blockNum = block.number;
} else {
randomSeed.push(Seed(_hash, 1, block.number));
}
// Refund transaction gas cost
if (!msg.sender.send(msg.gas * tx.gasprice)) throw;
}
/**
* @dev Get random number
* @param _id Seed ident
* @param _range Random number range value
*/
function get(uint256 _id, uint256 _range) constant returns (uint256) {
var seed = randomSeed[_id];
if (seed.entropy < minEntropy) throw;
return uint256(seed.seed) % _range;
}
}
Обратили внимание на то, что смарт-контракт возвращает потраченый газ при коммите очередной части seed (см. функцию put)? Само по себе желание вернуть потраченную коммисию не вписывается в парадигму платформы Ethereum, но это еще не самое плохое. Уязвимость здесь в том, что значение msg.gas контролируется отправителем и означает оставшийся газ. Таким образом атакующий, манипулируя газом транзации и его ценой, может вывести все средства из контракта.
Вместо заключения
В этой статье мы рассмотрели лишь несколько логических уязвимостей для того, чтобы сформировать у читателя интуицию относительно мест, где можно ошибиться при написании смарт-контрактов. На самом деле, таких логических (авторских) уязвимостей в контрактах больше всего. Связаны они, прежде всего, с бизнес-логикой или предметной областью. Также это говорит о том, что большинство уязвимостей в контрактах не может быть обнаружено автоматическими средствами, по-крайней мере, пока они не начнут позволять пользователю описывать критерии "неправильного поведения". Кстати, в следующей части мы рассмотрим, какие инструменты все же существуют, и на что они годятся в их текущем состоянии.
P.S. Выражаю благодарность Raz0r за пример Generous refund :)