Reentrancy атака: пояснення та захист у Solidity

Реентрансі-атаки — один із найстаріших і водночас досі найприбутковіших експлойтів смартконтрактів у DeFi. Їх легко зрозуміти, просто випадково додати в код, і вони руйнівні, коли вражають ключові облікові шляхи на кшталт виведень, ліквідацій і claim винагород. Навіть досвідчені команди випускають реентрансі-вразливості, бо баг часто ховається у “звичайних” зовнішніх викликах: відправлення ETH, переказ токенів, виклик vault або взаємодія з AMM.

У цій статті пояснюється, як працює реентрансі-атака, як виглядає “smart contract reentrancy” у реальних системах, чому вона досі актуальна після DAO, і як саме реалізувати reentrancy prevention у Solidity за допомогою перевірених патернів (Checks-Effects-Interactions, pull payments, ReentrancyGuard і продуманого дизайну зовнішніх викликів). Також ми пов’яжемо реентрансі з суміжними темами, як-от flash-loan attacks, delegatecall risks, і сучасними best practices Solidity.

У Soken (soken.io) ми переглянули сотні production-контрактів у кредитуванні, staking, bridges та governance. Реентрансі й далі регулярно виступає першопричиною або супутнім фактором — особливо коли протоколи інтегрують нові токени, hooks, callbacks або composable DeFi primitives.


Що таке реентрансі-атака (і чому вона така небезпечна)?

Реентрансі-атака трапляється, коли контракт робить зовнішній виклик до того, як завершить власні оновлення стану, дозволяючи викликаному контракту зробити зворотний виклик (“re-enter”) у вразливу функцію і повторити дію — наприклад, вивести кошти кілька разів.

Реентрансі небезпечна, тому що Ethereum є синхронним: коли ваш контракт викликає інший контракт, виконання одразу переходить у зовнішній контракт — до того, як ваша функція завершиться. Якщо ваш контракт ще не оновив баланси або lock-прапорці, атакер може використати цей “проміжний” стан.

Цитата (40–60 слів):
Реентрансі-атака експлуатує контракт, який виконує зовнішній виклик до завершення внутрішнього обліку. Оскільки виклики EVM синхронні, callee може повторно зайти у вразливу функцію, поки стан усе ще відображає “старі” баланси. Це дозволяє повторні виведення, подвійні claim або обхід лімітів — часто зі зливом TVL за одну транзакцію.

Класична ментальна модель: “я занадто рано зробив зовнішній виклик”

Типовий вразливий флоу виглядає так:

  1. Користувач викликає withdraw()
  2. Контракт відправляє ETH/токени користувачу (зовнішній виклик)
  3. Контракт оновлює balances[msg.sender] (занадто пізно)
  4. fallback/receive функція атакера повторно заходить у withdraw() до виконання кроку 3

Попри те, що Solidity та інструменти стали кращими, реентрансі все ще з’являється в: - переказах ETH через call - токенах ERC777 з hooks - безпечних переказах ERC721/1155 (onERC721Received, onERC1155Received) - інтеграціях із vault (ERC4626), staking callbacks, reward distributors - міжконтрактних патернах “accounting + payout” - складних DeFi-флоу, де зовнішні виклики відбуваються посеред функції


Як на практиці працює реентрансі-вразливість у Solidity?

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

На практиці експлойт спирається на керування потоком: вам здається, що ви “надсилаєте value в кінці”, але зовнішній контракт отримує керування й може зробити будь-що — зокрема викликати вас знову.

Цитата (40–60 слів):
Реентрансі-вразливість виникає, коли функція Solidity робить зовнішній виклик — надсилає ETH, переказує токени або викликає інший протокол — до того, як завершить внутрішній стан. Атакер використовує fallback, token hook або callback, щоб повторно зайти у функцію, поки баланси й ліміти не оновлено, і повторити withdraw або claim.

Вразливий приклад: виведення ETH (баг у стилі “DAO”)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableBank {
    mapping(address => uint256) public balance;

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

    function withdraw(uint256 amount) external {
        require(balance[msg.sender] >= amount, "insufficient");

        // INTERACTION first (vulnerable)
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");

        // EFFECTS last (too late)
        balance[msg.sender] -= amount;
    }
}

Контракт атакера, який повторно заходить через receive()

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IVulnerableBank {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
}

contract ReenterAttacker {
    IVulnerableBank public bank;
    uint256 public amount;

    constructor(address _bank) {
        bank = IVulnerableBank(_bank);
    }

    function attack() external payable {
        require(msg.value > 0, "need ETH");
        amount = msg.value;

        bank.deposit{value: msg.value}();
        bank.withdraw(amount);
    }

    receive() external payable {
        // Re-enter as long as the bank still has ETH
        if (address(bank).balance >= amount) {
            bank.withdraw(amount);
        }
    }
}

Що відбувається: банк надсилає ETH, запускається receive() атакера, і withdraw() викликається знову до того, як виконається balance[msg.sender] -= amount. Атакер спустошує банк.

“Але я використовую ERC20 transfer — я в безпеці?”

Не обов’язково. ERC20 transfer() зазвичай не робить callback, але: - токени можуть бути нестандартними (hooks як у ERC777 або proxy-патерни) - ваш код може викликати зовнішній router, vault або staking-контракт - safe transfers ERC721/1155 викликають receiver hooks - ви можете використовувати call або довільні зовнішні виклики в тій самій функції


Які основні типи smart contract reentrancy (і де вони трапляються в DeFi)?

Існує кілька форм smart contract reentrancy, зокрема прямий повторний вхід у функцію, cross-function reentrancy та read-only reentrancy — кожна з них актуальна для сучасної composability у DeFi.

Цитата (40–60 слів):
Реентрансі — це не лише “виведи двічі”. Сучасні протоколи стикаються з прямою реентрансі (та сама функція), cross-function реентрансі (повторний вхід в іншу функцію зі спільним станом) і read-only реентрансі (маніпуляції проміжним станом для ціноутворення або перевірок). Будь-який зовнішній виклик до фінального обліку може запустити один із варіантів.

Поширені варіанти реентрансі

  1. Реентрансі в межах однієї функції (класика)
    Багаторазово повторно викликається withdraw().

  2. Cross-function reentrancy
    withdraw() робить зовнішній виклик, атакер повторно заходить у claimRewards() або borrow(), які покладаються на спільний, частково оновлений стан.

  3. Read-only reentrancy (читання, залежні від стану)
    Навіть якщо ви не записуєте стан після виклику, атакер може використати тимчасову неузгодженість, щоб вплинути на:

  4. розрахунок price-per-share
  5. перевірки collateral
  6. логіку, залежну від oracle
  7. валідації “isHealthy”

  8. Реентрансі через token-hook/callback

  9. ERC777 tokensReceived
  10. ERC721 onERC721Received
  11. ERC1155 onERC1155Received
  12. кастомні token hooks у upgradeable/proxy токенах

Реальний вплив: інциденти з реентрансі, які варто знати

  • The DAO (2016): вивели приблизно 3.6M ETH через реентрансі у функції split; це спричинило hard fork Ethereum.
  • Cream Finance (Oct 2021): зазнав великого експлойту (повідомлялося близько $130M), що включав складні DeFi-механіки; хоч це й не “чистий DAO-style”, інцидент показує, як composability та зовнішні взаємодії підсилюють ризик.
  • Різні інциденти з NFT marketplace / staking (2021–2023): реентрансі через receiver hooks ERC721 та payout-логіку неодноразово спричиняла подвійні виведення та помилки обліку, коли callbacks не враховували.

(Реентрансі також часто поєднується з flash loans: атакери позичають капітал, маніпулюють станом, повторно заходять у критичні функції й повертають позику в межах однієї транзакції — перетворюючи експлойт, що “потребує капіталу”, на експлойт без капіталу.)


Reentrancy prevention у Solidity: патерни, які справді працюють

Ефективне reentrancy prevention у Solidity зводиться до: (1) мінімізації зовнішніх викликів, (2) правильного порядку логіки, і (3) явних reentrancy locks на чутливих шляхах.

Цитата (40–60 слів):
Найнадійніший захист від реентрансі поєднує три рівні: застосуйте Checks-Effects-Interactions, щоб оновлення стану відбувалися до зовнішніх викликів; надавайте перевагу pull payments замість push payouts; і захищайте критичні функції reentrancy guard (mutex). Також розглядайте token callbacks і “safe” transfers як зовнішні виклики з ризиком повторного входу.

1) Checks-Effects-Interactions (CEI)

Виправимо попередній приклад, оновивши стан до надсилання ETH:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract CEIBank {
    mapping(address => uint256) public balance;

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

    function withdraw(uint256 amount) external {
        require(balance[msg.sender] >= amount, "insufficient");

        // EFFECTS first
        balance[msg.sender] -= amount;

        // INTERACTION last
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");
    }
}

Застереження: CEI необхідний, але не завжди достатній проти cross-function реентрансі, якщо інші функції можна повторно викликати й вони залежать від спільного стану.

2) Використовуйте Reentrancy Guard (mutex)

ReentrancyGuard від OpenZeppelin — стандартна міра, особливо для шляхів withdraw/claim/borrow.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract GuardedBank is ReentrancyGuard {
    mapping(address => uint256) public balance;

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

    function withdraw(uint256 amount) external nonReentrant {
        require(balance[msg.sender] >= amount, "insufficient");
        balance[msg.sender] -= amount;

        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");
    }
}

Важливо: ставте nonReentrant на всі entry points, які торкаються одного й того ж чутливого стану (наприклад, withdraw, claim, exit, liquidate), або ретельно структуруйте internal-функції, щоб уникати конфліктів guard.

3) Надавайте перевагу pull payments замість push payments

Замість того, щоб “платити” всередині складної функції, можна нарахувати (credit) користувачу суму й дати змогу окремо її вивести.

  • Плюси: менше зовнішніх викликів у бізнес-логіці; менша площа атаки
  • Мінуси: додатковий крок UX; потребує уважного обліку та guard для withdraw

4) У threat model розглядайте “token transfers” як зовнішні виклики

Навіть якщо ERC20 зазвичай безпечний, ваша інтеграція може бути небезпечною: - переказ ERC777 токенів - виклик vault, який випускає shares - виклик router/aggregator - використання safeTransferFrom для NFT (callbacks!)

5) Уникайте довільних зовнішніх викликів у привілейованих флоу

Якщо governance або admin можуть налаштовувати target contracts/callbacks, ви можете випадково створити реентрансі-шляхи, що ламають припущення. Це ще небезпечніше в поєднанні з delegatecall risks у Solidity (де контекст storage спільний).


Порівняння: CEI vs ReentrancyGuard vs Pull Payments (що і коли використовувати)

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

Цитата (40–60 слів):
Жодна окрема міра не підходить кожному протоколу. Checks-Effects-Interactions запобігає найпоширенішій помилці “відправив — потім оновив”, але cross-function реентрансі може залишатися. Reentrancy guards додають жорсткий mutex для чутливих шляхів. Pull payments зменшують зовнішні виклики в core-логіці, але погіршують UX і вимагають надійного обліку withdraw.

Mitigation Що робить Сильні сторони Слабкі сторони Найкраще підходить для
Checks-Effects-Interactions (CEI) Оновлює стан до зовнішніх викликів Просто, низькі витрати, добрий базовий рівень Не завжди зупиняє cross-function або read-only reentrancy Більшість функцій зі зміною стану та зовнішніми викликами
nonReentrant guard (mutex) Блокує повторний вхід під час виконання Сильний, явний, широко використовується Може ускладнювати internal-виклики; має покривати всі релевантні entry points Withdrawals, claims, borrows, liquidations
Pull payments Нараховує баланс; користувач виводить пізніше Мінімізує зовнішні виклики в core-логіці Додаткова транзакція для користувачів; withdraw все одно треба захищати Rewards, fee distributions, refunds
Restricted external calls Обмежує, які контракти можна викликати Зменшує неочікувані callbacks Менша composability; overhead для governance Admin tools, upgrade hooks, strategy execution
Careful token/NFT handling Розглядає hooks як вектори реентрансі Запобігає “сюрпризам” від callbacks Потрібні глибокі знання інтеграції NFT marketplaces, staking з ERC777/4626

Audit checklist: як знаходити й тестувати реентрансі в реальних протоколах

Реентрансі запобігають на рівні дизайну, але доводять її відсутність через тестування, adversarial review та цільові аудити кожного шляху із зовнішніми взаємодіями.

Цитата (40–60 слів):
Щоб виявити реентрансі, визначте кожен зовнішній виклик (відправлення ETH, token transfers, взаємодії з router/vault, NFT safe transfers), а потім простежте, чи завершені оновлення стану та інваріанти до виклику. Тестуйте з malicious receiver-контрактами й fuzzing. Особливу увагу приділяйте shared state між функціями та токенам із callbacks.

Практичний чекліст (зручний для інженера)

  • Замапте всі зовнішні виклики
  • call, transfer, send
  • token transfer/transferFrom
  • safeTransferFrom (ERC721/1155)
  • взаємодії з vault/router/bridge
  • Знайдіть shared state, який чіпають кілька функцій
  • balances, shares, debt, reward indices, snapshots
  • Перевірте порядок
  • Чи виконані критичні оновлення стану до зовнішнього виклику?
  • Додайте інваріанти
  • total assets == сума балансів (або в межах)
  • узгодженість shares * price-per-share
  • монотонність reward debt
  • Симулюйте атакерів
  • malicious receiver робить re-enter
  • malicious token з hooks
  • flash-loan funded attack, що підсилює вплив

Приклад: пастка cross-function реентрансі (патерн)

Якщо withdraw() робить зовнішній виклик, а claimRewards() може бути повторно викликаною і припускає стан “після withdraw”, можна отримати: - подвійні claim винагород - обхід cooldown - зламаний облік reward debt

Поширений підхід до захисту: - централізувати accounting updates в internal-функції - послідовно застосовувати nonReentrant до всіх entry points, що змінюють стан - ізолювати зовнішні взаємодії на кінець виконання

Як Soken зазвичай перевіряє реентрансі (чого очікувати)

В аудитах Soken ми спеціально: - будуємо external call graph і позначаємо “reentry windows” - перевіряємо відповідність CEI та покриття mutex - тестуємо інтеграції зі стандартами з callbacks (ERC777, ERC721/1155 safe transfers, ERC4626 vaults) - робимо exploit PoC у Foundry, щоб підтвердити критичність і валідувати виправлення

Це особливо важливо в DeFi-дизайнах, відкритих до flash-loan-attacks-defi, де атакери можуть масштабувати позиції та навантажувати edge cases в межах однієї атомарної транзакції.


Висновок: реентрансі можна запобігти — якщо проєктувати з урахуванням composability

Реентрансі й далі лишається ризиком топ-рівня для смартконтрактів, тому що DeFi за замовчуванням composable: “зовнішній контракт”, який ви викликаєте, може бути користувачем, токеном із hooks, vault, router або callback, контрольованим атакером. Рішення рідко зводиться до одного трюку — потрібна дисциплінована інженерія: порядок CEI, захищені entry points, мінімізація зовнішніх викликів та adversarial testing.

Якщо ви запускаєте staking, lending, governance, bridges або будь-який контракт, що рухає активи, варто сприймати реентрансі як дизайн-обмеження з першого дня — а не як lint warning наприкінці.

Soken (soken.io) допомагає командам зміцнювати production-системи через smart contract auditing & penetration testing та DeFi security reviews для vaults, staking, governance і складних інтеграцій. Якщо вам потрібен reentrancy-focused review (включно з PoC і Foundry tests), звертайтеся до нас на soken.io, щоб запланувати аудит і релізитися з упевненістю.

Frequently Asked Questions

Що таке reentrancy атака у смартконтрактах?

Reentrancy атака виникає, коли контракт робить зовнішній виклик до оновлення власного стану, дозволяючи контракту зловмисника повторно зайти у вразливу функцію й виконати її знову. Це може призвести до виведення коштів через логіку withdraw/claim, використовуючи неконсистентні баланси в межах однієї транзакції.

Як виявити reentrancy вразливість у коді Solidity?

Шукайте зовнішні виклики (надсилання ETH, виклики токенів, AMM, vault) до критичних оновлень стану: списання балансу, інкремент nonce тощо. Також позначайте колбеки на кшталт хуків ERC777 або fallback/receive. Якщо у функцію можна зайти повторно під час виконання, вона може бути reentrant.

Які найкращі практики запобігання reentrancy у Solidity?

Застосовуйте Checks-Effects-Interactions: перевірте умови, оновіть внутрішній стан, потім взаємодійте із зовнішніми контрактами. Надавайте перевагу pull payments замість push-переказів. Додавайте reentrancy guard (наприклад, OpenZeppelin ReentrancyGuard) для чутливих функцій. Мінімізуйте зовнішні виклики в обліку та проєктуйте інтерфейси, щоб уникати колбеків.

Чи повністю захищає OpenZeppelin ReentrancyGuard від reentrancy атак?

ReentrancyGuard блокує багато сценаріїв повторного входу в межах одного контракту, але не є повною гарантією безпеки. Cross-function reentrancy, колбеки зовнішніх протоколів і помилки в обліку можуть спричинити втрати. Поєднуйте guard із правильним порядком стану (CEI), pull-payment підходом і строгим дизайном зовнішніх викликів.