Угоди в цифрову епоху

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

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

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

Смарт-контракти або “розумні контракти”, як і будь-який контракт, визначають умови угоди. Однак, “розумним” контракт роблять умови, які встановлюються та виконуються як код, що працює в Інтернеті, в мережі блокчейн, а не умови за документом, який укладено за допомогою юриста. 

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

Практичне застосування смарт-контрактів

service

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

З моменту винаходження смарт-контрактів і до сьогодні 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;
    }
}

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

service

Незважаючи на високу стійкість алгоритмів розподіленої мережі блокчейн до злому, випадки, коли користувачі втрачають величезну кількість коштів через дії зловмисників, все ж трапляються. Це відбувається через помилки при розробці смарт-контрактів – так звані вразливості. 

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

Ще один відомий випадок отримав назву “DAO Hack”. Хакер, знайшовши вразливість, вкрав 3,6 мільйона ефіру (60 мільйонів доларів), скориставшись резервною функцією в коді, який мав вразливість типу «Reentrancy». 

У цих прикладах зловмисники використали відкритість та прозорість алгоритмів роботи смарт-контрактів. Перевіривши вихідний код контрактів, хакери знайшли вразливості та успішно проексплуатували їх.

У загальному випадку, для класифікації вразливостей смарт-контрактів використовується два реєстри, що були розроблені крипто-ентузіастами та експертами з безпеки:

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

Види вразливостей смарт-контрактів

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

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

Приклад сценарію атаки повторного входу:

service

Атака повторного входу реалізується за участі двох смарт-контрактів: контракт жертви (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 не вказано, а отже, в результаті кожен може викликати цю функцію (режим за замовчуванням загальнодоступний) і отримати баланс.

Перераховані вище вразливості смарт-контрактів не є вичерпними. Для повної оцінки безпеки смарт-контракту, виявлення слабких сторін та потенційних вразливостей скористайтеся аудитом смарт-контрактів.

Як уникнути вразливостей смарт-контрактів

service

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

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

Висновок

service

Таким чином, ми розглянули основи роботи й безпеки смарт-контрактів. Ми навели кілька прикладів уразливостей смарт-контрактів та методів зменшення ризиків цих уразливостей. Дізнайтеся більше про аудит смарт-контрактів, щоб покращити ваше уявлення про їхню безпеку.

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

Якщо у вас виникли додаткові питання з безпеки смарт-контрактів, або інші питання у сфері безпеки Web3 і блокчейн, ви можете отримати відповідь від кваліфікованих спеціалістів у рамках нашої індивідуальної консультації.

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

Інші записи

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