Реентрансі-атаки — один із найстаріших і водночас досі найприбутковіших експлойтів смартконтрактів у 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 за одну транзакцію.
Класична ментальна модель: “я занадто рано зробив зовнішній виклик”
Типовий вразливий флоу виглядає так:
- Користувач викликає
withdraw() - Контракт відправляє ETH/токени користувачу (зовнішній виклик)
- Контракт оновлює
balances[msg.sender](занадто пізно) - 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 реентрансі (маніпуляції проміжним станом для ціноутворення або перевірок). Будь-який зовнішній виклик до фінального обліку може запустити один із варіантів.
Поширені варіанти реентрансі
-
Реентрансі в межах однієї функції (класика)
Багаторазово повторно викликаєтьсяwithdraw(). -
Cross-function reentrancy
withdraw()робить зовнішній виклик, атакер повторно заходить уclaimRewards()абоborrow(), які покладаються на спільний, частково оновлений стан. -
Read-only reentrancy (читання, залежні від стану)
Навіть якщо ви не записуєте стан після виклику, атакер може використати тимчасову неузгодженість, щоб вплинути на: - розрахунок price-per-share
- перевірки collateral
- логіку, залежну від oracle
-
валідації “isHealthy”
-
Реентрансі через token-hook/callback
- ERC777
tokensReceived - ERC721
onERC721Received - ERC1155
onERC1155Received - кастомні 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, щоб запланувати аудит і релізитися з упевненістю.