Attention! S in Ethereum stands for Security. Part 3. Solidity in practice


    Представляем третью часть цикла, посвященного типичным уязвимостям, атакам и проблемным местам, присущим смарт-контрактам на языке 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, чтобы привести к нужным единицам".


    $amount = (sum/5000000000000000) * 1000000000000000000$


    Но есть нюанс. Каждый вызов библиотеки (а их тут два), будет получать два 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 :)

    • +16
    • 2,9k
    • 4
    Digital Security 168,49
    Безопасность как искусство
    Поделиться публикацией
    Комментарии 4
    • 0
      В моем топе первое место- фокусы с неявным изменением внутренних переменных контракта в storage через опустошение массива или через struct вроде такого
      github.com/alatushkin/not-so-smart-contracts/blob/master/rewrite-variable-example.sol
    • 0

      Зарегистрировался сказать спасибо за эти три статьи. От себя бы добавил такой красивый трюк с вызовом чужого кода при помощи delegatecall.

      • 0
        Не совсем понял, в чем именно «трюк»?
        Что delegatecall работает с данными в контексте вызывающего контракта а не вызываемого? (но это «нормальное» поведение)

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое