Данным постом начинаю цикл статей на тему безопасности смарт-контрактов Ethereum. Считаю эту тему весьма актуальной, так-как количество разработчиков лавинообразно растет, а уберечь от «граблей» — некому. Пока — переводы…

1. Сканирование Live Ethereum контрактов на ошибку «Unchecked-Send»


Оригинал — Scanning Live Ethereum Contracts for the «Unchecked-Send...»


Авторы: Zikai Alex Wen и Andrew Miller

Программирование смарт-контрактов в Ethereum, как известно, подвержено ошибкам [1] . Недавно мы увидели, что несколько
высококлассных смарт-контрактов, таких как King of the Ether и The DAO-1.0, содержали уязвимости, вызванные ошибками программирования.

Начиная с марта 2015 года программисты смарт-контрактов были предупреждены о конкретных опасностях программирования, которые могут возникнуть, когда контракты отправляют сообщения друг другу [6]

В нескольких руководствах по программированию содержится рекомендация, как избежать распространенных ошибок (в официальных документах Ethereum [3] и в независимом руководстве от UMD [2] ). Хотя эти опасности достаточно понятны, чтобы избегать их, последствия такой ошибки являются ужасными: деньги могут быть заблокированы, потеряны или украдены.

Насколько распространены ошибки, возникающие в результате этих опасностей? Есть ли еще уязвимые, но живые контракты на block-chain Ethereum? В этой статье мы отвечаем на этот вопрос, анализируя контракты на живом block-chain Ethereum с помощью нового инструмента анализа, который мы разработали.


Что такое ошибка «unchecked-send»?


Для отправки контрактом эфира на другой адрес, самым простым способом является использование ключевого слова send. Это действует как метод, определенный для каждого объекта. Например, следующий фрагмент кода может быть найден в смарт-контракте, который реализует настольную игру.


 /*** Listing 1 ***/ 
if (gameHasEnded && !( prizePaidOut ) ) {
  winner.send(1000); // отправить выигрыш победителю
  prizePaidOut = True;
}

Проблема здесь в том, что метод send  может выполниться с ошибкой. Если он не сработает, то победитель не получит деньги, однако переменная prizePaidOut будет установлена в True.

Существуют два разных случая, когда функция winner.send() может выйти из строя. Мы разберем различие между ними позже. Первый случай заключается в том, что адрес winner — это контракт (а не учетная запись пользователя), а код этого контракта генерирует исключение (например, если он использует слишком много «газа»). Если это так, то, возможно, в этом случае это «ошибка победителя». Второй случай менее очевиден. Виртуальная машина Ethereum имеет ограниченный ресурс, называемый «callstack» (глубина стека вызовов), и этот ресурс может быть использован другим кодом контракта, который был выполнен ранее в транзакции. Если callstack  уже израсходован к моменту выполнения команды send , выполнение команды потерпит неудачу, независимо от того, как определен winner. Приз победителя будет уничтожен не по его вине! 



Как можно избежать этой ошибки?

В документации Ethereum содержится краткое предупреждение об этой потенциальной опасности [3] :"Есть некоторая опасность при использовании send — передача завершается с ошибкой, если глубина стека вызовов составляет 1024 (это всегда может быть вызвано вызывающим), и также терпит неудачу, если у получателя заканчивается «газ». Поэтому, чтобы обеспечить безопасную передачу эфира, всегда проверяйте возвращаемое значение send или даже лучше: используйте шаблон, в котором получатель изымает деньги."

Два предложения. Первое — проверить возвращаемое значение send, чтобы убедиться, успешно ли оно завершено. Если это не так, то генерируйте исключение, чтобы откатить состояние назад.


  /*** Listing 2 ***/
if (gameHasEnded && !( prizePaidOut ) ) {
  if (winner.send(1000))
    prizePaidOut = True;
  else throw;
}

Это адекватное исправление для текущего примера, но не всегда это правильное решение. Предположим, мы модифицируем наш пример, чтобы, когда игра закончилась, победитель и проигравший откатили свое состояние назад. Очевидным применением «официального» решения было бы следующее:


/*** Listing 3 ***/
if (gameHasEnded && !( prizePaidOut ) ) {
  if (winner.send(1000) && loser.send(10))
    prizePaidOut = True;
  else throw; 
}

Однако это ошибка, поскольку она вводит дополнительную уязвимость. В то время как этот код защищает winner от атаки callstack, он также делает winner и loser уязвимыми друг для друга. В этом случае мы хотим предотвратить атаку callstack, но продолжаем выполнение, если команда send по какой-либо причине не сработает.

Поэтому даже лучшая передовая практика (рекомендованная в нашем «Руководстве программиста для Ethereum и Serpent», хотя она одинаково применима к Solidity), заключается в проверке наличия ресурса callstack. Мы можем определить макрос callStackIsEmpty (), который вернет ошибку, если и только если callstack пустой.


/*** Listing 4 ***/
if (gameHasEnded && !( prizePaidOut ) ) {
  if (callStackIsEmpty()) throw;
    winner.send(1000)
    loser.send(10)
    prizePaidOut = True;  
    }

Еще лучше рекомендация из документации Ethereum — «Использовать шаблон, в котором получатель забирает деньги», является немного загадочной, но имеет объяснение. Предложение состоит в том, чтобы реорганизовать ваш код, чтобы эффект неудачи send был изолирован, и влиял только на одного получателя за раз. Ниже приведен пример этого подхода. Однако этот совет также является анти-шаблоном. Он принимает на себя ответственность за проверку callstack самим получателям, что делает вероятными попадание в одну и ту же ловушку.


/*** Listing 5 ***/
if (gameHasEnded && !( prizePaidOut ) ) {
  accounts[winner] += 1000
  accounts[loser] += 10
  prizePaidOut = True;
  }
 ...
function withdraw(amount) {
if (accounts[msg.sender] >= amount) {
  msg.sender.send(amount);
  accounts[msg.sender] -= amount;
  }
}

Многие высокоразвитые интеллектуальные контракты уязвимы. Лотерея «Король Эфира Трона» — наиболее известный случай этой ошибки [4] . Эта ошибка не была замечена, пока сумму 200 эфиров (стоимостью более 2000 долларов США по сегодняшней цене) не смог получить законный победитель лотереи. Соответствующий код в King of the Ether похож на код в листинге 2 К счастью, в этом случае разработчик контракта смог использовать несвязанную функцию в контракте в качестве «ручного переопределения» для выпуска застрявших средств. Менее скрупулезный администратор мог бы использовать ту же функцию, чтобы украсть эфир!


Продолжение Сканирование Live Ethereum контрактов на ошибку «Unchecked-Send». Часть 2

Комментарии (12)


  1. Tsvetik
    21.11.2018 11:41

    Кому задачку?
    В некоторых смарт-контрактах ставят запрет на вызов функции другим смарт-контрактом. Делают это двумя способами. Один из этих способов не работает.
    Какие это способы?
    Какой из них не работает?
    Почему?


    1. ReklatsMasters
      22.11.2018 05:56

      Знаю только, что так можно проверить, что адрес это контракт. Если длина больше 0, то контракт.


      assembly {
             length := extcodesize(_addr)
          }


      1. Tsvetik
        22.11.2018 10:35

        Да, это один из способов и он как раз не работает. Есть второй.


        1. Luxo
          24.11.2018 09:54

          msg.sender == tx.origin

          а почему не работает extcodesize?


          1. Tsvetik
            24.11.2018 10:03

            Создайте контракт с 10 эфира с генератором случайных чисел на основе BLOCKHASH или TIMESTAMP. Защитите платящую функцию с помощью extcodesize, и тогда я покажу. Можете даже исходный код не публиковать. Только адрес


            1. Luxo
              24.11.2018 20:05

              Слипнется

              ответ: extcodesize возвращает ноль для контракта, который создаётся прямо сейчас


              1. Tsvetik
                24.11.2018 20:06

                +1


  1. koras1
    21.11.2018 20:00

    send давно пофиксили и рекомендуют использовать transfer.


    1. yumata Автор
      21.11.2018 20:06

      Тем не менее, думаю, что статья актуальна, как пример подхода к разработке — с учетом возможных уязвимостей.


  1. maxbaluev
    22.11.2018 12:05

    Нашёл уязвимость статьи в заголовке)


  1. Delics
    24.11.2018 10:22

    По-моему, вся проблема контрактов из примеров в том, что у них всего один флаг отправки, хотя отправок две. А когда в одном из примеров они всё-таки ввели два флага, то привязали их к одной проверке.

    Очевидное решение:

    if (gameHasEnded) 
    {
      if (!prizePaidOut && winner.send(1000))
      {
         prizePaidOut = True;
      }
    
      if (!loserPaidOut && loser.send(10))
      {
         loserPaidOut = True;
      }
      
      if (!prizePaidOut && !loserPaidOut)
      {
         throw; 
      }
    }


    1. Luxo
      24.11.2018 20:07

      Это небезопасный код из-за reentrancy