Договоры в цифровую эпоху

15.07.2022 Автор: Михаил Степанов

Основы работы и безопасности смарт-контрактов

Что такое смарт-контракт

Смарт-контракты или “умные контракты”, как и любой контракт, определяют условия сделки. Однако «умным» контракт делают условия, которые устанавливаются и выполняются как код, работающий в Интернете, в сети блокчейн, а не условия по документу, заключенному с помощью юриста.

Смарт-контракты расширяют основную идею криптовалют – отправку и получение денег без «доверенного посредника». И поскольку смарт-контракты работают в блокчейне, например, Ethereum, они гарантируют безопасность, надежность и доступность без каких-либо ограничений.

Практическое применение смарт-контрактов

people at the computer

Смарт-контракты позволяют разработчикам создавать широкий спектр децентрализованных программ и токенов – аналогов ценных бумаг в виртуальном мире. “Умные” контракты используются для многих задач: от обмена криптовалют и новых финансовых инструментов до логистики и игр. Смарт-контракты хранятся в блокчейне, как и любая крипто-транзакция.

С момента изобретения смарт-контрактов и по сей день Ethereum является самой популярной платформой для смарт-контрактов, но их также могут выполнять многие другие платформы. Такие альтернативные блокчейны включают, например, Solana, EOS, Neo, Tezos, Tron, Polkadot и Algorand. Смарт-контракты пишутся на разных языках программирования: Solidity, Web Assembly, Michelson, Rust, Vyper, Yul, DAML, JavaScript, Move, Bitcoin Script, Golang, C/C++, C#, Haskell, Clarity и т.д.

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

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

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

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

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

pragma solidity ^0.8.9;
contract Inbox {
    string public message;

    constructor(string memory initialMessage) {
        message = initialMessage;
    }

    function setMessage(string memory newMessage) public {
        message = newMessage;
    }

    function getMessage() public view returns(string) {
       return message;
    }
}

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

smartphone password

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

К примеру, блокчейн-сеть Ronin Network, связанная с популярной игрой Axie Infinity, потеряла 625 миллионов долларов США в результате хакерской атаки.

Еще один известный случай получил название “DAO Hack”. Хакер, обнаружив уязвимость, украл 3,6 миллиона эфира (60 миллионов долларов), воспользовавшись резервной функцией в коде, обладающем уязвимостью типа «Reentrancy».

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

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

  • Реестр классификации слабых мест смарт-контрактов (SWC Registry) является реализацией схемы классификации слабых мест, предложенной в EIP-1470 (предложения по улучшению Ethereum). Он частично согласован с терминологией и структурой, используемой в Common Weakness Enumeration (CWE), и одновременно описывает широкий спектр вариантов уязвимостей, характерных для смарт-контрактов.
  • Decentralized Application Security Project (или DASP) Top 10 of 2018 – проект является инициативой NCC Group. Это открытый совместный проект для объединения усилий по обнаружению уязвимостей смарт-контрактов в сообществе крипто-энтузиастов.

Виды уязвимостей смарт-контрактов

1) Reentrancy – повторный вход. Атака Reentrancy является одной из самых разрушительных атак в смарт-контрактах Solidity. Атака повторного входа происходит, когда функция осуществляет внешний вызов к другому, ненадежному контракту. Затем ненадежный контракт делает рекурсивный вызов к исходной функции, пытаясь исчерпать все денежные средства. Если контракт не может обновить свое состояние перед отправкой денежных средств, злоумышленник может постоянно вызывать функцию вывода, чтобы исчерпать денежные средства контракта. 

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

Пример сценария атаки повторного входа:

scheme

Атака повторного входа реализуется с участием двух смарт-контрактов: контракт жертвы (A), содержащий уязвимую функцию, и контракт злоумышленника (B).

  1. Контракт A вызывает функцию приема средств в контракте B – receivefunds.
  2. Тогда контракт B сразу снова обращается к контракту A, пока функции передачи средств (sendfunds) и обновления баланса (updatebalance) еще не завершились. Этот шаг выполняется максимально быстро и максимальное количество раз.

Функция fallback контракта B повторно вызывает контракт A для злоупотребления порядком вызова функции updatebalance, обновляющей  баланс смарт-контракта. Таким образом, функция fallback успевает вызвать функции checkbalance и sendfunds несколько раз, прежде чем выполнится функция updatebalance.

Ниже приведен пример смарт-контракта, уязвимого к Reentrancy.

contract EtherStore {
    mapping(address => uint) public balances;

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

    function withdraw() public {
        uint bal = balances[msg.sender];
        require(bal > 0);

        (bool sent, ) = msg.sender.call{value: bal}("");
        require(sent, "Failed to send Ether");

        balances[msg.sender] = 0;
    }

    // Helper function to check the balance of this contract
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

Злоумышленник будет вызывать функцию widthdraw(), пока баланс контракта не иссякнет, то есть, пока не станет равен нулю.

2) Arithmetic Overflow and Underflow – арифметические переполнения и антипереполнения. Простыми словами, переполнение – это ситуация, когда uint (целое число без знака) превышает свой размер в байтах. Тогда следующее увеличение переменной вернет её в начальное состояние. Например, наибольшее число, которое может хранить тип uint8, состоящий из 8 бит, это двоичное 11111111 (в десятичном 2^8 – 1 = 255). Вот пример кода:

uint8 balance = 255;
balance++;

Если выполнить этот код, баланс станет равным 0. Это простой пример переполнения. Если добавить 1 к двоичному 11111111, он сбрасывается в 00000000.

В противоположном случае, если от числа uint8, равного 0, вычесть 1, то оно станет равно 255.

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

3) Source of Randomness – источник случайности.

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

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

function guess(uint _guess) public {
  uint answer = uint(
    keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))
  );
}

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

4) Denial of Service – отказ от обслуживания. Существует множество способов, как можно вывести смарт-контракт из строя и прервать его нормальную работу. Общего метода поиска таких уязвимостей нет, поскольку каждый контракт имеет свою логику работы. Несмотря на это, можно выделить несколько векторов атак, которые могут привести к эксплуатации уязвимостей типа denial of service.

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

function distribute() public {
    require(msg.sender == owner); // only owner
    for(uint i = 0; i < investors.length; i++) {
        transferToken(investors[i],investorTokens[i]);
    }
}

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

5) Unchecked Call Return Value – непроверенное значение возврата вызова. Основная идея этого типа уязвимости состоит в том, что в контракте отсутствует проверка возвращаемого значения в сообщении после вызова внешнего контракта. В результате контракт продолжит работу, даже если другой вызванный им контракт ответил на вызов некорректно. Это может привести к непредсказуемому поведению алгоритма в будущем. В Solidity используются несколько низкоуровневых методов вызова, работающих с необработанными адресами: call, callcode, delegatecall и 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);
     }
 }

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

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

Как описано в документации Solidity, существует четыре типа видимости функций:

  • external
  • public
  • internal
  • private

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

Public-функции являются частью интерфейса контракта и могут быть вызваны внутренне или через сообщения.

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

Частные функции видны только для контракта, в котором они определены, а не в производных контрактах.

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

contract HashForEther {

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

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

В примере, приведенном выше, – простая игра, в которой, чтобы получить баланс, пользователь должен создать адрес Ethereum, последние 8 шестнадцатеричных символов которого равны 0.

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

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

Как избежать уязвимостей смарт-контрактов

hacked

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

1. Reentrancy. Есть несколько методов, которые помогают избегать появления уязвимостей типа «Reentrancy» в смарт-контрактах. Первый из них – использовать функцию transfer() и везде, где необходимо, отправлять эфир на балансы внешних контрактов. Использование этой функции позволит ограничить количество газа до 2300 единиц, что сделает невозможным повторный вызов контракта получателем.

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

Для примера уязвимого смарт-контракта, приведенного в настоящей статье, достаточно изменить порядок вызова функций в Контракте А:

  1. checkbalance() – проверка баланса смарт-контракта
  2. updatebalance() обновление баланса смарт-контракта
  3. sendfunds() отправка денежных средств

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

bool internal locked;
modifier noReentrant() {
    require(!locked, "No re-entrancy");
    locked = true;
    _;
    locked = false;
}

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

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract ExampleName as ReentrancyGuard{
    function attack() external payable nonReentrant() {
        require(msg.value >= 1 ether);
        depositFunds.deposit {value: 1 ether} ();
        depositFunds.withdraw();
    }
}

2. Integer overflow and underflow. Самым эффективным методом защиты от атаки переполнения является создание собственных или использование посторонних открытых библиотек, заменяющих стандартные математические операции, такие как сложение, вычитание и умножение. Операции деления практически неуязвимы для этой атаки, поскольку Ethereum Virtual Machine выдает ошибку при делении на 0.

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

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

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

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;
  }

Также можно использовать библиотеку OpenZeppelin по аналогии с описанием в рекомендациях по Re-entrancy.

3. Source of randomness. Необходимо избегать использования временных меток блоков для генерации случайных чисел или хотя бы не использовать их в критических местах, таких как определение победителя, изменение баланса контракта и т.д.

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

4. Denial of Service. Необходимо избегать использования циклов данных, которые могут манипулироваться пользователями. Рекомендуется создать шаблон функции вывода денежных средств, где каждый из инвесторов может вызвать ее независимо от других.

В случае, когда продление работы контракта зависит от его владельца, необходимо разработать отказоустойчивый механизм его работы так, чтобы даже при недееспособности владельца контракт продолжал работать. Одним из возможных решений является создание “многоподписного” контракта, в котором даже при ликвидации одного из владельцев, все остальные имели бы права для управления состоянием контракта. Также можно создать механизм, который автоматически продолжит работу контракта через предварительно определенный промежуток времени без вмешательства владельца.

5. Unchecked Call return value. Необходимо использовать функцию transfer() вместо send() везде, где это возможно. Функция transfer() отменит транзакцию, если передача денежных средств не состоялась. Если использование функции send() все же необходимо, нужно проверять возвращаемое значение.

6. Visibility. Хорошая практика – всегда указывать видимость функций, даже если они должны быть публичными. Это поможет избежать непреднамеренного присвоения ложного типа видимости. Новые версии Solidity выдают ошибку при компиляции, если функциям не присвоен ни один из атрибутов видимости.

Вывод

lock

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

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

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

Подпишитесь на наш канал Телеграм, чтобы не пропустить новые статьи нашего блога.

Другие посты

10/11/2024
Как защитить и научить защищать входы в системы
10/10/2024
Обзор современных языков программирования и блокчейнов для смарт-контрактов