Безопаность смарт-контрактов

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

Как и другие программы, смарт-контракт будет выполнять именно то, что написано, что не всегда соответствует замыслу программиста. Кроме того, все смарт-контракты являются публичными, и любой пользователь может взаимодействовать с ними, просто создав транзакцию. Любая уязвимость может быть использована, а потери почти всегда невозможно восстановить. Поэтому очень важно следовать лучшим практикам и использовать хорошо проверенные шаблоны проектирования.

Лучшие практики безопасности

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

Минимализм/простота

Сложность - враг безопасности. Чем проще код и чем меньше он делает, тем меньше вероятность возникновения ошибки или непредвиденного эффекта. Когда разработчики впервые приступают к программированию смарт-контрактов, у них часто возникает соблазн попытаться написать много кода. Вместо этого вам следует просмотреть код вашего смарт-контракта и попытаться найти способы сделать меньше, с меньшим количеством строк кода, меньшей сложностью и меньшим количеством "функций". Если кто-то говорит вам, что его проект создал "тысячи строк кода" для своих смарт-контрактов, вам следует усомниться в безопасности этого проекта. Проще - значит надежнее.

Повторное использование кода

Старайтесь не изобретать колесо. Если уже существует библиотека или контракт, который делает большую часть того, что вам нужно, используйте его повторно. В своем собственном коде следуйте принципу DRY: Не повторяйтесь. Если вы видите, что какой-то фрагмент кода повторяется более одного раза, спросите себя, можно ли написать его в виде функции или библиотеки и использовать повторно. Код, который уже многократно использовался и тестировался, скорее всего, более безопасен, чем любой новый код, который вы пишете. Остерегайтесь синдрома "здесь ничего не изобрели", когда вы поддаетесь искушению "улучшить" функцию или компонент, создав его с нуля. Риск безопасности часто превышает стоимость улучшения.

Качество кода

Код смарт-контракта неумолим. Каждая ошибка может привести к денежным потерям. Вы не должны относиться к программированию смарт-контрактов так же, как к программированию общего назначения. Написание DApps на Solidity не похоже на создание веб-виджета на JavaScript. Скорее, вы должны применять строгую методологию проектирования и разработки программного обеспечения, как в аэрокосмической технике или любой другой подобной неумолимой дисциплине. Как только вы "запустите" свой код, вы мало что сможете сделать, чтобы исправить любые проблемы.

Читаемость/аудируемость

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

Покрытие тестов

Тестируйте все, что можно. Умные контракты работают в публичной среде исполнения, где любой может выполнить их с любыми входными данными. Никогда не следует полагать, что входные данные, такие как аргументы функций, хорошо сформированы, правильно ограничены или имеют доброкачественное назначение. Проверьте все аргументы, чтобы убедиться, что они находятся в ожидаемых диапазонах и правильно отформатированы, прежде чем разрешить выполнение кода.

Риски и антипаттерны безопасности

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

Reentrancy

Одной из особенностей смарт-контрактов Ethereum является их способность вызывать и использовать код из других внешних контрактов. Контракты также обычно работают с эфиром и поэтому часто отправляют эфир на различные внешние адреса пользователей. Эти операции требуют от контрактов внешних вызовов. Эти внешние вызовы могут быть перехвачены злоумышленниками, которые могут заставить контракты выполнять дальнейший код (через функцию отката), включая вызовы обратно в себя. Атаки такого рода были использованы в печально известном взломе DAO. Более подробную информацию об атаках на реентерабельность можно найти в блоге Гуса Гимареаса, а также в документе Ethereum Smart Contract Best Practices.

Уязвимость

Этот тип атаки может возникнуть, когда контракт отправляет эфир на неизвестный адрес. Злоумышленник может тщательно сконструировать контракт на внешний адрес, который содержит вредоносный код в функции возврата. Таким образом, когда контракт отправляет эфир на этот адрес, он вызывает вредоносный код. Как правило, вредоносный код выполняет функцию на уязвимом контракте, выполняя операции, не предусмотренные разработчиком. Термин "реентерабельность" происходит от того, что внешний вредоносный контракт вызывает функцию на уязвимом контракте, и путь выполнения кода "входит" в него. Чтобы пояснить это, рассмотрим простой уязвимый контракт в EtherStore.sol, который действует как хранилище Ethereum, позволяющее вкладчикам снимать только 1 эфир в неделю. Пример 1. EtherStore.sol

contract EtherStore {

    uint256 public withdrawalLimit = 1 ether;
    mapping(address => uint256) public lastWithdrawTime;
    mapping(address => uint256) public balances;

    function depositFunds() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawFunds (uint256 _weiToWithdraw) public {
        require(balances[msg.sender] >= _weiToWithdraw);
        // limit the withdrawal
        require(_weiToWithdraw <= withdrawalLimit);
        // limit the time allowed to withdraw
        require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
        require(msg.sender.call.value(_weiToWithdraw)());
        balances[msg.sender] -= _weiToWithdraw;
        lastWithdrawTime[msg.sender] = now;
    }
 }

Этот контракт имеет две публичные функции, depositFunds и withdrawFunds. Функция depositFunds просто увеличивает баланс отправителя. Функция withdrawFunds позволяет отправителю указать сумму вэев для снятия. Эта функция будет успешной только в том случае, если запрашиваемая сумма для снятия меньше 1 эфира и снятие средств не происходило в течение последней недели.

Уязвимость находится в строке 17, где контракт отправляет пользователю запрошенное им количество эфира. Рассмотрим злоумышленника, который создал контракт в Attack.sol. Пример 2. Атака.sol

import "EtherStore.sol";

contract Attack {
  EtherStore public etherStore;

  // intialize the etherStore variable with the contract address
  constructor(address _etherStoreAddress) {
      etherStore = EtherStore(_etherStoreAddress);
  }

  function attackEtherStore() external payable {
      // attack to the nearest ether
      require(msg.value >= 1 ether);
      // send eth to the depositFunds() function
      etherStore.depositFunds.value(1 ether)();
      // start the magic
      etherStore.withdrawFunds(1 ether);
  }

  function collectEther() public {
      msg.sender.transfer(this.balance);
  }

  // fallback function - where the magic happens
  function () payable {
      if (etherStore.balance > 1 ether) {
          etherStore.withdrawFunds(1 ether);
      }
  }
}

Как может произойти эксплуатация? Во-первых, злоумышленник создаст вредоносный контракт (допустим, по адресу 0x0...123) с адресом контракта EtherStore в качестве единственного параметра конструктора. Это инициализирует и направит публичную переменную etherStore на атакуемый контракт.

Затем атакующий вызовет функцию attackEtherStore, имея некоторое количество эфира, большее или равное 1 - предположим, что на данный момент 1 эфир. В этом примере мы также предположим, что несколько других пользователей внесли эфир в этот контракт, так что его текущий баланс составляет 10 эфиров. Тогда произойдет следующее:

  1. Attack.sol, строка 15: Функция depositFunds контракта EtherStore будет вызвана с msg.value, равным 1 эфиру (и большим количеством газа). Отправителем (msg.sender) будет вредоносный контракт (0x0...123). Таким образом, остатки[0x0...123] = 1 эфир.
  2. Attack.sol, строка 17: Затем вредоносный контракт вызовет функцию withdrawFunds контракта EtherStore с параметром в 1 эфир. Это будет соответствовать всем требованиям (строки 12-16 контракта EtherStore), так как никаких предыдущих снятий средств не производилось.
  3. EtherStore.sol, строка 17: Контракт отправит 1 эфир обратно вредоносному контракту.
  4. Attack.sol, строка 25: После оплаты вредоносного контракта будет выполнена функция fallback.
  5. Attack.sol, строка 26: Общий баланс контракта EtherStore составлял 10 эфиров, а теперь составляет 9 эфиров, поэтому оператор if проходит.
  6. Attack.sol, строка 27: Функция fallback снова вызывает функцию EtherStore withdrawFunds и "снова входит" в контракт EtherStore.
  7. EtherStore.sol, строка 11: В этом втором вызове withdrawFunds баланс атакующего контракта все еще равен 1 эфиру, поскольку строка 18 еще не была выполнена. Таким образом, мы все еще имеем баланс[0x0..123] = 1 эфир. То же самое происходит и с переменной lastWithdrawTime. И снова мы выполняем все требования.
  8. EtherStore.sol, строка 17: Атакующий контракт забирает еще 1 эфир.
  9. Шаги 4-8 повторяются до тех пор, пока EtherStore.balance > 1, как диктует строка 26 в Attack.sol.
  10. Attack.sol, строка 26: Как только в контракте EtherStore останется 1 (или меньше) эфира, этот оператор if завершится неудачей. Это позволит выполнить строки 18 и 19 контракта EtherStore (для каждого вызова функции withdrawFunds).
  11. EtherStore.sol, строки 18 и 19: Будут установлены сопоставления balances и lastWithdrawTime, и выполнение завершится.

В итоге злоумышленник вывел из контракта EtherStore все эфиры, кроме 1, за одну транзакцию.

Профилактические методы

Существует ряд общих приемов, которые помогают избежать потенциальных уязвимостей реентерабельности в смарт-контрактах. Первая заключается в том, чтобы (по возможности) использовать встроенную функцию передачи при отправке эфира внешним контрактам. Функция передачи отправляет только 2300 газа при внешнем вызове, чего недостаточно для того, чтобы адрес назначения/контракт вызвал другой контракт (т. е. повторно ввел отправляющий контракт).

Вторая техника заключается в том, чтобы убедиться, что вся логика, изменяющая переменные состояния, происходит до отправки эфира из контракта (или любого внешнего вызова). В примере EtherStore строки 18 и 19 файла EtherStore.sol должны быть помещены перед строкой 17. Хорошей практикой является то, что любой код, выполняющий внешние вызовы по неизвестным адресам, должен быть последней операцией в локализованной функции или части выполнения кода. Это известно как паттерн "проверка-эффект-взаимодействие".

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

Применение всех этих методов (использование всех трех методов необязательно, но мы делаем это в демонстрационных целях) к EtherStore.sol дает контракт без реентерабельности:

contract EtherStore {

    // initialize the mutex
    bool reEntrancyMutex = false;
    uint256 public withdrawalLimit = 1 ether;
    mapping(address => uint256) public lastWithdrawTime;
    mapping(address => uint256) public balances;

    function depositFunds() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawFunds (uint256 _weiToWithdraw) public {
        require(!reEntrancyMutex);
        require(balances[msg.sender] >= _weiToWithdraw);
        // limit the withdrawal
        require(_weiToWithdraw <= withdrawalLimit);
        // limit the time allowed to withdraw
        require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
        balances[msg.sender] -= _weiToWithdraw;
        lastWithdrawTime[msg.sender] = now;
        // set the reEntrancy mutex before the external call
        reEntrancyMutex = true;
        msg.sender.transfer(_weiToWithdraw);
        // release the mutex after the external call
        reEntrancyMutex = false;
    }
 }

Пример реального мира: DAO

Атака на DAO (Децентрализованная автономная организация) была одним из крупных взломов, произошедших на ранних этапах развития Ethereum. В то время в контракте хранилось более 150 миллионов долларов. Реентерабельность сыграла важную роль в этой атаке, которая в конечном итоге привела к жесткому форку, в результате которого был создан Ethereum Classic (ETC). Хороший анализ эксплойта DAO см. на сайте http://bit.ly/2EQaLCI. Более подробную информацию об истории форков Ethereum, хронологии взлома DAO и рождении ETC в результате жесткого форка можно найти в [ethereum_standards].

Арифметические переполнения/недополнения

Виртуальная машина Ethereum определяет типы данных фиксированного размера для целых чисел. Это означает, что целочисленная переменная может представлять только определенный диапазон чисел. Например, uint8 может хранить только числа в диапазоне [0,255]. Попытка записать 256 в uint8 приведет к 0. Если не соблюдать осторожность, переменные в Solidity могут быть использованы, если пользовательский ввод не проверяется и выполняются вычисления, в результате которых получаются числа, лежащие вне диапазона типа данных, в котором они хранятся.

Дополнительную информацию об арифметических переполнениях/недополнениях см. в статьях "How to Secure Your Smart Contracts", Ethereum Smart Contract Best Practices и "Ethereum, Solidity и целочисленные переполнения: программирование блокчейн как 1970".

Уязвимость

Переполнение/недополнение происходит, когда выполняется операция, требующая от переменной фиксированного размера хранения числа (или части данных), которое находится за пределами диапазона типа данных переменной.

Например, вычитание 1 из переменной uint8 (беззнаковое целое число из 8 бит; т.е. неотрицательное), значение которой равно 0, приведет к числу 255. Это неполное переполнение. Мы присвоили число ниже диапазона uint8, поэтому результат обернется вокруг и даст наибольшее число, которое может хранить uint8. Аналогично, добавление 2^8=256 к uint8 оставит переменную без изменений, так как мы обернули всю длину uint. Двумя простыми аналогиями такого поведения являются одометры в автомобилях, которые измеряют пройденное расстояние (они сбрасываются на 000000, после того как превышено наибольшее число, т.е. 999999) и периодические математические функции (добавление 2π к аргументу sin оставляет значение неизменным).

Добавление чисел, превышающих диапазон типа данных, называется переполнением. Для наглядности добавление числа 257 к uint8, которое в данный момент имеет значение 0, приведет к числу 1. Иногда полезно думать о переменных фиксированного размера как о циклических, где мы начинаем отсчет с нуля, если прибавляем числа выше наибольшего возможного хранимого числа, и начинаем отсчет от наибольшего числа, если вычитаем из нуля. В случае знаковых типов int, которые могут представлять отрицательные числа, мы начинаем отсчет заново, как только достигаем наибольшего отрицательного значения; например, если мы попытаемся вычесть 1 из int8, значение которого равно -128, мы получим 127.

Подобные числовые неудачи позволяют злоумышленникам использовать код не по назначению и создавать неожиданные логические потоки. Например, рассмотрим контракт TimeLock в файле TimeLock.sol. Пример 3. TimeLock.sol

contract TimeLock {

    mapping(address => uint) public balances;
    mapping(address => uint) public lockTime;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
        lockTime[msg.sender] = now + 1 weeks;
    }

    function increaseLockTime(uint _secondsToIncrease) public {
        lockTime[msg.sender] += _secondsToIncrease;
    }

    function withdraw() public {
        require(balances[msg.sender] > 0);
        require(now > lockTime[msg.sender]);
        uint transferValue = balances[msg.sender];
        balances[msg.sender] = 0;
        msg.sender.transfer(transferValue);
    }
}

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

В случае, если пользователь вынужден передать свой закрытый ключ, подобный контракт может быть удобен для обеспечения недоступности эфира в течение короткого периода времени. Но если пользователь заблокировал 100 эфиров в этом контракте и передал свои ключи злоумышленнику, злоумышленник может использовать переполнение, чтобы получить эфиры, независимо от времени блокировки.

Атакующий может определить текущее время блокировки (lockTime) для адреса, для которого у него сейчас есть ключ (это открытая переменная). Назовем ее userLockTime. Затем можно вызвать функцию increaseLockTime и передать в качестве аргумента число 2^256 - userLockTime. Это число будет добавлено к текущему userLockTime и вызовет переполнение, сбросив lockTime[msg.sender] в 0. Затем злоумышленник может просто вызвать функцию withdraw, чтобы получить свое вознаграждение.

Давайте рассмотрим еще один пример (пример уязвимости Underflow из задачи Ethernaut), этот пример из задач Ethernaut.

SPOILER ALERT: Если вы еще не прошли испытания Ethernaut, здесь дается решение одного из уровней.

Пример 4. Пример уязвимости к переполнению из задачи Ethernaut

pragma solidity ^0.4.18;

contract Token {

  mapping(address => uint) balances;
  uint public totalSupply;

  function Token(uint _initialSupply) {
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }

  function balanceOf(address _owner) public constant returns (uint balance) {
    return balances[_owner];
  }
}

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

Изъян кроется в передаточной функции. Оператор require в строке 13 можно обойти, используя недополнение. Рассмотрим пользователя с нулевым балансом. Он может вызвать функцию transfer с любым ненулевым значением _value и обойти оператор require в строке 13. Это происходит потому, что balances[msg.sender] равен 0 (и uint256), поэтому вычитание любой положительной суммы (исключая 2^256) приведет к положительному числу, как описано ранее. Это справедливо и для строки 14, где на баланс будет зачислено положительное число. Таким образом, в данном примере злоумышленник может получить бесплатные токены благодаря уязвимости в неполном переполнении.

Профилактические методы

В настоящее время традиционная техника защиты от уязвимостей с занижением/переполнением заключается в использовании или создании математических библиотек, которые заменяют стандартные математические операторы сложения, вычитания и умножения (деление исключено, так как оно не вызывает переполнения и EVM возвращается к делению на 0).

Компания OpenZeppelin проделала большую работу по созданию и аудиту безопасных библиотек для сообщества Ethereum. В частности, ее библиотека SafeMath может быть использована для предотвращения уязвимостей недополнения/переполнения.

Чтобы продемонстрировать, как эти библиотеки используются в Solidity, давайте исправим контракт TimeLock с помощью библиотеки SafeMath. Версия контракта без переполнения имеет вид:

library SafeMath {

  function mul(uint256 a, uint256 b) internal pure returns (uint256) {
    if (a == 0) {
      return 0;
    }
    uint256 c = a * b;
    assert(c / a == b);
    return c;
  }

  function div(uint256 a, uint256 b) internal pure returns (uint256) {
    // assert(b > 0); // Solidity automatically throws when dividing by 0
    uint256 c = a / b;
    // assert(a == b * c + a % b); // This holds in all cases
    return c;
  }

  function sub(uint256 a, uint256 b) internal pure returns (uint256) {
    assert(b <= a);
    return a - b;
  }

  function add(uint256 a, uint256 b) internal pure returns (uint256) {
    uint256 c = a + b;
    assert(c >= a);
    return c;
  }
}

contract TimeLock {
    using SafeMath for uint; // use the library for uint type
    mapping(address => uint256) public balances;
    mapping(address => uint256) public lockTime;

    function deposit() external payable {
        balances[msg.sender] = balances[msg.sender].add(msg.value);
        lockTime[msg.sender] = now.add(1 weeks);
    }

    function increaseLockTime(uint256 _secondsToIncrease) public {
        lockTime[msg.sender] = lockTime[msg.sender].add(_secondsToIncrease);
    }

    function withdraw() public {
        require(balances[msg.sender] > 0);
        require(now > lockTime[msg.sender]);
        uint256 transferValue = balances[msg.sender];
        balances[msg.sender] = 0;
        msg.sender.transfer(transferValue);
    }
}

Обратите внимание, что все стандартные математические операции были заменены операциями, определенными в библиотеке SafeMath. Контракт TimeLock больше не выполняет никаких операций, которые могут привести к занижению/переполнению.

Примеры реального мира: PoWHC и переполнение при пакетной передаче (CVE-2018-10299)

Proof of Weak Hands Coin (PoWHC), изначально придуманная как своего рода шутка, была схемой Понци, написанной интернет-коллективом. К сожалению, похоже, что автор(ы) контракта не сталкивались с переполнением/недополнением раньше, и в результате 866 эфиров были освобождены от своего контракта. Эрик Банисадр дает хороший обзор того, как произошло переполнение (что не слишком похоже на проблему Эфира, описанную ранее) в своем блоге, посвященном этому событию. Другой пример связан с реализацией функции batchTransfer() в группе контрактов на токены ERC20. Реализация содержала уязвимость переполнения; подробности можно прочитать в аккаунте PeckShield.

Неожиданный эфир

Обычно, когда эфир отправляется в контракт, он должен выполнить либо функцию отката, либо другую функцию, определенную в контракте. Есть два исключения из этого правила, когда эфир может существовать в контракте без выполнения какого-либо кода. Контракты, которые полагаются на выполнение кода для всех отправляемых им эфиров, могут быть уязвимы для атак, когда эфир отправляется принудительно. Подробнее об этом читайте в статьях "Как защитить смарт-контракты" и "Паттерны безопасности Solidity - принуждение Ether к контракту".

Уязвимость

Распространенной техникой защитного программирования, полезной для обеспечения корректных переходов между состояниями или проверки операций, является проверка инвариантов. Эта техника включает определение набора инвариантов (метрик или параметров, которые не должны изменяться) и проверку того, что они остаются неизменными после одной (или многих) операций. Как правило, это хороший дизайн, если проверяемые инварианты действительно являются инвариантами. Одним из примеров инварианта является totalSupply токена ERC20 фиксированной эмиссии. Поскольку ни одна функция не должна изменять этот инвариант, можно добавить проверку в функцию передачи, которая гарантирует, что totalSupply остается неизменным, чтобы гарантировать, что функция работает так, как ожидалось.

В частности, есть один очевидный инвариант, который может быть заманчиво использовать, но которым на самом деле могут манипулировать внешние пользователи (независимо от правил, установленных в смарт-контракте). Это текущий эфир, хранящийся в контракте. Часто, когда разработчики впервые изучают Solidity, у них складывается неверное представление о том, что контракт может принимать или получать эфир только через оплачиваемые функции. Это заблуждение может привести к тому, что контракты будут иметь ложные представления о балансе эфира в них, что может привести к целому ряду уязвимостей. Дымящимся орудием этой уязвимости является (неправильное) использование this.balance. Существует два способа, с помощью которых эфир может быть (принудительно) отправлен на контракт без использования оплачиваемой функции или выполнения какого-либо кода на контракте:

Самоуничтожение/самоубийство

Любой контракт может реализовать функцию самоуничтожения, которая удаляет весь байткод с адреса контракта и отправляет весь хранящийся там эфир на указанный параметром адрес. Если этот указанный адрес также является контрактом, то никакие функции (включая fallback) не вызываются. Таким образом, функция самоуничтожения может быть использована для принудительной отправки эфира на любой контракт, независимо от любого кода, который может существовать в контракте, даже на контракты без оплачиваемых функций. Это означает, что любой злоумышленник может создать контракт с функцией самоуничтожения, отправить в него эфир, вызвать selfdestruct(target) и заставить отправить эфир в целевой контракт. В блоге Мартина Свенде есть отличная статья, описывающая некоторые причуды опкода самоуничтожения (причуда №2), а также рассказ о том, как клиентские узлы проверяли неверные инварианты, что могло привести к довольно катастрофическому краху сети Ethereum.

Предварительно отправленный эфир

Другой способ введения эфира в контракт - это предварительная загрузка адреса контракта эфиром. Адреса контрактов детерминированы - фактически, адрес вычисляется из хэша Keccak-256 (обычно синоним SHA-3) адреса, создающего контракт, и нонса транзакции, которая создает контракт. Точнее, он имеет вид address = sha3(rlp.encode([account_address,transaction_nonce])) (см. обсуждение "Эфира без ключа" Адриана Мэннинга для некоторых забавных случаев использования этого). Это означает, что любой может вычислить адрес контракта до его создания и отправить эфир на этот адрес. Когда контракт будет создан, он будет иметь ненулевой баланс эфира.

Давайте рассмотрим некоторые подводные камни, которые могут возникнуть с учетом этих знаний. Рассмотрим слишком простой контракт в EtherGame.sol. Пример 5. EtherGame.sol

contract EtherGame {

    uint public payoutMileStone1 = 3 ether;
    uint public mileStone1Reward = 2 ether;
    uint public payoutMileStone2 = 5 ether;
    uint public mileStone2Reward = 3 ether;
    uint public finalMileStone = 10 ether;
    uint public finalReward = 5 ether;

    mapping(address => uint) redeemableEther;
    // Users pay 0.5 ether. At specific milestones, credit their accounts.
    function play() external payable {
        require(msg.value == 0.5 ether); // each play is 0.5 ether
        uint currentBalance = this.balance + msg.value;
        // ensure no players after the game has finished
        require(currentBalance <= finalMileStone);
        // if at a milestone, credit the player's account
        if (currentBalance == payoutMileStone1) {
            redeemableEther[msg.sender] += mileStone1Reward;
        }
        else if (currentBalance == payoutMileStone2) {
            redeemableEther[msg.sender] += mileStone2Reward;
        }
        else if (currentBalance == finalMileStone ) {
            redeemableEther[msg.sender] += finalReward;
        }
        return;
    }

    function claimReward() public {
        // ensure the game is complete
        require(this.balance == finalMileStone);
        // ensure there is a reward to give
        require(redeemableEther[msg.sender] > 0);
        uint transferValue = redeemableEther[msg.sender];
        redeemableEther[msg.sender] = 0;
        msg.sender.transfer(transferValue);
    }
 }

Этот контракт представляет собой простую игру (которая, естественно, включает условия гонки), в которой игроки отправляют 0,5 эфира на контракт в надежде стать игроком, который первым достигнет одной из трех вех. Вехи обозначаются в эфирах. Тот, кто первым достигнет вехи, может претендовать на часть эфира по окончании игры. Игра заканчивается при достижении последнего рубежа (10 эфиров), после чего пользователи могут получить свое вознаграждение.

Проблемы с контрактом EtherGame связаны с плохим использованием this.balance в строках 14 (и, по ассоциации, 16) и 32. Злонамеренный злоумышленник может принудительно отправить небольшое количество эфира - скажем, 0,1 эфира - с помощью функции самоуничтожения (обсуждавшейся ранее), чтобы помешать любым будущим игрокам достичь вехи. Благодаря этому вкладу в 0,1 эфира this.balance никогда не будет кратен 0,5 эфира, потому что все легитимные игроки могут отправлять только 0,5 эфира. Это предотвращает выполнение всех условий if в строках 18, 21 и 24.

Хуже того, мстительный злоумышленник, пропустивший веху, может принудительно отправить 10 эфиров (или эквивалентное количество эфира, которое поднимет баланс контракта выше finalMileStone), что навсегда заблокирует все награды в контракте. Это происходит потому, что функция claimReward всегда будет возвращаться, благодаря требованию в строке 32 (т.е. потому, что this.balance больше, чем finalMileStone).

Профилактические методы

Подобная уязвимость обычно возникает из-за неправильного использования этого баланса. Логика контракта, по возможности, не должна зависеть от точных значений баланса контракта, поскольку им можно искусственно манипулировать. При применении логики, основанной на this.balance, вам придется иметь дело с неожиданными балансами.

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

Исходя из этого, исправленная версия контракта EtherGame могла бы выглядеть следующим образом:

contract EtherGame {

    uint public payoutMileStone1 = 3 ether;
    uint public mileStone1Reward = 2 ether;
    uint public payoutMileStone2 = 5 ether;
    uint public mileStone2Reward = 3 ether;
    uint public finalMileStone = 10 ether;
    uint public finalReward = 5 ether;
    uint public depositedWei;

    mapping (address => uint) redeemableEther;

    function play() external payable {
        require(msg.value == 0.5 ether);
        uint currentBalance = depositedWei + msg.value;
        // ensure no players after the game has finished
        require(currentBalance <= finalMileStone);
        if (currentBalance == payoutMileStone1) {
            redeemableEther[msg.sender] += mileStone1Reward;
        }
        else if (currentBalance == payoutMileStone2) {
            redeemableEther[msg.sender] += mileStone2Reward;
        }
        else if (currentBalance == finalMileStone ) {
            redeemableEther[msg.sender] += finalReward;
        }
        depositedWei += msg.value;
        return;
    }

    function claimReward() public {
        // ensure the game is complete
        require(depositedWei == finalMileStone);
        // ensure there is a reward to give
        require(redeemableEther[msg.sender] > 0);
        uint transferValue = redeemableEther[msg.sender];
        redeemableEther[msg.sender] = 0;
        msg.sender.transfer(transferValue);
    }
 }

Здесь мы создали новую переменную, depositedWei, которая отслеживает известное количество депонированного эфира, и именно эту переменную мы используем в наших тестах. Обратите внимание, что у нас больше нет ссылок на this.balance.

Дополнительные примеры

Несколько примеров эксплуатируемых контрактов были приведены в конкурсе кодирования Underhanded Solidity, который также содержит расширенные примеры ряда подводных камней, затронутых в этом разделе.

DELEGATECALL

Опкоды CALL и DELEGATECALL полезны для того, чтобы разработчики Ethereum могли модулировать свой код. Стандартные вызовы внешних сообщений к контрактам обрабатываются опкодом CALL, при этом код выполняется в контексте внешнего контракта/функции. Опкод DELEGATECALL практически идентичен, за исключением того, что код, выполняемый по целевому адресу, запускается в контексте вызывающего контракта, а msg.sender и msg.value остаются неизменными. Эта возможность позволяет реализовать библиотеки, позволяя разработчикам один раз развернуть многократно используемый код и вызывать его из будущих контрактов.

Хотя различия между этими двумя опкодами просты и интуитивно понятны, использование DELEGATECALL может привести к неожиданному выполнению кода. Для дальнейшего чтения см. вопрос Loi.Luu на Ethereum Stack Exchange по этой теме и документацию Solidity.

Уязвимость

В результате контекстно-сохраняющей природы DELEGATECALL создание свободных от уязвимостей пользовательских библиотек не так просто, как может показаться. Код в библиотеках сам по себе может быть безопасным и без уязвимостей, однако при запуске в контексте другого приложения могут возникнуть новые уязвимости. Рассмотрим достаточно сложный пример на примере чисел Фибоначчи.

Рассмотрим библиотеку в FibonacciLib.sol, которая может генерировать последовательность Фибоначчи и последовательности схожей формы. (Примечание: этот код был изменен с сайта https://bit.ly/2MReuii).

Пример 6. FibonacciLib.sol

// library contract - calculates Fibonacci-like numbers
contract FibonacciLib {
    // initializing the standard Fibonacci sequence
    uint public start;
    uint public calculatedFibNumber;

    // modify the zeroth number in the sequence
    function setStart(uint _start) public {
        start = _start;
    }

    function setFibonacci(uint n) public {
        calculatedFibNumber = fibonacci(n);
    }

    function fibonacci(uint n) internal returns (uint) {
        if (n == 0) return start;
        else if (n == 1) return start + 1;
        else return fibonacci(n - 1) + fibonacci(n - 2);
    }
}

Эта библиотека предоставляет функцию, которая может генерировать n-е число Фибоначчи в последовательности. Она позволяет пользователям изменять начальный номер последовательности (start) и вычислять n-е число Фибоначчи в этой новой последовательности.

Теперь рассмотрим контракт, использующий эту библиотеку, показанный в файле FibonacciBalance.sol.

Пример 7. FibonacciBalance.sol

contract FibonacciBalance {

    address public fibonacciLibrary;
    // the current Fibonacci number to withdraw
    uint public calculatedFibNumber;
    // the starting Fibonacci sequence number
    uint public start = 3;
    uint public withdrawalCounter;
    // the Fibonancci function selector
    bytes4 constant fibSig = bytes4(sha3("setFibonacci(uint256)"));

    // constructor - loads the contract with ether
    constructor(address _fibonacciLibrary) external payable {
        fibonacciLibrary = _fibonacciLibrary;
    }

    function withdraw() {
        withdrawalCounter += 1;
        // calculate the Fibonacci number for the current withdrawal user-
        // this sets calculatedFibNumber
        require(fibonacciLibrary.delegatecall(fibSig, withdrawalCounter));
        msg.sender.transfer(calculatedFibNumber * 1 ether);
    }

    // allow users to call Fibonacci library functions
    function() public {
        require(fibonacciLibrary.delegatecall(msg.data));
    }
}

Этот контракт позволяет участнику выводить эфир из контракта, при этом количество эфира равно числу Фибоначчи, соответствующему порядку вывода участника; т.е. первый участник получает 1 эфир, второй также получает 1, третий - 2, четвертый - 3, пятый - 5 и так далее (пока баланс контракта не станет меньше выводимого числа Фибоначчи).

В этом контракте есть ряд элементов, которые могут потребовать некоторого объяснения. Во-первых, есть интересная на вид переменная fibSig. Она содержит первые 4 байта хэша Keccak-256 (SHA-3) строки 'setFibonacci(uint256)'. Этот параметр известен как селектор функций и помещается в calldata, чтобы указать, какая функция смарт-контракта будет вызвана. Он используется в функции delegatecall в строке 21, чтобы указать, что мы хотим запустить функцию fibonacci(uint256). Второй аргумент в delegatecall - это параметр, который мы передаем функции. Во-вторых, мы предполагаем, что адрес библиотеки FibonacciLib правильно указан в конструкторе (в статье External Contract Referencing обсуждаются некоторые потенциальные уязвимости, связанные с подобной инициализацией контрактных ссылок).

Можете ли вы заметить какие-либо ошибки в этом контракте? Если развернуть этот контракт, заполнить его эфиром и вывести, он, скорее всего, вернется.

Возможно, вы заметили, что переменная состояния start используется как в библиотеке, так и в основном контракте вызова. В библиотечном контракте start используется для указания начала последовательности Фибоначчи и имеет значение 0, в то время как в вызывающем контракте она имеет значение 3. Вы также могли заметить, что функция fallback в контракте FibonacciBalance позволяет передавать все вызовы в библиотечный контракт, что позволяет вызвать функцию setStart библиотечного контракта. Напоминая, что мы сохраняем состояние контракта, может показаться, что эта функция позволит вам изменить состояние переменной start в локальном контракте FibonnacciBalance. Если так, то это позволило бы вывести больше эфира, поскольку результирующее значение calculatedFibNumber зависит от переменной start (как видно из библиотечного контракта). На самом деле функция setStart не изменяет (и не может изменить) переменную start в контракте FibonacciBalance. Уязвимость, лежащая в основе этого контракта, значительно хуже, чем просто модификация переменной start.

Прежде чем обсуждать этот вопрос, давайте сделаем небольшой экскурс, чтобы понять, как переменные состояния на самом деле хранятся в контрактах. Переменные состояния или переменные хранения (переменные, которые сохраняются в течение отдельных транзакций) помещаются в слоты последовательно по мере их появления в контракте. (Здесь есть некоторые сложности; для более глубокого понимания обратитесь к документации Solidity). В качестве примера рассмотрим библиотечный контракт. Он имеет две переменные состояния, start и calculatedFibNumber. Первая переменная, start, хранится в хранилище контракта в слоте[0] (т.е. в первом слоте). Вторая переменная, calculatedFibNumber, помещается в следующий доступный слот хранения, slot[1]. Функция setStart принимает входные данные и устанавливает start на то значение, которое было задано на входе. Поэтому эта функция устанавливает слот[0] на тот вход, который мы предоставляем в функции setStart. Аналогично, функция setFibonacci устанавливает calculatedFibNumber в результат fibonacci(n). Опять же, это просто установка слота хранения[1] на значение fibonacci(n).

Теперь рассмотрим контракт FibonacciBalance. Слот[0] хранилища теперь соответствует адресу fibonacciLibrary, а слот[1] - calculatedFibNumber. Именно в этом неправильном сопоставлении и кроется уязвимость. delegatecall сохраняет контекст контракта. Это означает, что код, выполняемый через delegatecall, будет действовать на состояние (т.е. хранилище) вызывающего контракта.

Теперь обратите внимание, что в withdraw в строке 21 мы выполняем fibonacciLibrary.delegatecall(fibSig,withdrawalCounter). Это вызывает функцию setFibonacci, которая, как мы уже говорили, изменяет слот хранения[1], который в нашем текущем контексте является calculatedFibNumber. Это ожидаемо (т.е. после выполнения calculatedFibNumber изменяется). Однако вспомните, что переменная start в контракте FibonacciLib находится в слоте хранения[0], который является адресом fibonacciLibrary в текущем контракте. Это означает, что функция fibonacci даст неожиданный результат. Это происходит потому, что она ссылается на start (slot[0]), который в текущем контексте вызова является адресом библиотеки fibonacciLibrary (который часто будет довольно большим, если интерпретировать его как uint). Таким образом, вполне вероятно, что функция вывода вернется, поскольку она не будет содержать uint(fibonacciLibrary) количество эфира, которое вернет calculatedFibNumber.

Что еще хуже, контракт FibonacciBalance позволяет пользователям вызывать все функции fibonacciLibrary через функцию fallback в строке 26. Как мы обсуждали ранее, сюда входит функция setStart. Мы обсуждали, что эта функция позволяет любому изменять или устанавливать слот хранения[0]. В данном случае storage slot[0] - это адрес библиотеки fibonacciLibrary. Поэтому злоумышленник может создать вредоносный контракт, преобразовать адрес в uint (это можно легко сделать в Python с помощью int('<адрес>',16)), а затем вызвать setStart(<attack_contract_address_as_uint>). Это изменит fibonacciLibrary на адрес контракта атаки. Затем, когда бы пользователь ни вызвал withdraw или функцию fallback, будет запущен вредоносный контракт (который может украсть весь баланс контракта), поскольку мы изменили фактический адрес fibonacciLibrary. Примером такого атакующего контракта может быть:

contract Attack {
    uint storageSlot0; // corresponds to fibonacciLibrary
    uint storageSlot1; // corresponds to calculatedFibNumber

    // fallback - this will run if a specified function is not found
    function() public {
        storageSlot1 = 0; // we set calculatedFibNumber to 0, so if withdraw
        // is called we don't send out any ether
        <attacker_address>.transfer(this.balance); // we take all the ether
    }
 }

Обратите внимание, что этот контракт на атаку изменяет вычисленное число волокна (calculatedFibNumber) путем изменения слота хранения[1]. В принципе, злоумышленник может изменить любые другие слоты хранения по своему выбору, чтобы выполнить все виды атак на этот контракт. Мы рекомендуем вам поместить эти контракты в Remix и поэкспериментировать с различными контрактами атаки и изменениями состояния с помощью этих функций вызова делегата.

Важно также отметить, что когда мы говорим, что delegatecall сохраняет состояние, мы имеем в виду не имена переменных контракта, а фактические слоты хранения, на которые эти имена указывают. Как видно из этого примера, простая ошибка может привести к тому, что злоумышленник перехватит весь контракт и его эфир.

Профилактические методы

Solidity предоставляет ключевое слово library для реализации библиотечных контрактов (подробнее см. документацию). Это гарантирует, что библиотечный контракт не имеет статусов и не саморазрушается. Принуждение библиотек к безстатусности смягчает сложности контекста хранения, продемонстрированные в этом разделе. Библиотеки без состояния также предотвращают атаки, в которых злоумышленники изменяют состояние библиотеки напрямую, чтобы повлиять на контракты, зависящие от кода библиотеки. В качестве общего правила, при использовании DELEGATECALL обращайте пристальное внимание на возможный контекст вызова как библиотечного контракта, так и вызывающего контракта, и по возможности создавайте библиотеки без статусов.

Пример реального мира: Паритетный мультисигма-кошелек (второй взлом)

Второй взлом Parity Multisig Wallet - это пример того, как хорошо написанный библиотечный код может быть использован, если его запустить не по назначению. Существует ряд хороших объяснений этого взлома, например, "Parity Multisig взломан. Снова" и "Углубленный взгляд на ошибку Parity Multisig".

Чтобы дополнить эти ссылки, давайте изучим контракты, которые были использованы. Библиотеку и контракты кошелька можно найти на GitHub.

Контракт с библиотекой заключается следующим образом:

contract WalletLibrary is WalletEvents {

  ...

  // throw unless the contract is not yet initialized.
  modifier only_uninitialized { if (m_numOwners > 0) throw; _; }

  // constructor - just pass on the owner array to multiowned and
  // the limit to daylimit
  function initWallet(address[] _owners, uint _required, uint _daylimit)
      only_uninitialized {
    initDaylimit(_daylimit);
    initMultiowned(_owners, _required);
  }

  // kills the contract sending everything to `_to`.
  function kill(address _to) onlymanyowners(sha3(msg.data)) external {
    suicide(_to);
  }

  ...

}

А вот контракт с кошельком:

contract Wallet is WalletEvents {

  ...

  // METHODS

  // gets called when no other function matches
  function() payable {
    // just being sent some cash?
    if (msg.value > 0)
      Deposit(msg.sender, msg.value);
    else if (msg.data.length > 0)
      _walletLibrary.delegatecall(msg.data);
  }

  ...

  // FIELDS
  address constant _walletLibrary =
    0xcafecafecafecafecafecafecafecafecafecafe;
}

Обратите внимание, что контракт Wallet по существу передает все вызовы контракту WalletLibrary через вызов делегата. Постоянный адрес _walletLibrary в этом фрагменте кода выступает в качестве заполнителя для фактически развернутого контракта WalletLibrary (который находится по адресу 0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4).

Предполагалось, что эти контракты будут работать как простой недорогой развертываемый контракт Wallet, кодовая база и основная функциональность которого находится в контракте WalletLibrary. К сожалению, контракт WalletLibrary сам является контрактом и поддерживает свое собственное состояние. Можете ли вы понять, почему это может быть проблемой?

Можно отправлять вызовы в сам контракт WalletLibrary. В частности, контракт WalletLibrary может быть инициализирован и стать владельцем. В действительности, один пользователь сделал это, вызвав функцию initWallet на контракте WalletLibrary и став владельцем контракта библиотеки. Впоследствии тот же пользователь вызвал функцию kill. Поскольку пользователь был владельцем библиотечного контракта, модификатор прошел, и библиотечный контракт самоуничтожился. Поскольку все существующие контракты Wallet ссылаются на этот библиотечный контракт и не содержат метода изменения этой ссылки, все их функциональные возможности, включая возможность вывода эфира, были потеряны вместе с контрактом WalletLibrary. В результате весь эфир во всех мультисиговых кошельках Parity данного типа был мгновенно потерян или навсегда утратил возможность восстановления.

Видимость по умолчанию

Функции в Solidity имеют спецификаторы видимости, которые определяют, как они могут быть вызваны. Видимость определяет, может ли функция вызываться пользователями извне, другими производными контрактами, только внутри или только извне. Существует четыре спецификатора видимости, которые подробно описаны в документации Solidity. Функции по умолчанию имеют значение public, что позволяет пользователям вызывать их извне. Сейчас мы увидим, как неправильное использование спецификаторов видимости может привести к некоторым разрушительным уязвимостям в смарт-контрактах.

Уязвимость

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

contract HashForEther {

    function withdrawWinnings() {
        // Winner if the last 8 hex characters of the address are 0
        require(uint32(msg.sender) == 0);
        _sendWinnings();
     }

     function _sendWinnings() {
         msg.sender.transfer(this.balance);
     }
}

Этот простой контракт предназначен для игры в баунти с угадыванием адреса. Чтобы выиграть баланс контракта, пользователь должен сгенерировать адрес Ethereum, последние 8 шестнадцатеричных символов которого равны 0. После этого он может вызвать функцию withdrawWinnings для получения своего вознаграждения.

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

Профилактические методы

Хорошей практикой является всегда указывать видимость всех функций в контракте, даже если они намеренно публичны. Последние версии solc показывают предупреждение для функций, у которых не задана явная видимость, чтобы поощрить такую практику.

Пример реального мира: Паритетный мультисигмовый кошелек (первый взлом)

В ходе первого многосигового взлома Parity было похищено около 31 млн. долларов Эфира, в основном из трех кошельков. Хороший обзор того, как именно это было сделано, дает Хасиб Куреши.

По сути, мультисиг-кошелек строится из базового контракта Wallet, который вызывает библиотечный контракт, содержащий основную функциональность (как описано в примере реального мира: Мультисиг-кошелек Parity (второй взлом)). Библиотечный контракт содержит код для инициализации кошелька, как видно из следующего фрагмента:

contract WalletLibrary is WalletEvents {

  ...

  // METHODS

  ...

  // constructor is given number of sigs required to do protected
  // "onlymanyowners" transactions as well as the selection of addresses
  // capable of confirming them
  function initMultiowned(address[] _owners, uint _required) {
    m_numOwners = _owners.length + 1;
    m_owners[1] = uint(msg.sender);
    m_ownerIndex[uint(msg.sender)] = 1;
    for (uint i = 0; i < _owners.length; ++i)
    {
      m_owners[2 + i] = uint(_owners[i]);
      m_ownerIndex[uint(_owners[i])] = 2 + i;
    }
    m_required = _required;
  }

  ...

  // constructor - just pass on the owner array to multiowned and
  // the limit to daylimit
  function initWallet(address[] _owners, uint _required, uint _daylimit) {
    initDaylimit(_daylimit);
    initMultiowned(_owners, _required);
  }
}

Обратите внимание, что ни одна из функций не указывает свою видимость, поэтому по умолчанию обе имеют значение public. Функция initWallet вызывается в конструкторе кошелька и устанавливает владельцев для мультисигового кошелька, как видно из функции initMultiowned. Поскольку эти функции были случайно оставлены публичными, злоумышленник смог вызвать эти функции на развернутых контрактах, сбросив владельца на адрес злоумышленника. Став владельцем, злоумышленник затем опустошил все кошельки с эфиром.

Иллюзия энтропии

Все транзакции в блокчейне Ethereum представляют собой детерминированные операции перехода состояния. Это означает, что каждая транзакция изменяет глобальное состояние экосистемы Ethereum просчитываемым способом, без какой-либо неопределенности. Из этого следует, что в Ethereum нет источника энтропии или случайности. Достижение децентрализованной энтропии (случайности) - хорошо известная проблема, для которой было предложено множество решений, включая RANDAO или использование цепочки хэшей, как описано Виталиком Бутериным в записи блога "Validator Ordering and Randomness in PoS".

Уязвимость

Одни из первых контрактов, построенных на платформе Ethereum, были основаны на азартных играх. По сути, азартные игры требуют неопределенности (чего-то, на что можно поставить), что делает построение системы азартных игр на блокчейне (детерминированной системе) довольно сложным. Очевидно, что неопределенность должна исходить из внешнего по отношению к блокчейну источника. Это возможно для ставок между игроками (см., например, технику commit-reveal); однако это значительно сложнее, если вы хотите реализовать контракт, выполняющий роль "дома" (как в блэкджеке или рулетке). Распространенной ошибкой является использование переменных будущего блока - то есть переменных, содержащих информацию о блоке транзакции, значения которых еще не известны, например, хэши, временные метки, номера блоков или пределы газа. Проблема с ними заключается в том, что они контролируются майнером, добывающим блок, и как таковые не являются действительно случайными. Рассмотрим, например, смарт-контракт для рулетки с логикой, которая возвращает черное число, если хэш следующего блока заканчивается на четное число. Майнер (или пул майнеров) может поставить $1M на черное число. Если они разгадают следующий блок и обнаружат, что хэш заканчивается нечетным числом, они могут с радостью не публиковать свой блок и добывать другой, пока не найдут решение, в котором хэш блока будет четным числом (при условии, что вознаграждение за блок и комиссии не превышают $1M). Использование переменных прошлого или настоящего может быть еще более разрушительным, как показывает Мартин Свенде в своей замечательной статье в блоге. Более того, использование исключительно переменных блока означает, что псевдослучайное число будет одинаковым для всех транзакций в блоке, поэтому злоумышленник может умножить свой выигрыш, проведя множество транзакций в блоке (при наличии максимальной ставки).

Профилактические методы

Источник энтропии (случайности) должен быть внешним по отношению к блокчейну. Это может быть сделано среди равных с помощью таких систем, как commit-reveal, или путем изменения модели доверия на группу участников (как в RandDAO). Это также может быть сделано с помощью централизованной структуры, которая действует как оракул случайности. Блокчейн-переменные (в целом, есть некоторые исключения) не должны использоваться для получения энтропии, так как ими могут манипулировать майнеры.

Пример реального мира: Контракты ГПСЧ

В феврале 2018 года Арсений Реутов написал в блоге о своем анализе 3649 живых смарт-контрактов, в которых использовался некий генератор псевдослучайных чисел (ГПСЧ); он обнаружил 43 контракта, которые могли быть использованы.

Внешнее реферирование контрактов

Одним из преимуществ "мирового компьютера" Ethereum является возможность повторного использования кода и взаимодействия с контрактами, уже развернутыми в сети. В результате большое количество контрактов ссылается на внешние контракты, обычно через вызовы внешних сообщений. Эти вызовы внешних сообщений могут маскировать намерения злоумышленников некоторыми неочевидными способами, которые мы сейчас рассмотрим.

Уязвимость

В Solidity любой адрес может быть приведен к контракту, независимо от того, представляет ли код по этому адресу тип приводимого контракта. Это может вызвать проблемы, особенно когда автор контракта пытается скрыть вредоносный код. Проиллюстрируем это на примере.

Рассмотрим такой кусок кода, как Rot13Encryption.sol, который рудиментарно реализует шифр ROT13.

Пример 8. Rot13Encryption.sol

// encryption contract
contract Rot13Encryption {

   event Result(string convertedString);

    // rot13-encrypt a string
    function rot13Encrypt (string text) public {
        uint256 length = bytes(text).length;
        for (var i = 0; i < length; i++) {
            byte char = bytes(text)[i];
            // inline assembly to modify the string
            assembly {
                // get the first byte
                char := byte(0,char)
                // if the character is in [n,z], i.e. wrapping
                if and(gt(char,0x6D), lt(char,0x7B))
                // subtract from the ASCII number 'a',
                // the difference between character <char> and 'z'
                { char:= sub(0x60, sub(0x7A,char)) }
                if iszero(eq(char, 0x20)) // ignore spaces
                // add 13 to char
                {mstore8(add(add(text,0x20), mul(i,1)), add(char,13))}
            }
        }
        emit Result(text);
    }

    // rot13-decrypt a string
    function rot13Decrypt (string text) public {
        uint256 length = bytes(text).length;
        for (var i = 0; i < length; i++) {
            byte char = bytes(text)[i];
            assembly {
                char := byte(0,char)
                if and(gt(char,0x60), lt(char,0x6E))
                { char:= add(0x7B, sub(char,0x61)) }
                if iszero(eq(char, 0x20))
                {mstore8(add(add(text,0x20), mul(i,1)), sub(char,13))}
            }
        }
        emit Result(text);
    }
}

Этот код просто берет строку (буквы a-z, без проверки) и шифрует ее, сдвигая каждый символ на 13 позиций вправо (оборачивая вокруг z); т.е. a сдвигается на n, а x сдвигается на k. Для понимания обсуждаемого вопроса не требуется понимание ассемблера в предыдущем контракте, поэтому читатели, не знакомые с ассемблером, могут смело игнорировать его.

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

import "Rot13Encryption.sol";

// encrypt your top-secret info
contract EncryptionContract {
    // library for encryption
    Rot13Encryption encryptionLibrary;

    // constructor - initialize the library
    constructor(Rot13Encryption _encryptionLibrary) {
        encryptionLibrary = _encryptionLibrary;
    }

    function encryptPrivateData(string privateInfo) {
        // potentially do some operations here
        encryptionLibrary.rot13Encrypt(privateInfo);
     }
 }

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

// encryption contract
contract Rot26Encryption {

   event Result(string convertedString);

    // rot13-encrypt a string
    function rot13Encrypt (string text) public {
        uint256 length = bytes(text).length;
        for (var i = 0; i < length; i++) {
            byte char = bytes(text)[i];
            // inline assembly to modify the string
            assembly {
                // get the first byte
                char := byte(0,char)
                // if the character is in [n,z], i.e. wrapping
                if and(gt(char,0x6D), lt(char,0x7B))
                // subtract from the ASCII number 'a',
                // the difference between character <char> and 'z'
                { char:= sub(0x60, sub(0x7A,char)) }
                // ignore spaces
                if iszero(eq(char, 0x20))
                // add 26 to char!
                {mstore8(add(add(text,0x20), mul(i,1)), add(char,26))}
            }
        }
        emit Result(text);
    }

    // rot13-decrypt a string
    function rot13Decrypt (string text) public {
        uint256 length = bytes(text).length;
        for (var i = 0; i < length; i++) {
            byte char = bytes(text)[i];
            assembly {
                char := byte(0,char)
                if and(gt(char,0x60), lt(char,0x6E))
                { char:= add(0x7B, sub(char,0x61)) }
                if iszero(eq(char, 0x20))
                {mstore8(add(add(text,0x20), mul(i,1)), sub(char,26))}
            }
        }
        emit Result(text);
    }
}

Этот контракт реализует шифр ROT26, который сдвигает каждый символ на 26 мест (т.е. ничего не делает). Опять же, нет необходимости понимать ассемблер в этом контракте. Более просто, атакующий мог бы подключить следующий контракт для того же эффекта:

contract Print{
    event Print(string text);

    function rot13Encrypt(string text) public {
        emit Print(text);
    }
 }

Если бы адрес любого из этих контрактов был указан в конструкторе, функция encryptPrivateData просто выдавала бы событие, которое печатает незашифрованные приватные данные.

Хотя в этом примере библиотечный контракт был установлен в конструкторе, часто бывает так, что привилегированный пользователь (например, владелец) может изменить адреса библиотечных контрактов. Если связанный контракт не содержит вызываемой функции, будет выполнена резервная функция. Например, в строке encryptionLibrary.rot13Encrypt(), если контракт, указанный encryptionLibrary, был:

 contract Blank {
     event Print(string text);
     function () {
         emit Print("Here");
         // put malicious code here and it will run
     }
 }

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

Предупреждение: Представленные здесь контракты предназначены только для демонстрационных целей и не являются надлежащим шифрованием. Их не следует использовать для шифрования.

Профилактические методы

Как было показано ранее, безопасные контракты (в некоторых случаях) могут быть развернуты таким образом, что они ведут себя злонамеренно. Аудитор может публично проверить контракт и заставить его владельца развернуть его злонамеренным образом, в результате чего публично проверенный контракт будет иметь уязвимости или злой умысел.

Существует ряд приемов, позволяющих предотвратить подобные сценарии.

Одна из техник заключается в использовании ключевого слова new для создания контрактов. В предыдущем примере конструктор может быть записан как:

constructor() {
    encryptionLibrary = new Rot13Encryption();
}

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

Другим решением является жесткое кодирование адресов внешних контрактов.

В целом, код, вызывающий внешние контракты, всегда должен тщательно проверяться. Как разработчик, при определении внешних контрактов, хорошей идеей может быть сделать адреса контрактов общедоступными (что не так в примере с honey-pot в следующем разделе), чтобы пользователи могли легко изучить код, на который ссылается контракт. И наоборот, если контракт имеет приватный переменный адрес контракта, это может быть признаком того, что кто-то ведет себя злонамеренно (как показано в реальном примере). Если пользователь может изменить адрес контракта, который используется для вызова внешних функций, может быть важно (в контексте децентрализованной системы) реализовать механизм временной блокировки и/или голосования, чтобы позволить пользователям видеть, какой код изменяется, или дать участникам возможность отказаться от участия/отказаться от участия с новым адресом контракта.

Пример реального мира: Медовый горшок реентерабельности

В последнее время в сети появилось несколько "медовых горшков". Эти контракты пытаются перехитрить хакеров Ethereum, которые пытаются использовать контракты, но в итоге теряют эфир из-за контракта, который они рассчитывают использовать. Один из примеров использует эту атаку, заменяя ожидаемый контракт на вредоносный в конструкторе. Код можно найти здесь:

pragma solidity ^0.4.19;

contract Private_Bank
{
    mapping (address => uint) public balances;
    uint public MinDeposit = 1 ether;
    Log TransferLog;

    function Private_Bank(address _log)
    {
        TransferLog = Log(_log);
    }

    function Deposit()
    public
    payable
    {
        if(msg.value >= MinDeposit)
        {
            balances[msg.sender]+=msg.value;
            TransferLog.AddMessage(msg.sender,msg.value,"Deposit");
        }
    }

    function CashOut(uint _am)
    {
        if(_am<=balances[msg.sender])
        {
            if(msg.sender.call.value(_am)())
            {
                balances[msg.sender]-=_am;
                TransferLog.AddMessage(msg.sender,_am,"CashOut");
            }
        }
    }

    function() external payable{}

}

contract Log
{
    struct Message
    {
        address Sender;
        string  Data;
        uint Val;
        uint  Time;
    }

    Message[] public History;
    Message LastMsg;

    function AddMessage(address _adr,uint _val,string _data)
    public
    {
        LastMsg.Sender = _adr;
        LastMsg.Time = now;
        LastMsg.Val = _val;
        LastMsg.Data = _data;
        History.push(LastMsg);
    }
}

В этом сообщении одного из пользователей reddit объясняется, как они потеряли 1 эфир на этом контракте, пытаясь использовать ошибку реентерабельности, которая, как они ожидали, должна была присутствовать в контракте.

Атака на короткий адрес/параметр

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

Для дальнейшего чтения см. статьи "Объяснение атаки на короткий адрес ERC20", "Уязвимость смарт-контрактов ICO: Атака на короткий адрес" или это сообщение на Reddit.

Уязвимость

При передаче параметров смарт-контракту параметры кодируются в соответствии со спецификацией ABI. Можно отправить закодированные параметры, длина которых меньше ожидаемой (например, адрес, состоящий всего из 38 шестнадцатеричных символов (19 байт) вместо стандартных 40 шестнадцатеричных символов (20 байт)). В этом случае EVM добавит нули в конец закодированных параметров, чтобы восполнить ожидаемую длину.

Это становится проблемой, когда сторонние приложения не проверяют вводимые данные. Самый яркий пример - биржа, которая не проверяет адрес токена ERC20, когда пользователь запрашивает вывод средств. Более подробно этот пример рассмотрен в статье Питера Вессенеса "Атака на короткий адрес ERC20 объяснена". Рассмотрим стандартный интерфейс передаточной функции ERC20, обращая внимание на порядок параметров: function transfer(address to, uint tokens) public returns (bool success);

Теперь рассмотрим биржу, на которой хранится большое количество токенов (допустим, REP), и пользователя, который хочет вывести свою долю в 100 токенов. Биржа закодирует эти параметры в порядке, определенном функцией передачи; то есть сначала адрес, затем токены. Результат кодирования будет следующим:

a9059cbb000000000000000000000000deaddeaddea \
ddeaddeaddeaddeaddeaddeaddead0000000000000
000000000000000000000000000000000056bc75e2d63100000

Первые 4 байта (a9059cbb) - это подпись/селектор функции передачи, следующие 32 байта - это адрес, а последние 32 байта представляют собой число токенов uint256. Обратите внимание, что шестнадцатеричное число 56bc75e2d63100000 в конце соответствует 100 токенам (с 18 десятичными знаками, как указано в контракте токенов REP).

Теперь давайте рассмотрим, что произойдет, если отправить адрес, в котором не хватает 1 байта (2 шестнадцатеричных цифры). В частности, допустим, злоумышленник отправляет 0xdeaddeaddeaddeaddeaddeaddeaddeadde в качестве адреса (не хватает двух последних цифр) и те же 100 токенов для вывода. Если биржа не подтвердит этот ввод, он будет закодирован как:

a9059cbb000000000000000000000000deaddeaddea \
ddeaddeaddeaddeaddeaddeadde00000000000000
00000000000000000000000000000000056bc75e2d6310000000

Разница едва заметна. Обратите внимание, что в конце кодировки добавлено 00, чтобы компенсировать короткий адрес, который был отправлен. Когда это будет отправлено смарт-контракту, параметры адреса будут прочитаны как 0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddeadde00, а значение будет прочитано как 56bc75e2d6310000000 (обратите внимание на два дополнительных 0). Теперь это значение равно 25600 жетонов (значение было умножено на 256). В данном примере, если бы на бирже хранилось такое количество токенов, пользователь вывел бы 25600 токенов (в то время как биржа думает, что пользователь выводит только 100) на измененный адрес. Очевидно, что в данном примере злоумышленник не будет обладать модифицированным адресом, но если бы злоумышленник сгенерировал любой адрес, заканчивающийся на 0 (который можно легко перебрать), и использовал этот сгенерированный адрес, он мог бы украсть токены у ничего не подозревающей биржи.

Профилактические методы

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

Возвращаемые значения CALL без проверки

Существует несколько способов осуществления внешних вызовов в Solidity. Отправка эфира на внешние счета обычно выполняется с помощью метода transfer. Однако можно также использовать функцию send, а для более универсальных внешних вызовов в Solidity можно напрямую использовать опкод CALL. Функции call и send возвращают булево значение, указывающее на успех или неудачу вызова. Таким образом, эти функции имеют простую оговорку: транзакция, выполняющая эти функции, не вернется назад, если внешний вызов (инициализированный с помощью call или send) не удался; скорее, функции просто вернут false. Распространенная ошибка заключается в том, что разработчик ожидает возврата в случае неудачи внешнего вызова и не проверяет возвращаемое значение.

Для дальнейшего чтения смотрите №4 в Топ-10 DASP за 2018 год и статью "Сканирование живых контрактов Ethereum на наличие ошибки "Unchecked-Send"".

Уязвимость

Рассмотрим следующий пример:

contract Lotto {

    bool public payedOut = false;
    address public winner;
    uint public winAmount;

    // ... extra functionality here

    function sendToWinner() public {
        require(!payedOut);
        winner.send(winAmount);
        payedOut = true;
    }

    function withdrawLeftOver() public {
        require(payedOut);
        msg.sender.send(this.balance);
    }
}

Это представляет собой контракт, похожий на лото, где победитель получает winAmount эфира, что обычно оставляет немного средств для вывода.

Уязвимость существует в строке 11, где используется отправка без проверки ответа. В этом тривиальном примере победитель, чья транзакция не удалась (либо из-за того, что закончился бензин, либо из-за того, что контракт намеренно выбрасывает функцию fallback), позволяет установить значение payedOut в true независимо от того, был ли отправлен эфир или нет. В этом случае любой может снять выигрыш победителя через функцию withdrawLeftOver.

Профилактические методы

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

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

Пример реального мира: Эфирный горшок и король Эфира

Etherpot был смарт-контрактом лотереи, не слишком похожим на пример контракта, упомянутого ранее. Падение этого контракта произошло в основном из-за неправильного использования хэшей блоков (пригодны только последние 256 хэшей блоков; см. статью Аакила Фернандеша о том, как Etherpot не смог правильно это учесть). Однако этот контракт также страдал от непроверенного значения вызова. Рассмотрим функцию cash в файле lotto.sol: Фрагмент кода. Пример 9. lotto.sol: Фрагмент кода

...
  function cash(uint roundIndex, uint subpotIndex){

        var subpotsCount = getSubpotsCount(roundIndex);

        if(subpotIndex>=subpotsCount)
            return;

        var decisionBlockNumber = getDecisionBlockNumber(roundIndex,subpotIndex);

        if(decisionBlockNumber>block.number)
            return;

        if(rounds[roundIndex].isCashed[subpotIndex])
            return;
        //Subpots can only be cashed once. This is to prevent double payouts

        var winner = calculateWinner(roundIndex,subpotIndex);
        var subpot = getSubpot(roundIndex);

        winner.send(subpot);

        rounds[roundIndex].isCashed[subpotIndex] = true;
        //Mark the round as cashed
}
...

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

Более серьезная версия этой ошибки произошла в контракте King of the Ether. Было написано отличное вскрытие этого контракта, в котором подробно описано, как непроверенная неудачная отправка может быть использована для атаки на контракт.

Условия гонки/Фронтальный забег

Сочетание внешних вызовов других контрактов и многопользовательской природы базового блокчейна порождает множество потенциальных "подводных камней" Solidity, когда пользователи гоняются за выполнением кода, получая неожиданные состояния. Реентерабельность (рассмотренная ранее в этой главе) является одним из примеров таких условий гонки. В этом разделе мы обсудим другие виды условий гонки, которые могут возникнуть в блокчейне Ethereum. Существует множество хороших статей на эту тему, включая "Условия гонки" на Ethereum Wiki, № 7 в DASP Top10 of 2018, а также "Лучшие практики смарт-контрактов Ethereum".

Уязвимость

Как и в большинстве блокчейнов, узлы Ethereum объединяют транзакции и формируют их в блоки. Транзакции считаются действительными только после того, как майнер решит механизм консенсуса (в настоящее время Ethash PoW для Ethereum). Майнер, решающий блок, также выбирает, какие транзакции из пула будут включены в блок, обычно в порядке возрастания цены gasPrice каждой транзакции. Вот потенциальный вектор атаки. Злоумышленник может следить за пулом транзакций в поисках транзакций, которые могут содержать решения проблем, и изменять или отзывать разрешения решателя или изменять состояние контракта в ущерб решателю. Затем злоумышленник может получить данные из этой транзакции и создать собственную транзакцию с более высокой GasPrice, чтобы его транзакция попала в блок перед оригиналом.

Давайте посмотрим, как это может работать на простом примере. Рассмотрим контракт, показанный в FindThisHash.sol.

Пример 10. FindThisHash.sol

contract FindThisHash {
    bytes32 constant public hash =
      0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a;

    constructor() external payable {} // load with ether

    function solve(string solution) public {
        // If you can find the pre-image of the hash, receive 1000 ether
        require(hash == sha3(solution));
        msg.sender.transfer(1000 ether);
    }
}

Допустим, этот контракт содержит 1 000 эфиров. Пользователь, который сможет найти предварительный образ следующего хэша SHA-3:

0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a

могут представить решение и получить 1 000 эфиров. Допустим, один пользователь догадался, что решение - это Ethereum! Они вызывают команду solve с параметром Ethereum! К сожалению, злоумышленник был достаточно умен, чтобы следить за пулом транзакций в поисках тех, кто отправил решение. Они видят это решение, проверяют его действительность, а затем подают эквивалентную транзакцию с гораздо более высокой ценой gasPrice, чем первоначальная транзакция. Майнер, решающий блок, скорее всего, отдаст предпочтение злоумышленнику из-за более высокой цены gasPrice, и его транзакция будет добыта раньше, чем транзакция оригинального решателя. Злоумышленник заберет 1 000 эфиров, а пользователь, решивший проблему, не получит ничего. Помните, что в этом типе уязвимости "опережающего действия" майнеры имеют уникальную мотивацию для проведения атак самостоятельно (или могут быть подкуплены для проведения таких атак с помощью экстравагантных гонораров). Не следует недооценивать возможность того, что атакующий сам является майнером.

Профилактические методы

Существует два класса субъектов, которые могут осуществлять подобные атаки с опережением: пользователи (которые изменяют GasPrice своих транзакций) и сами майнеры (которые могут изменять порядок транзакций в блоке по своему усмотрению). Контракт, уязвимый для первого класса (пользователи), значительно хуже, чем уязвимый для второго (майнеры), поскольку майнеры могут осуществить атаку только при решении блока, что маловероятно для каждого отдельного майнера, нацеленного на конкретный блок. Здесь мы перечислим несколько мер по смягчению последствий для обоих классов злоумышленников.

Один из методов - установить верхнюю границу для цены газа (GasPrice). Это не позволит пользователям увеличить цену на газ и получить преимущественный порядок транзакций, превышающий верхнюю границу. Эта мера защищает только от первого класса злоумышленников (произвольных пользователей). Майнеры в этом сценарии все еще могут атаковать контракт, поскольку они могут упорядочивать транзакции в своем блоке как им заблагорассудится, независимо от цены газа.

Более надежным методом является использование схемы "фиксация-раскрытие". Согласно такой схеме, пользователи отправляют транзакции со скрытой информацией (обычно хэш). После включения транзакции в блок пользователь посылает транзакцию, раскрывающую отправленные данные (фаза раскрытия). Этот метод не позволяет ни майнерам, ни пользователям опережать транзакции, поскольку они не могут определить содержание транзакции. Однако этот метод не позволяет скрыть стоимость транзакции (которая в некоторых случаях является ценной информацией, которую необходимо скрыть). Смарт-контракт ENS позволял пользователям отправлять транзакции, данные которых включали сумму эфира, которую они готовы были потратить. Затем пользователи могли отправлять транзакции произвольной стоимости. На этапе раскрытия пользователям возвращалась разница между суммой, отправленной в транзакции, и суммой, которую они были готовы потратить.

Лоренц Брайденбах, Фил Дайан, Ари Джуэлс и Флориан Трамер предложили использовать "подводные посылки". Для эффективной реализации этой идеи требуется опкод CREATE2, который в настоящее время не принят, но, похоже, будет принят в ближайших hard forks.

Примеры реального мира: ERC20 и Bancor

Стандарт ERC20 довольно хорошо известен для создания токенов на Ethereum. Этот стандарт имеет потенциальную уязвимость перед атакой, которая возникает из-за функции approve. Михаил Владимиров и Дмитрий Ховратович написали хорошее объяснение этой уязвимости (и способов ослабления атаки).

Стандарт определяет функцию утверждения как:

function approve(address _spender, uint256 _value) returns (bool success)

Эта функция позволяет пользователю разрешить другим пользователям переводить токены от его имени. Уязвимость front-running возникает в сценарии, когда пользователь Алиса разрешает своему другу Бобу потратить 100 жетонов. Позже Алиса решает, что она хочет отозвать разрешение Боба потратить, скажем, 100 жетонов, поэтому она создает транзакцию, которая устанавливает распределение Боба на 50 жетонов. Боб, который внимательно следил за цепочкой, видит эту транзакцию и создает собственную транзакцию, расходующую 100 жетонов. Он ставит на свою транзакцию более высокую цену gasPrice, чем у Алисы, поэтому его транзакция получает приоритет над ее транзакцией. В некоторых реализациях одобрения Боб может перевести свои 100 жетонов, а затем, когда транзакция Алисы будет зафиксирована, сбросить одобрение Боба до 50 жетонов, фактически предоставив Бобу доступ к 150 жетонам.

Другим ярким примером реального мира является Bancor. Иван Богатый и его команда задокументировали прибыльную атаку на первоначальную реализацию Bancor. Его сообщение в блоге и доклад на DevCon3 подробно описывают, как это было сделано. По сути, цены на токены определяются на основе стоимости транзакций; пользователи могут следить за пулом транзакций Bancor и опережать их, чтобы получить прибыль от разницы в цене. Команда Bancor приняла меры по устранению этой атаки.

Отказ в обслуживании (DoS)

Эта категория очень широка, но в основном включает в себя атаки, в ходе которых пользователи могут вывести контракт из строя на определенный период времени, а в некоторых случаях и навсегда. В результате эфир может навсегда застрять в этих контрактах, как это произошло в примере реального мира: Кошелек Parity Multisig (второй взлом).

Уязвимость

Контракт может стать неработоспособным разными способами. Здесь мы приведем лишь несколько менее очевидных моделей кодирования Solidity, которые могут привести к DoS-уязвимости:

Циклический просмотр внешних отображений или массивов

Эта схема обычно возникает, когда владелец хочет распределить токены среди инвесторов с помощью функции, подобной распределению, как в этом примере контракта:

contract DistributeTokens {
    address public owner; // gets set somewhere
    address[] investors; // array of investors
    uint[] investorTokens; // the amount of tokens each investor gets

    // ... extra functionality, including transfertoken()

    function invest() external payable {
        investors.push(msg.sender);
        investorTokens.push(msg.value * 5); // 5 times the wei sent
        }

    function distribute() public {
        require(msg.sender == owner); // only owner
        for(uint i = 0; i < investors.length; i++) {
            // here transferToken(to,amount) transfers "amount" of
            // tokens to the address "to"
            transferToken(investors[i],investorTokens[i]);
        }
    }
}

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

Деятельность владельца

Другая распространенная схема - когда владельцы имеют определенные привилегии в контрактах и должны выполнить определенную задачу, чтобы контракт перешел в следующее состояние. Примером может служить контракт на первичное размещение монет (ICO), который требует от владельца завершить контракт, что позволяет передавать токены. Например:

bool public isFinalized = false;
address public owner; // gets set somewhere

function finalize() public {
    require(msg.sender == owner);
    isFinalized = true;
}

// ... extra ICO functionality

// overloaded transfer function
function transfer(address _to, uint _value) returns (bool) {
    require(isFinalized);
    super.transfer(_to,_value)
}

...

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

Продвижение состояния на основе внешних вызовов

Контракты иногда составляются таким образом, что для перехода в новое состояние требуется отправить эфир на адрес или дождаться какого-то ввода из внешнего источника. Такие шаблоны могут привести к DoS-атакам, когда внешний вызов не удается или предотвращается по внешним причинам. В примере с отправкой эфира пользователь может создать контракт, который не принимает эфир. Если контракт требует изъятия эфира для перехода в новое состояние (рассмотрим контракт с временной блокировкой, который требует изъятия всего эфира перед тем, как снова стать пригодным для использования), контракт никогда не достигнет нового состояния, поскольку эфир никогда не может быть отправлен в контракт пользователя, который не принимает эфир.

Профилактические методы

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

Во втором примере для изменения состояния контракта требовался привилегированный пользователь. В таких примерах может быть использована защита от сбоев на случай, если владелец станет недееспособным. Одно из решений - сделать владельцем контракта мультисигму. Другим решением является использование блокировки по времени: в приведенном примере require в строке 5 может включать механизм, основанный на времени, такой как require(msg.sender == owner || now > unlockTime), который позволяет любому пользователю завершить работу через период времени, заданный unlockTime. Подобная техника смягчения последствий может быть использована и в третьем примере. Если для перехода в новое состояние требуются внешние вызовы, учтите их возможный отказ и потенциально добавьте переход в состояние на основе времени на случай, если нужный вызов так и не поступит.

Примечание: Конечно, существуют централизованные альтернативы этим предложениям: можно добавить пользователя maintenanceUser, который при необходимости может прийти и устранить проблемы с векторами DoS-атак. Обычно такие контракты связаны с вопросами доверия из-за власти такого субъекта.

Примеры реального мира: GovernMental

GovernMental была старой схемой Понци, которая накопила довольно большое количество эфира (1 100 эфиров в один момент). К сожалению, она была подвержена DoS-уязвимостям, упомянутым в этом разделе. В сообщении etherik на Reddit описывается, как контракт требовал удаления большого количества отображений, чтобы вывести эфир. Удаление этого отображения имело стоимость газа, которая превышала лимит газа блока в то время, и поэтому невозможно было вывести 1100 эфиров. Адрес контракта 0xF45717552f12Ef7cb65e95476F217Ea008167Ae3, и вы можете видеть из транзакции 0x0d80d67202bd9cb6773df8dd2020e719 0a1b0793e8ec4fc105257e8128f0506b, что 1,100 эфира были наконец получены с помощью транзакции, которая использовала 2.5М газа (когда лимит газа в блокчейне поднялся настолько, что позволил провести такую транзакцию).

Манипуляция временными метками блока

Временные метки блоков исторически использовались для различных целей, таких как энтропия для случайных чисел (подробнее см. "Иллюзия энтропии"), блокировка средств на определенный период времени и различные условные утверждения, изменяющие состояние и зависящие от времени. Майнеры имеют возможность незначительно корректировать временные метки, что может оказаться опасным при неправильном использовании временных меток блока в смарт-контрактах.

Полезные ссылки для этого включают документацию Solidity и вопрос Йориса Бонтье на Ethereum Stack Exchange по этой теме.

Уязвимость

block.timestamp и его псевдоним теперь могут манипулировать майнеры, если у них есть для этого стимул. Давайте построим простую игру, показанную в roulette.sol, которая была бы уязвима для майнеров.

Пример 11. roulette.sol

contract Roulette { uint public pastBlockTime; // forces one bet per block

constructor() external payable {} // initially fund contract

// fallback function used to make a bet
function () external payable {
    require(msg.value == 10 ether); // must send 10 ether to play
    require(now != pastBlockTime); // only 1 transaction per block
    pastBlockTime = now;
    if(now % 15 == 0) { // winner
        msg.sender.transfer(this.balance);
    }
}

}

Этот контракт ведет себя как простая лотерея. Одна транзакция на блок может поставить 10 эфиров на шанс выиграть остаток контракта. Предполагается, что последние две цифры block.timestamp распределены равномерно. Если бы это было так, то шанс выиграть в этой лотерее был бы 1 к 15.

Однако, как мы знаем, майнеры могут корректировать временную метку, если им это необходимо. В данном конкретном случае, если в контракте достаточно пулов эфира, майнер, решивший блок, получает стимул выбрать такую временную метку, чтобы block.timestamp или now modulo 15 было равно 0. При этом он может выиграть эфир, заблокированный в этом контракте, вместе с вознаграждением за блок. Поскольку на один блок разрешено делать ставки только одному человеку, этот контракт также уязвим для атак с опережением (более подробная информация приведена в разделе Условия гонки/Опережение).

На практике временные метки блоков монотонно возрастают, поэтому майнеры не могут выбирать произвольные временные метки блоков (они должны быть позже своих предшественников). Они также ограничены в выборе времени блока не слишком далеко в будущем, поскольку такие блоки, скорее всего, будут отклонены сетью (узлы не будут подтверждать блоки, временные метки которых находятся в будущем).

Профилактические методы

Временные метки блоков не должны использоваться для энтропии или генерации случайных чисел - т.е. они не должны быть решающим фактором (напрямую или через какие-то производные) для победы в игре или изменения важного состояния.

Иногда требуется логика, чувствительная ко времени; например, для разблокировки контрактов (блокировка по времени), завершения ICO через несколько недель или обеспечения соблюдения сроков действия. Иногда рекомендуется использовать номер блока и среднее время блока для оценки времени; при времени блока 10 секунд 1 неделя равна приблизительно 60480 блокам. Таким образом, указание номера блока, при котором необходимо изменить состояние контракта, может быть более безопасным, поскольку майнеры не могут легко манипулировать номером блока. Контракт BAT ICO использовал эту стратегию.

Это может быть излишним, если контракты не особенно обеспокоены манипуляциями майнеров с временной меткой блока, но это то, о чем следует помнить при разработке контрактов.

Пример реального мира: GovernMental

GovernMental, старая схема Понци, упомянутая выше, также была уязвима для атаки на основе временной метки. Контракт выплачивал деньги игроку, который последним присоединился (по крайней мере, на одну минуту) в раунде. Таким образом, майнер, который был игроком, мог изменить временную метку (на будущее время, чтобы казалось, что прошла минута), чтобы создать впечатление, что он был последним игроком, присоединившимся более чем на минуту (хотя в действительности это было не так). Более подробно об этом можно прочитать в статье Тани Бахриновской "История уязвимостей безопасности Ethereum, взломов и их устранения".

Конструкторы с осторожностью

Конструкторы - это специальные функции, которые часто выполняют критические, привилегированные задачи при инициализации контрактов. До версии Solidity v0.4.22 конструкторы определялись как функции, имеющие то же имя, что и содержащий их контракт. В таких случаях при изменении имени контракта в процессе разработки, если имя конструктора не изменено, он становится обычной, вызываемой функцией. Как вы можете себе представить, это может привести (и уже привело) к некоторым интересным хакам для контрактов.

Для дальнейшего понимания читателю может быть интересно пройти испытания Ethernaut (в частности, уровень Fallout).

Уязвимость

Если имя контракта изменено или в имени конструктора допущена опечатка, не совпадающая с именем контракта, конструктор будет вести себя как обычная функция. Это может привести к плачевным последствиям, особенно если конструктор выполняет привилегированные операции. Рассмотрим следующий контракт:

contract OwnerWallet {
    address public owner;

    // constructor
    function ownerWallet(address _owner) public {
        owner = _owner;
    }

    // Fallback. Collect ether.
    function () payable {}

    function withdraw() public {
        require(msg.sender == owner);
        msg.sender.transfer(this.balance);
    }
}

Этот контракт собирает эфир и позволяет только владельцу изымать его, вызывая функцию withdraw. Проблема возникает из-за того, что конструктор назван не совсем так же, как контракт: первая буква отличается! Таким образом, любой пользователь может вызвать функцию ownerWallet, установить себя в качестве владельца, а затем забрать весь эфир из контракта, вызвав функцию withdraw.

Профилактические методы

Эта проблема была решена в версии 0.4.22 компилятора Solidity. В этой версии введено ключевое слово constructor, которое указывает конструктор, а не требует, чтобы имя функции совпадало с именем контракта. Использование этого ключевого слова для указания конструкторов рекомендуется для предотвращения проблем с именованием.

Пример реального мира: Rubixi

Rubixi была еще одной финансовой пирамидой, в которой была обнаружена подобная уязвимость. Первоначально она называлась DynamicPyramid, но имя контракта было изменено перед развертыванием на Rubixi. Имя конструктора не было изменено, что позволяло любому пользователю стать создателем. Некоторые интересные обсуждения, связанные с этой ошибкой, можно найти на Bitcointalk. В конечном счете, она позволяла пользователям бороться за статус создателя, чтобы претендовать на вознаграждение от финансовой пирамиды. Более подробно об этой конкретной ошибке можно прочитать в статье "История уязвимостей безопасности Ethereum, взломов и их исправлений".

Неинициализированные указатели хранилища

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

Чтобы узнать больше о хранении и памяти в EVM, обратитесь к документации Solidity по размещению данных, расположению переменных состояния в хранилище и расположению в памяти.

Примечание: Этот раздел основан на замечательном сообщении Стефана Бейера. Дополнительную информацию по этой теме, вдохновленную Стефаном, можно найти в этой теме на Reddit.

Уязвимость

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

Рассмотрим относительно простой контракт регистратора имен в NameRegistrar.sol.

Пример 12. NameRegistrar.sol

// A locked name registrar
contract NameRegistrar {

    bool public unlocked = false;  // registrar locked, no name updates

    struct NameRecord { // map hashes to addresses
        bytes32 name;
        address mappedAddress;
    }

    // records who registered names
    mapping(address => NameRecord) public registeredNameRecord;
    // resolves hashes to addresses
    mapping(bytes32 => address) public resolve;

    function register(bytes32 _name, address _mappedAddress) public {
        // set up the new NameRecord
        NameRecord newRecord;
        newRecord.name = _name;
        newRecord.mappedAddress = _mappedAddress;

        resolve[_name] = _mappedAddress;
        registeredNameRecord[msg.sender] = newRecord;

        require(unlocked); // only allow registrations if contract is unlocked
    }
}

Этот простой регистратор имен имеет только одну функцию. Когда контракт разблокирован, он позволяет любому зарегистрировать имя (в виде хэша байт32) и сопоставить это имя с адресом. Изначально регистратор заблокирован, и require в строке 25 не позволяет register добавлять записи имен. Кажется, что контракт непригоден для использования, поскольку нет способа разблокировать регистратор! Однако существует уязвимость, которая позволяет регистрировать имена независимо от разблокированной переменной.

Чтобы обсудить эту уязвимость, сначала нужно понять, как работает хранение в Solidity. В качестве высокоуровневого обзора (без каких-либо надлежащих технических деталей - мы рекомендуем прочитать документацию Solidity для надлежащего обзора), переменные состояния хранятся последовательно в слотах по мере их появления в контракте (они могут быть сгруппированы вместе, но в данном примере это не так, поэтому мы не будем об этом беспокоиться). Таким образом, unlocked существует в слоте[0], registeredNameRecord в слоте[1], resolve в слоте[2] и т.д. Каждый из этих слотов имеет размер 32 байта (есть дополнительные сложности с отображениями, которые мы пока проигнорируем). Булева разблокировка будет выглядеть как 0x000...0 (64 0s, исключая 0x) для false или 0x000...1 (63 0s) для true. Как вы можете видеть, в этом конкретном примере имеет место значительная трата памяти.

Следующая часть головоломки заключается в том, что Solidity по умолчанию помещает сложные типы данных, такие как структуры, в хранилище при их инициализации в качестве локальных переменных. Поэтому newRecord в строке 18 по умолчанию помещается в хранилище. Уязвимость вызвана тем, что newRecord не инициализируется. Поскольку по умолчанию она используется в хранилище, она отображается на слот хранилища[0], который в настоящее время содержит указатель на unlocked. Обратите внимание, что в строках 19 и 20 мы затем устанавливаем newRecord.name в _name и newRecord.mappedAddress в _mappedAddress; это обновляет местоположение слотов хранения slot[0] и slot[1], что изменяет как unlocked, так и слот хранения, связанный с registeredNameRecord.

Это означает, что значение unlocked может быть изменено напрямую, просто с помощью параметра bytes32 _name функции register. Поэтому, если последний байт _name ненулевой, он изменит последний байт слота хранения[0] и непосредственно изменит значение unlocked на true. Такие значения _name приведут к успеху вызова require в строке 25, поскольку мы установили значение unlocked в true. Попробуйте сделать это в Remix. Обратите внимание, что функция пройдет, если вы используете _name вида: 0x0000000000000000000000000000000000000000000000000000000000000001

Профилактические методы

Компилятор Solidity выдает предупреждение о неинциализированных переменных хранения; разработчики должны обращать пристальное внимание на эти предупреждения при создании смарт-контрактов. Текущая версия Mist (0.10) не позволяет компилировать такие контракты. Часто хорошей практикой является явное использование спецификаторов памяти или хранения при работе со сложными типами, чтобы убедиться, что они ведут себя так, как ожидается.

Примеры реального мира: Медовые горшки OpenAddressLottery и CryptoRoulette

Был развернут "горшок с медом" под названием OpenAddressLottery, который использовал эту причуду неинициализированной переменной хранения для сбора эфира с потенциальных хакеров. Контракт довольно сложный, поэтому мы оставим анализ для темы на Reddit, где атака довольно четко описана.

Другой "медовый горшок", CryptoRoulette, также использовал этот трюк, чтобы попытаться собрать немного эфира. Если вы не можете понять, как работает атака, обратитесь к статье "Анализ пары контрактов Ethereum Honeypot", где приведен обзор этого контракта и других.

Плавающая точка и точность

На момент написания этой статьи (v0.4.24) Solidity не поддерживает числа с фиксированной и плавающей точкой. Это означает, что в Solidity представления с плавающей точкой должны быть построены с использованием целочисленных типов. При неправильной реализации это может привести к ошибкам и уязвимостям.

Примечание: Дополнительную информацию можно найти в вики "Техника и советы по обеспечению безопасности контрактов Ethereum".

Уязвимость

Поскольку в Solidity нет типов с фиксированной точкой, разработчикам приходится реализовывать свои собственные, используя стандартные целочисленные типы данных. В этом процессе разработчики могут столкнуться с рядом подводных камней. В этом разделе мы постараемся осветить некоторые из них.

Начнем с примера кода (для простоты мы проигнорируем вопросы переполнения/недополнения, рассмотренные ранее в этой главе):

contract FunWithNumbers {
    uint constant public tokensPerEth = 10;
    uint constant public weiPerEth = 1e18;
    mapping(address => uint) public balances;

    function buyTokens() external payable {
        // convert wei to eth, then multiply by token rate
        uint tokens = msg.value/weiPerEth*tokensPerEth;
        balances[msg.sender] += tokens;
    }

    function sellTokens(uint tokens) public {
        require(balances[msg.sender] >= tokens);
        uint eth = tokens/tokensPerEth;
        balances[msg.sender] -= tokens;
        msg.sender.transfer(eth*weiPerEth);
    }
}

Этот простой контракт на покупку/продажу токенов имеет некоторые очевидные проблемы. Хотя математические расчеты для покупки и продажи токенов верны, отсутствие чисел с плавающей точкой даст ошибочные результаты. Например, при покупке жетонов в строке 8, если значение меньше 1 эфира, начальное деление будет равно 0, а результат конечного умножения будет равен 0 (например, 200 вэев, деленные на 1e18 вэев за один этер, равны 0). Аналогично, при продаже жетонов любое количество жетонов меньше 10 также будет равно 0 эфиров. На самом деле, округление здесь всегда в меньшую сторону, поэтому при продаже 29 жетонов получится 2 эфира.

Проблема с этим контрактом заключается в том, что точность указана только с точностью до ближайшего эфира (т.е. 1e18 вей). Это может вызвать затруднения при работе с десятичными числами в токенах ERC20, когда требуется более высокая точность.

Профилактические методы

Соблюдение точности в ваших смарт-контрактах очень важно, особенно при работе с коэффициентами и ставками, которые отражают экономические решения. Вы должны убедиться, что используемые вами коэффициенты или ставки позволяют использовать большие числители в дробях. Например, в нашем примере мы использовали коэффициент tokensPerEth. Лучше было бы использовать weiPerTokens, что было бы большим числом. Чтобы вычислить соответствующее количество лексем, мы могли бы сделать msg.value/weiPerTokens. Это дало бы более точный результат.

Еще одна тактика, о которой следует помнить, - это порядок операций. В нашем примере расчет для покупки токенов был msg.value/weiPerEthtokenPerEth. Обратите внимание, что деление происходит перед умножением. (Solidity, в отличие от некоторых языков, гарантирует выполнение операций в том порядке, в котором они записаны). Этот пример достиг бы большей точности, если бы при вычислении сначала выполнялось умножение, а затем деление; т.е. msg.valuetokenPerEth/weiPerEth.

Наконец, при определении произвольной точности для чисел может быть хорошей идеей преобразовать значения в более высокую точность, выполнить все математические операции, затем окончательно преобразовать обратно до точности, необходимой для вывода. Обычно используются uint256 (поскольку они оптимальны для использования в газовой среде); они дают примерно 60 порядков величины в своем диапазоне, часть из которых может быть выделена для точности математических операций. Может оказаться так, что лучше хранить все переменные с высокой точностью в Solidity и конвертировать их обратно в более низкую точность во внешних приложениях (по сути, так работает переменная decimals в контрактах с токенами ERC20). Чтобы увидеть пример того, как это можно сделать, мы рекомендуем посмотреть на DS-Math. В нем используется несколько странное именование ("wads" и "rays"), но концепция полезна.

Пример реального мира: Ethstick

В контракте Ethstick не используется расширенная точность; однако он имеет дело с wei. Поэтому у этого контракта будут проблемы с округлением, но только на уровне точности wei. У него есть и более серьезные недостатки, но они связаны с трудностями получения энтропии в блокчейне (см. "Иллюзия энтропии"). Для дальнейшего обсуждения контракта Ethstick мы отсылаем вас к другой статье Питера Вессенеса "Контракты Ethereum станут конфеткой для хакеров".

Аутентификация Tx.Origin

В Solidity есть глобальная переменная tx.origin, которая проходит через весь стек вызовов и содержит адрес учетной записи, которая первоначально отправила вызов (или транзакцию). Использование этой переменной для аутентификации в смарт-контракте делает контракт уязвимым для фишинговой атаки.

Примечание: Для дальнейшего чтения см. вопросы dbryson на Ethereum Stack Exchange, "Tx.Origin и Ethereum Oh My!" Питера Вессенеса и "Solidity: Атаки на Tx Origin" Криса Ковердейла.

Уязвимость

Контракты, авторизующие пользователей с помощью переменной tx.origin, обычно уязвимы к фишинговым атакам, которые могут обманом заставить пользователей выполнить аутентифицированные действия в уязвимом контракте.

Рассмотрим простой контракт в Phishable.sol.

Пример 13. Phishable.sol

contract Phishable {
    address public owner;

    constructor (address _owner) {
        owner = _owner;
    }

    function () external payable {} // collect ether

    function withdrawAll(address _recipient) public {
        require(tx.origin == owner);
        _recipient.transfer(this.balance);
    }
}

Обратите внимание, что в строке 11 контракт разрешает функцию withdrawAll с использованием tx.origin. Этот контракт позволяет злоумышленнику создать атакующий контракт вида:

import "Phishable.sol";

contract AttackContract {

    Phishable phishableContract;
    address attacker; // The attacker's address to receive funds

    constructor (Phishable _phishableContract, address _attackerAddress) {
        phishableContract = _phishableContract;
        attacker = _attackerAddress;
    }

    function () payable {
        phishableContract.withdrawAll(attacker);
    }
}

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

В любом случае, если жертва отправит транзакцию с достаточным количеством газа на адрес AttackContract, она вызовет функцию fallback, которая, в свою очередь, вызовет функцию withdrawAll контракта Phishable с параметром attacker. Это приведет к выводу всех средств из контракта Phishable на адрес атакующего. Это происходит потому, что адрес, который первым инициализировал вызов, был жертвой (т.е. владельцем контракта Phishable). Поэтому tx.origin будет равен owner и требование в строке 11 контракта Phishable пройдет.

Профилактические методы

tx.origin не следует использовать для авторизации в смарт-контрактах. Это не означает, что переменная tx.origin никогда не должна использоваться. У нее есть несколько законных случаев использования в смарт-контрактах. Например, если нужно запретить внешним контрактам вызывать текущий контракт, можно реализовать требование вида require(tx.origin == msg.sender). Это предотвращает использование промежуточных контрактов для вызова текущего контракта, ограничивая контракт обычными адресами без кода.

Библиотеки по контракту

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

В Ethereum наиболее широко используемым ресурсом является набор OpenZeppelin - обширная библиотека контрактов, начиная от реализации токенов ERC20 и ERC721, многих видов моделей краудсейла и заканчивая простыми поведенческими характеристиками, часто встречающимися в контрактах, такими как Ownable, Pausable или LimitBalance. Контракты в этом репозитории были тщательно протестированы и в некоторых случаях даже функционируют как де-факто стандартные реализации. Они бесплатны для использования, и создаются и поддерживаются Zeppelin вместе с постоянно растущим списком внешних авторов.

Также Zeppelin представляет ZeppelinOS - платформу с открытым исходным кодом, включающую сервисы и инструменты для безопасной разработки и управления приложениями смарт-контрактов. ZeppelinOS представляет собой слой поверх EVM, который упрощает разработчикам запуск обновляемых DApps, связанных с библиотекой проверенных контрактов на цепочке, которые сами могут обновляться. Различные версии этих библиотек могут сосуществовать на платформе Ethereum, а система поручительства позволяет пользователям предлагать или продвигать улучшения в различных направлениях. Платформа также предоставляет набор внецепочечных инструментов для отладки, тестирования, развертывания и мониторинга децентрализованных приложений.

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

  • Веб-сайт: https://www.ethpm.com/
  • Ссылка на репозиторий: https://www.ethpm.com/registry
  • Ссылка на GitHub: https://github.com/ethpm
  • Документация: https://www.ethpm.com/docs/integration-guide

Выводы

Любой разработчик, работающий в сфере смарт-контрактов, должен многое знать и понимать. Следуя лучшим практикам при разработке и написании кода смарт-контракта, вы сможете избежать многих серьезных подводных камней и ловушек. Возможно, самым фундаментальным принципом безопасности программного обеспечения является максимальное повторное использование доверенного кода. В криптографии этот принцип настолько важен, что он был сжат в поговорку: "Не создавай свою собственную криптографию". В случае смарт-контрактов это означает использование как можно большего количества свободно доступных библиотек, которые были тщательно проверены сообществом.