Attacco reentrancy: prevenzione in Solidity

Gli attacchi di reentrancy sono tra gli exploit di smart contract più vecchi — e ancora oggi tra i più redditizi — in DeFi. Sono semplici da capire, facili da introdurre per errore e devastanti quando colpiscono percorsi di accounting core come prelievi, liquidazioni e richieste di reward. Anche team esperti rilasciano vulnerabilità di reentrancy perché il bug spesso si nasconde in chiamate esterne “normali”: inviare ETH, trasferire token, chiamare un vault o interagire con un AMM.

Questo articolo spiega come funziona un attacco di reentrancy, che aspetto ha la “smart contract reentrancy” nei sistemi reali, perché è ancora rilevante nel post-DAO e come implementare in modo preciso la prevenzione della reentrancy in Solidity usando pattern collaudati (Checks-Effects-Interactions, pull payments, ReentrancyGuard e progettazione attenta delle chiamate esterne). Collegheremo inoltre la reentrancy a temi adiacenti come flash-loan attacks, delegatecall risks e le best practice moderne di Solidity.

In Soken (soken.io), abbiamo revisionato centinaia di contratti in produzione tra lending, staking, bridge e governance. La reentrancy resta una causa ricorrente (o un fattore contributivo) — soprattutto quando i protocolli integrano nuovi token, hook, callback o primitive DeFi componibili.


Cos’è un attacco di reentrancy (e perché è così pericoloso)?

Un attacco di reentrancy avviene quando un contratto effettua una chiamata esterna prima di completare i propri aggiornamenti di stato, permettendo al chiamato di richiamare (“rientrare”, re-enter) nella funzione vulnerabile e ripetere un’azione come prelevare fondi più volte.

La reentrancy è pericolosa perché Ethereum è sincrono: quando il tuo contratto chiama un altro contratto, l’esecuzione passa immediatamente al contratto esterno — prima che la tua funzione finisca. Se il tuo contratto non ha ancora aggiornato i saldi o le flag di lock, un attaccante può sfruttare quello stato “intermedio”.

Quotable (40–60 words):
Un attacco di reentrancy sfrutta un contratto che esegue una chiamata esterna prima di completare l’accounting interno. Poiché le chiamate EVM sono sincrone, il chiamato può rientrare nella funzione vulnerabile mentre lo stato riflette ancora i saldi “vecchi”. Questo abilita prelievi ripetuti, double-claim o bypass dei limiti — spesso svuotando il TVL in una sola transazione.

Il modello mentale classico: “ho chiamato fuori troppo presto”

Un flusso tipicamente vulnerabile appare così:

  1. L’utente chiama withdraw()
  2. Il contratto invia ETH/token all’utente (chiamata esterna)
  3. Il contratto aggiorna balances[msg.sender] (troppo tardi)
  4. La funzione fallback/receive dell’attaccante rientra in withdraw() prima che venga eseguito lo step 3

Anche se Solidity e gli strumenti sono migliorati, la reentrancy compare ancora in: - trasferimenti di ETH via call - token ERC777 con hook - safe transfer ERC721/1155 (onERC721Received, onERC1155Received) - integrazioni con vault (ERC4626), callback di staking, distributor di reward - pattern cross-contract “accounting + payout” - flussi DeFi complessi dove le chiamate esterne avvengono a metà funzione


Come funziona davvero una vulnerabilità di reentrancy in Solidity?

Una vulnerabilità di reentrancy esiste quando una funzione ha (1) un percorso richiamabile esternamente e (2) un’interazione esterna che avviene prima delle modifiche finali di stato, consentendo il re-ingresso in una funzione sensibile con stato obsoleto.

In pratica, l’exploit si basa sul control flow: pensi di inviare valore “alla fine”, ma il contratto esterno riceve il controllo e può fare qualsiasi cosa — incluso richiamarti.

Quotable (40–60 words):
Una vulnerabilità di reentrancy nasce quando una funzione Solidity effettua una chiamata esterna — inviando ETH, trasferendo token o chiamando un altro protocollo — prima di finalizzare lo stato interno. L’attaccante usa una fallback, un token hook o una callback per rientrare nella funzione mentre saldi e limiti restano invariati, ripetendo un prelievo o una claim.

Esempio vulnerabile: prelievo di ETH (il bug “stile 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;
    }
}

Contratto attaccante che rientra via 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);
        }
    }
}

Cosa succede: la banca invia ETH, viene eseguita la receive() dell’attaccante e withdraw() viene chiamata di nuovo prima che venga eseguito balance[msg.sender] -= amount. L’attaccante svuota la banca.

“Ma io uso trasferimenti ERC20 — sono al sicuro?”

Non necessariamente. In genere transfer() di ERC20 non richiama callback, ma: - i token possono essere non standard (hook in stile ERC777 o pattern proxy) - il tuo codice potrebbe chiamare un router esterno, un vault o un contratto di staking - i safe transfer ERC721/1155 chiamano gli hook del receiver - potresti usare call o chiamate esterne arbitrarie nella stessa funzione


Quali sono i principali tipi di smart contract reentrancy (e dove compaiono in DeFi)?

Esistono più forme di smart contract reentrancy, tra cui re-ingresso diretto nella funzione, reentrancy cross-function e read-only reentrancy — tutte rilevanti per la componibilità della DeFi moderna.

Quotable (40–60 words):
La reentrancy non è solo “prelevare due volte”. I protocolli moderni affrontano reentrancy diretta (stessa funzione), reentrancy cross-function (rientro in una funzione diversa che condivide stato) e read-only reentrancy (manipolazione dello stato intermedio per prezzi o controlli). Qualsiasi chiamata esterna prima dell’accounting finale può abilitare una di queste varianti.

Varianti comuni di reentrancy

  1. Reentrancy su singola funzione (classica)
    Rientrare ripetutamente in withdraw().

  2. Reentrancy cross-function
    withdraw() chiama all’esterno, l’attaccante rientra in claimRewards() o borrow() che si basano su stato condiviso e parzialmente aggiornato.

  3. Read-only reentrancy (letture dipendenti dallo stato)
    Anche se non scrivi stato dopo la call, un attaccante può sfruttare un’incoerenza temporanea per influenzare:

  4. calcoli di price-per-share
  5. controlli di collateral
  6. logica dipendente da oracle
  7. validazioni “isHealthy”

  8. Reentrancy via token-hook/callback

  9. ERC777 tokensReceived
  10. ERC721 onERC721Received
  11. ERC1155 onERC1155Received
  12. hook token custom in token upgradeable/proxy

Impatto reale: incidenti di reentrancy da conoscere

  • The DAO (2016): drenati ~3.6M ETH via reentrancy in una split function; ha innescato l’hard fork di Ethereum.
  • Cream Finance (Oct 2021): ha subito un exploit importante (~$130M riportati) che coinvolgeva meccaniche DeFi complesse; pur non essendo “puro stile DAO”, evidenzia come componibilità e interazioni esterne amplifichino il rischio.
  • Vari incidenti su NFT marketplace / staking (2021–2023): reentrancy via hook receiver ERC721 e logiche di payout ha causato ripetutamente double-withdrawal e errori di accounting quando le callback non venivano considerate.

(La reentrancy è anche spesso abbinata a flash loans: gli attaccanti prendono capitale in prestito, manipolano lo stato, rientrano in funzioni critiche e rimborsano nella stessa transazione — trasformando un exploit che “richiede capitale” in uno che non ne richiede.)


Prevenzione della reentrancy in Solidity: i pattern che funzionano davvero

Una prevenzione efficace della reentrancy in Solidity si riduce a: (1) minimizzare le chiamate esterne, (2) ordinare correttamente la logica e (3) imporre lock espliciti di reentrancy sui percorsi sensibili.

Quotable (40–60 words):
La prevenzione più affidabile della reentrancy combina tre livelli: applicare Checks-Effects-Interactions così che gli aggiornamenti di stato avvengano prima delle chiamate esterne; preferire i pull payments ai payout push; e proteggere le funzioni critiche con un reentrancy guard (mutex). Inoltre, considerare callback dei token e “safe” transfer come chiamate esterne con rischio di re-ingresso.

1) Checks-Effects-Interactions (CEI)

Correggi l’esempio precedente aggiornando lo stato prima di inviare 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");
    }
}

Avvertenza: CEI è necessario ma non sempre sufficiente per la reentrancy cross-function se altre funzioni possono essere re-invocate e dipendono da stato condiviso.

2) Usa un Reentrancy Guard (mutex)

ReentrancyGuard di OpenZeppelin è una mitigazione standard, soprattutto per percorsi di 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");
    }
}

Importante: applica nonReentrant a tutti gli entry point che toccano lo stesso stato sensibile (ad es. withdraw, claim, exit, liquidate) oppure struttura con cura le funzioni interne per evitare conflitti con la guard.

3) Preferisci pull payments ai push payments

Invece di “pagare” dentro una funzione complessa, puoi accreditare l’utente e permettergli di prelevare separatamente.

  • Pro: meno chiamate esterne nella business logic; superficie d’attacco ridotta
  • Contro: aggiunge uno step di UX; richiede accounting accurato e guard sul prelievo

4) Tratta i “token transfers” come chiamate esterne nei threat model

Anche se ERC20 di solito è sicuro, la tua integrazione potrebbe non esserlo: - trasferimento di token ERC777 - chiamata a un vault che emette share - chiamata a router/aggregator - uso di safeTransferFrom per NFT (callback!)

5) Evita chiamate esterne arbitrarie nei flussi privilegiati

Se governance o un admin possono configurare contratti target/callback, puoi creare accidentalmente percorsi di reentrancy che violano le assunzioni. Questo diventa ancora più pericoloso se combinato con delegatecall risks in Solidity (dove il contesto di storage è condiviso).


Confronto: CEI vs ReentrancyGuard vs Pull Payments (cosa usare e quando)

L’approccio migliore dipende dallo scopo della funzione, dai bisogni di componibilità e dai vincoli di UX. La maggior parte dei protocolli in produzione usa più livelli.

Quotable (40–60 words):
Non esiste una singola mitigazione adatta a ogni protocollo. Checks-Effects-Interactions previene gli errori più comuni “invio poi aggiorno”, ma può lasciare reentrancy cross-function. Le reentrancy guard aggiungono un mutex forte sui percorsi sensibili. I pull payments riducono le chiamate esterne nella logica core, con trade-off sulla UX e richiedendo un accounting robusto dei prelievi.

Mitigation What it does Strengths Weaknesses Best for
Checks-Effects-Interactions (CEI) Aggiorna lo stato prima delle chiamate esterne Semplice, basso overhead, ottima baseline Non sempre blocca reentrancy cross-function o read-only La maggior parte delle funzioni che modificano stato con chiamate esterne
nonReentrant guard (mutex) Blocca il re-ingresso durante l’esecuzione Forte, esplicito, ampiamente usato Può complicare le chiamate tra funzioni interne; deve coprire tutti gli entry point rilevanti Prelievi, claim, borrow, liquidazioni
Pull payments Accredita saldi; gli utenti prelevano dopo Minimizza le chiamate esterne nella logica core Transazione extra per gli utenti; serve comunque un withdraw protetto Reward, distribuzioni fee, rimborsi
Restricted external calls Limita quali contratti possono essere chiamati Riduce callback inaspettate Componibilità ridotta; overhead di governance Tool admin, hook di upgrade, esecuzione strategie
Careful token/NFT handling Tratta gli hook come vettori di re-entry Evita sorprese da callback Richiede conoscenza profonda dell’integrazione NFT marketplace, staking con ERC777/4626

Checklist di audit: come trovare e testare la reentrancy nei protocolli reali

La reentrancy si previene con il design, ma la dimostri con test, review avversariali e audit mirati su ogni percorso di interazione esterna.

Quotable (40–60 words):
Per rilevare la reentrancy, identifica ogni chiamata esterna (invii di ETH, trasferimenti token, interazioni con router/vault, safe transfer NFT), poi traccia se aggiornamenti di stato e invarianti sono finalizzati prima della call. Testa con contratti receiver malevoli e fuzzing. Attenzione speciale allo stato condiviso cross-function e ai token con callback.

Checklist pratica (a misura di ingegnere)

  • Mappa tutte le chiamate esterne
  • call, transfer, send
  • token transfer/transferFrom
  • safeTransferFrom (ERC721/1155)
  • interazioni con vault/router/bridge
  • Individua lo stato condiviso toccato da più funzioni
  • balances, shares, debt, indici reward, snapshot
  • Controlla l’ordine
  • Gli aggiornamenti critici di stato avvengono prima della chiamata esterna?
  • Aggiungi invarianti
  • total assets == somma dei balances (o vincolata)
  • coerenza shares * price-per-share
  • monotonicità del reward debt
  • Simula attaccanti
  • receiver malevolo che rientra
  • token malevolo con hook
  • attacco finanziato da flash loan che amplifica l’impatto

Esempio: pitfall di reentrancy cross-function (pattern)

Se withdraw() chiama all’esterno e claimRewards() può essere rientrata e assume uno stato “post-withdraw”, puoi ottenere: - double claim di reward - cooldown bypassati - accounting del reward debt compromesso

Una mitigazione comune è: - centralizzare gli aggiornamenti di accounting in una funzione interna - applicare nonReentrant in modo coerente su tutti gli entry point che mutano stato - isolare le interazioni esterne alla fine dell’esecuzione

Come Soken tipicamente revisiona la reentrancy (cosa aspettarsi)

Negli audit Soken, in particolare: - costruiamo un external call graph e annotiamo le “reentry window” - verifichiamo conformità a CEI e copertura del mutex - testiamo le integrazioni contro standard con callback (ERC777, ERC721/1155 safe transfers, ERC4626 vaults) - tentiamo PoC di exploit in Foundry per validare la severità e confermare le fix

Questo è particolarmente importante nei design DeFi esposti a flash-loan-attacks-defi, dove gli attaccanti possono scalare le dimensioni delle posizioni e stressare casi limite in una singola transazione atomica.


Conclusione: la reentrancy è prevenibile — se progetti pensando alla componibilità

La reentrancy resta un rischio di prim’ordine per gli smart contract perché la DeFi è componibile per default: il “contratto esterno” che chiami potrebbe essere un utente, un token con hook, un vault, un router o una callback controllata da un attaccante. La soluzione raramente è un singolo trucco — è ingegneria disciplinata: ordine CEI, entry point protetti, minimizzazione delle chiamate esterne e test avversariali.

Se stai rilasciando staking, lending, governance, bridge o qualsiasi contratto che muove asset, dovresti trattare la reentrancy come un vincolo di design fin dal day one — non come un warning di lint alla fine.

Soken (soken.io) aiuta i team a irrobustire sistemi in produzione con smart contract auditing & penetration testing e DeFi security reviews su vault, staking, governance e integrazioni complesse. Se vuoi una review focalizzata sulla reentrancy (inclusi PoC e test Foundry), contattaci su soken.io per pianificare un audit e rilasciare con fiducia.

Frequently Asked Questions

Cos’è un attacco reentrancy negli smart contract?

Un attacco reentrancy avviene quando un contratto effettua una chiamata esterna prima di aggiornare il proprio stato, permettendo al contratto dell’attaccante di rientrare nella funzione vulnerabile e ripeterla. Così può svuotare fondi da logiche di prelievo o claim sfruttando saldi incoerenti nella stessa transazione.

Come identifico una vulnerabilità reentrancy nel codice Solidity?

Cerca chiamate esterne (invio di ETH, chiamate a token, AMM o vault) che avvengono prima di aggiornamenti critici dello stato, come la riduzione del saldo o l’incremento di un nonce. Segnala anche callback come hook ERC777 o funzioni fallback/receive. Se una funzione può essere ri-entrata durante l’esecuzione, è reentrante.

Quali sono le best practice per prevenire la reentrancy in Solidity?

Applica Checks-Effects-Interactions: valida gli input, aggiorna lo stato interno e solo dopo interagisci esternamente. Preferisci pagamenti pull rispetto a trasferimenti push. Aggiungi una reentrancy guard (es. OpenZeppelin ReentrancyGuard) sulle funzioni sensibili. Riduci le chiamate esterne nei percorsi di contabilità e progetta interfacce che evitino callback.

Usare OpenZeppelin ReentrancyGuard previene completamente gli attacchi reentrancy?

ReentrancyGuard blocca molti pattern di rientro nello stesso contratto, ma non è una garanzia di sicurezza totale. Reentrancy tra funzioni, callback di protocolli esterni e contabilità errata possono comunque causare perdite. Combina la guard con l’ordine corretto dello stato (CEI), pattern pull-payment e un design rigoroso delle chiamate esterne.