- PVSM.RU - https://www.pvsm.ru -
Представляем третью часть цикла, посвященного типичным уязвимостям, атакам и проблемным местам, присущим смарт-контрактам на языке Solidity, и платформе Ethereum в целом. Здесь поговорим о том, какими особенностями обладает Solidity и какими уязвимостями они могут обернуться в умелых руках.
В первой [1] части мы обсудили front-running attack, различные алгоритмы генерации случайных чисел и отказоустойчивость сети с Proof-of-Authority консенсусом. Во второй [2] говорили об Integer overflow, ABI encoding/decoding, Uninitialized storage pointer, Type Confusion и о том, как сделать бэкдор. А в этой части мы обсудим несколько отличительных особенностей Solidity и посмотрим на логические уязвимости, которые могут встретиться в контрактах.
Начнем с того, как смарт-контракты обмениваются друг с другом ценностями и пользовательскими адресами. В начале эфиры передавались посредством вызова другого контракта:
msg.sender.call.value(42) // или вот так msg.sender.call.value(42)()
Однако, при вызове контракта без указания сигнатуры будет вызвана его fallback-функция, в которой может быть произвольный код. Такая непривычная логика работы приводила к знаменитой reentrancy, с помощью которой была взломана TheDAO [3].
После этого появилась функция send
, которая тоже представляет собой просто синтаксический сахар, — под капотом у нее тот же call, только количество газа ограничено, поэтому reentrancy уже провернуть не получится.
msg.sender.send(42) // msg.sender.call.value(42).gas(2300)() - намного лучше, правда?
Однако, если что-то пойдет не так, и эфир отправить не получится, то send не будет прерывать поток исполнения. Такое поведение также может быть критично. Например, эфир не отправили, а состояние контракта уже поменяли. Кто-то останется без эфиров [4].
Поэтому появилась transfer, и она вызовет исключение, если что-то пойдет не так.
msg.sender.transfer(42) // if (!msg.sender.send(42)) revert()
Но и она не является серебряной пулей. Представим, что у нас есть массив адресов, на которые надо разослать эфир, и если использовать transfer
, то успех целой операции будет зависеть от каждого из получателей, — если кто-то один не примет эфир, тогда все изменения полностью откатятся.
И последний момент с отправкой эфира — функция selfdestruct
.
selfdestruct(where)
На самом деле, это функция для уничтожения контракта, но весь эфир, который остался на контракте, будет отправлен на тот адрес, который указан как аргумент. Причем этого никак нельзя избежать — эфир уйдет, даже если принимающий адрес — это контракт, и fallback-функция у него не payable
(fallback попросту не вызывается). Эфир будет отправлен даже на еще не созданный [5] контракт!
В Solidity, для разрешения множественного наследования, используется алгоритм C3 линерализации [6] (то же, что и в 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 [7].
Смарт-контракты пишут люди, а люди часто ошибаются… в названии переменных, конструкторов [8]; забывают ограничить доступ к каким-то функциям (как, например, в Parity Multisig [9]) и др. Также разработчик должен внимательно следить за возможным наступлением состояния гонки, поскольку любая функция смарт-контракта может быть вызвана с любого адреса, в любое время. Он должен сам реализовать необходимые примитивы синхронизации и модификаторы доступа, для того чтобы смарт-контракт мог контролировать очередность вызова. Кроме того, есть вещи, которые не сможет найти ни один анализатор кода, — ошибки предметной области. Поэтому в данном разделе будут рассмотрены авторские уязвимости.
В подавляющем большинстве контрактов, которым нужно работать с математикой, например, рассчитывать, сколько токенов получит пользователь за присланый эфир, применяется библиотека SafeMath [10]. Однако, название может быть обманчивым — на самом деле, SafeMath заботится лишь о переполнениях [2]. Предлагаем рассмотреть следующий кусочек контракта:
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 [11].
За таким длинным названием скрывается забавная уязвимость, обнаруженная при аудите контрактов 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 или С для того, чтобы проголосовать в голосовании Х второй раз. Фикс и оригинал отчета [12], а также другие уязвимости.
Прямо сейчас у вас могут резонно возникнуть два вопроса:
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 после того, как узнает в чате [15] ли на stackoverflow, что массивы — это дорого :)
Следующая уязвимость относится, скорее, к незнанию/непониманию значений глобальных переменных. Предлагаем самостоятельно взглянуть на одну из возможных имплементаций схемы commit-reveal [1]:
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 контролируется отправителем и означает оставшийся [16] газ. Таким образом атакующий, манипулируя газом транзации и его ценой, может вывести все средства из контракта.
В этой статье мы рассмотрели лишь несколько логических уязвимостей для того, чтобы сформировать у читателя интуицию относительно мест, где можно ошибиться при написании смарт-контрактов. На самом деле, таких логических (авторских) уязвимостей в контрактах больше всего. Связаны они, прежде всего, с бизнес-логикой или предметной областью. Также это говорит о том, что большинство уязвимостей в контрактах не может быть обнаружено автоматическими средствами, по-крайней мере, пока они не начнут позволять пользователю описывать критерии "неправильного поведения". Кстати, в следующей части мы рассмотрим, какие инструменты все же существуют, и на что они годятся в их текущем состоянии.
P.S. Выражаю благодарность Raz0r [17] за пример Generous refund :)
Автор: p4lex
Источник [18]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/informatsionnaya-bezopasnost/275118
Ссылки в тексте:
[1] первой: https://habrahabr.ru/company/dsec/blog/345196/
[2] второй: https://habrahabr.ru/company/dsec/blog/346408/
[3] TheDAO: http://hackingdistributed.com/2016/06/18/analysis-of-the-dao-exploit/
[4] Кто-то останется без эфиров: https://github.com/pertsev/solidity_tricks/tree/master/MoneyTransfer#gasless-send
[5] не созданный: https://ethereum.stackexchange.com/questions/760/how-is-the-address-of-an-ethereum-contract-computed
[6] C3 линерализации: https://en.wikipedia.org/wiki/C3_linearization
[7] Underhanded Solidity Coding Contest: https://pdaian.com/blog/solidity-anti-patterns-fun-with-inheritance-dag-abuse/
[8] конструкторов: https://www.reddit.com/r/ethereum/comments/4omdlf/to_kickstart_the_building_safer_smart_contracts/
[9] Parity Multisig: https://youtu.be/P8_oh1zjA84?t=1786
[10] SafeMath: https://github.com/OpenZeppelin/zeppelin-solidity/blob/master/contracts/math/SafeMath.sol
[11] solidity_tricks: https://github.com/pertsev/solidity_tricks/tree/master/ImplicitMath
[12] Фикс и оригинал отчета: https://github.com/poanetwork/wiki/wiki/POA-Network-Contract-Audit#35-medium-priority
[13] голосование: https://github.com/poanetwork/poa-network-consensus-contracts/blob/31209cd154d35f2040ff2b894e8b98b801b416de/contracts/VotingToChangeKeys.sol#L54
[14] проверки: https://github.com/poanetwork/poa-network-consensus-contracts/blob/31209cd154d35f2040ff2b894e8b98b801b416de/contracts/VotingToChangeProxyAddress.sol#L174
[15] чате: https://t.me/eth_ru
[16] оставшийся: http://solidity.readthedocs.io/en/v0.4.20/units-and-global-variables.html?highlight=msg.gas
[17] Raz0r: https://habrahabr.ru/users/raz0r/
[18] Источник: https://habrahabr.ru/post/347110/?utm_campaign=347110
Нажмите здесь для печати.