Vai al contenuto principale
Change page

Sicurezza dei contratti intelligenti

Ultima modifica: @Herbie_23(opens in a new tab), 22 maggio 2024

I contratti intelligenti sono estremamente flessibili e in grado di gestire grandi quantità di valori e dati, eseguendo allo stesso tempo una logica immutata basata sul codice distribuito sulla blockchain. Questo ha dato vita a un vivace ecosistema di applicazioni senza fiducia e decentralizzate, che offrono molti vantaggi rispetto ai sistemi legacy. I contratti intelligenti rappresentano anche un'opportunità per gli utenti malevoli che provano a speculare sfruttandone le vulnerabilità.

Blochchain pubbliche come Ethereun rendono ancora più complessa la questione della sicurezza dei contratti intelligenti. Una volta distribuito, il codice dei contratti di solito non può essere modificato per correggere falle di sicurezza. In più, le risorse rubate dai contratti intelligenti sono estremamente difficili da tracciare e praticamente irrecuperabili per via della loro immutabilità.

Anche se i dati possono variare, si stima che l'ammontare totale di valore rubato o perduto a causa di falle di sicurezza nei contratti intelligenti superi facilmente $1 miliardo. In questo sono inclusi incidenti rilievo come la violazione della DAO(opens in a new tab) (3,6 milioni di ETH rubati, per un valore superiore a $1 miliardo al prezzo attuale), la violazione del portafoglio multi-firma di Parity(opens in a new tab) ($30 milioni sottratti dagli hacker), e la questione del portafoglio Parity congelato(opens in a new tab) (più di $300 milioni di ETH bloccati per sempre).

Per via di tutte queste problematiche, è imperativo per gli sviluppatori investire risorse nella costruzione di contratti intelligenti sicuri, robusti e resilienti. La sicurezza dei contratti intelligenti è una questione seria, su cui ogni sviluppatore farebbe bene a informarsi. Questa guida tratterà alcune considerazioni sulla sicurezza rivolte agli sviluppatori Ethereum ed esaminerà le risorse per migliorare la sicurezza dei contratti intelligenti.

Prerequisiti

Assicurati di avere familiarità con le basi di programmazione di contratti intelligenti prima di affrontare la questione sicurezza.

Linee guida per costruire contratti intelligenti di Ethereum sicuri

1. Progetta controlli di accesso adeguati

Nei contratti intelligenti, le funzioni contrassegnate da public o external possono essere chiamate da qualsiasi conto posseduto esternamente (EOA) o da un conto contratto. È necessario specificare la visibilità pubblica per le funzioni se si desidera che altri interagiscano con il contratto. Le funzioni contrassegnate da private invece possono essere chiamate solo da funzioni all'interno del contratto intelligente e non da conti esterni. Dare a ogni partecipante della rete l'accesso alle funzioni del contratto può causare problemi, soprattutto se ciò implica che chiunque possa eseguire operazioni sensibili (ad esempio, coniare nuovi token).

Per prevenire l'uso non autorizzato di funzioni di contratti intelligenti, è necessario implementare controlli di accesso sicuri. I meccanismi di controlli di accesso limitano la possibilità di utilizzare determinate funzioni in un contratto intelligente a soggetti approvati, come i conti responsabili della gestione del contratto. Il modello Ownable e il controllo basato sul ruolo sono due modelli utili per implementare il controllo di access nei contratti intelligenti:

Modello Ownable

Nel modello Ownable, un indirizzo è impostato come “proprietario” del contratto durante il processo di creazione del contratto. Alle funzioni protette viene assegnato un modificatore OnlyOwner che garantisce che il contratto autentichi l'identità dell'indirizzo chiamante prima di eseguire la funzione. Le chiamate a funzioni protette che provengono da altri indirizzi a parte il proprietario del contratto sono sempre respinte, impedendo accessi indesiderati.

Controllo di accesso basato sul ruolo

Registrare un unico indirizzo come Owner in un contratto intelligente introduce un rischio di centralizzazione e rappresenta un punto di errore unico. Se le chiavi del conto del proprietario sono compromesse, gli aggressori possono attaccare il contratto proprietario. Questo è il motivo per cui utilizzare un modello di controllo di accesso basato su ruoli con più conti amministrativi potrebbe essere un'opzione migliore.

Nel controllo di accesso basato sui ruoli, l'accesso alle funzioni sensibili è distribuito in un gruppo di partecipanti fidati. Per esempio, un conto può essere responsabile della coniazione di token, mentre un altro esegue gli aggiornamenti o interrompe il contratto. Decentralizzare in questo modo il controllo di accesso elimina punti di errore unici e riduce il bisogno di ipotesi di fiducia per gli utenti.

Uso dei portafogli multifirma

Un altro approccio utile per implementare un controllo di accesso sicuro è utilizzare un conto multifirma nella gestione di un contratto. A differenza di un comune EOA, i conti multifirma sono di proprietà di più entità e richiedono un numero minimo di firme da parte di più conti - solitamente da 3 a 5 - per eseguire transazioni.

L'utilizzo di un multifirma per il controllo di accesso introduce un ulteriore livello di sicurezza, dal momento che le azioni effettuate sul contratto di destinazione richiedono il consenso di più parti. Ciò è particolarmente utile nei casi in cui è necessario fare uso del modello Ownable, in quanto sarà più di difficile per un aggressore esterno o un insider ostile manipolare le funzioni sensibili del contratto per scopi malevoli.

2. Usa le istruzioni require(), assert(), e revert() per sorvegliare le operazioni del contratto

Come accennato, chiunque potrà chiamare funzioni pubbliche nel tuo contratto intelligente una volta che questo sarà distribuito sulla blockchain. Poiché è impossibile sapere in anticipo come i conti esterni interagiranno con un contratto, l'ideale è implementare, prima della distribuzione, misure di salvaguardia interne nei confronti di operazioni sensibili. È possibile imporre un comportamento corretto nei contratti intelligenti utilizzando le istruzioni require(), assert() e revert() per attivare eccezioni e annullare le modifiche dello stato se l'esecuzione non soddisfa determinati requisiti.

require(): le istruzioni require sono definite all'inizio della funzione e assicurano che siano soddisfatte condizioni predefinite prima che venga eseguita la funzione chiamata. Un'istruzione require è utilizzabile per convalidare gli input dell'utente, controllare le variabili di stato o autenticare l'identità del conto chiamante, prima di procedere con una funzione.

assert(): assert() è usata per rilevare gli errori interni e verificare le violazioni delle "invarianti" nel tuo codice. Un'invariante è un'asserzione logica sullo stato di un contratto, che dovrebbe rimanere valida per tutte le esecuzioni della funzione. Un esempio di invariante è la fornitura o saldo massimo totale del contratto di un token. Usare assert() garantisce che il tuo contratto non raggiunga mai uno stato vulnerabile e, se lo fa, tutte le modifiche alle variabili di stato sono annullate.

revert(): revert() è utilizzabile in un'istruzione if-else che innesca un'eccezione se la condizione necessaria non è soddisfatta. Il seguente esempio di contratto utilizza revert() per proteggere l'esecuzione delle funzioni:

1pragma solidity ^0.8.4;
2
3contract VendingMachine {
4 address owner;
5 error Unauthorized();
6 function buy(uint amount) public payable {
7 if (amount > msg.value / 2 ether)
8 revert("Not enough Ether provided.");
9 // Perform the purchase.
10 }
11 function withdraw() public {
12 if (msg.sender != owner)
13 revert Unauthorized();
14
15 payable(msg.sender).transfer(address(this).balance);
16 }
17}
Mostra tutto

3. Testa i contratti intelligenti e verifica la correttezza del codice

L'immutabilità del codice in esecuzione sulla Macchina Virtuale di Ethereum fa sì che i contratti intelligenti richiedano un livello superiore di valutazione della qualità durante la fase di sviluppo. Testare ampiamente il tuo contratto e osservarlo per cogliere qualsiasi risultato imprevisto ne migliorerà considerevolmente la sicurezza e proteggerà i tuoi utenti sul lungo periodo.

Il metodo consueto prevede di scrivere piccole unità di prova utilizzando i dati di simulazione che il contratto dovrebbe ricevere dagli utenti. La conduzione di unit test contribuisce a testare la funzionalità di certe funzioni e assicurarsi che un contratto intelligente funzioni come previsto.

Sfortunatamente, gli unit test sono poco efficaci per migliorare la sicurezza del contratto intelligente se utilizzato in isolamento. Uno unit test potrebbe provare che una funzione sia eseguita correttamente per i dati di simulazione, ma gli unit test sono efficaci solo quanto i test scritti. Questo rende difficile rilevare i casi limite non identificati e le vulnerabilità che potrebbero spezzare la sicurezza del tuo contratto intelligente.

Un approccio migliore è combinare gli unit test con i test basati sulle proprietà, eseguiti usando l'analisi statica e dinamica. L'analisi statica si affida alle rappresentazioni di basso livello, come i grafici del flusso di controllo(opens in a new tab) e gli alberi di sintassi astratta(opens in a new tab) per analizzare gli stati raggiungibili del programma e i percorsi d'esecuzione. Nel mentre, le tecniche di analisi dinamica, come il fuzzing, eseguono il codice del contratto con valori di input casuali per rilevare le operazioni che violano le proprietà di sicurezza.

La verifica formale è un'altra tecnica per verificare le proprietà di sicurezza nei contratti intelligenti. A differenza dei test regolari, la verifica formale può dimostrare in modo conclusivo l'assenza di errori in un contratto intelligente. Ciò si ottiene creando una specifica formale che racchiuda le proprietà di sicurezza desiderate e dimostrando che un modello formale dei contratti aderisce a tale specifica.

4. Richiedi una revisione indipendente del tuo codice

Dopo aver testato il tuo contratto, è bene richiedere ad altri di verificare il codice sorgente per qualsiasi problema di sicurezza. I test non scopriranno ogni difetto di un contratto intelligente, ma ottenere una revisione indipendente aumenta la possibilità di individuare le vulnerabilità.

Controlli

Commissionare il controllo di un contratto intelligente è un modo di condurre una revisione indipendente del codice. I revisori rivestono un ruolo importante nell'assicurare che i contratti intelligenti siano sicuri e liberi da difetti di qualità ed errori di progettazione.

Detto ciò, dovresti evitare di trattare i controlli come una bacchetta magica. I controlli del contratto intelligente non troveranno ogni bug e sono principalmente progettati per fornire un ulteriore ciclo di revisioni, che possono aiutare a rilevare i problemi non identificati dagli sviluppatori durante lo sviluppo e test iniziali. Inoltre, dovresti seguire le migliori pratiche per lavorare con i revisori(opens in a new tab), come documentare correttamente il codice e aggiungere commenti inline, per massimizzare i benefici del controllo di un contratto intelligente.

Caccia ai bug

Impostare un programma di caccia ai bug è un altro approccio per implementare le revisioni esterne del codice. Una bug bounty è una ricompensa finanziaria data a persone (solitamente hacker whitehat) che scoprono vulnerabilità in un'applicazione.

Quando usate propriamente, queste ricompense per la caccia ai bug incentivano i membri della community di hacker a ispezionare il tuo codice in cerca di difetti critici. Un esempio reale è il "bug del denaro infinito", che avrebbe consentito a un utente malevolo di creare un importo illimitato di Ether su Optimism(opens in a new tab), un protocollo del livello 2 eseguito su Ethereum. Fortunatamente, un hacker whitehat ha scoperto il difetto(opens in a new tab) e informato il team, guadagnandosi una ricca ricompensa(opens in a new tab).

Una strategia utile è impostare la ricompensa di un programma di caccia ai bug proporzionale all'importo di fondi in staking. Descritto come "caccia ai bug scalare(opens in a new tab)", questo approccio fornisce incentivi finanziari alle persone perché divulghino responsabilmente le vulnerabilità invece di sfruttarle.

5. Segui le migliori pratiche durante lo sviluppo del contratto intelligente

L'esistenza di controlli e ricompense per la caccia ai bug non è una scusa per non scrivere codice di alta qualità. Una buona sicurezza dei contratti intelligenti deriva da processi di progettazione e sviluppo adeguati:

  • Archivia tutto il codice in un sistema di controllo delle versioni, come git

  • Effettua tutte le modifiche al codice tramite richieste pull

  • Assicurati che le richieste pull abbiano almeno un revisore indipendente; se stai lavorando a un progetto da solo, valuta di trovare altri sviluppatori e di scambiare revisioni di codice

  • Usa un ambiente di sviluppo per testare, compilare e distribuire i contratti intelligenti

  • Esegui il tuo codice tramite strumenti di analisi del codice di base, come Mythril e Slither. Idealmente, dovresti farlo prima della fusione di ogni richiesta pull e confrontare le differenze nell'output

  • Assicurati che il tuo codice si compili senza errori e che il compilatore di Solidity non emetta alcun avviso

  • Documenta correttamente il tuo codice (usando NatSpec(opens in a new tab)) e descrivi i dettagli sull'architettura del contratto in un linguaggio facile da comprendere. Questo semplificherà il controllo e la revisione del tuo codice da parte di altri.

6. Implementa solidi piani di ripristino in caso di disastro

Progettare controlli di accesso sicuri, implementare modificatori di funzioni e altri suggerimenti possono migliorare la sicurezza dei contratti intelligenti, ma non possono escludere la possibilità di exploit dannosi. Creare contratti intelligenti sicuri richiede di "prepararsi al fallimento" e di avere un piano di ripiego per rispondere efficientemente agli attacchi. Un adeguato piano di ripristino in caso di disastro includerà alcuni o tutti i seguenti componenti:

Aggiornamenti del contratto

Sebbene i contratti intelligenti di Ethereum siano immutabili di default, è possibile ottenere un certo grado di mutabilità utilizzando dei modelli di aggiornamento. Aggiornare i contratti è necessario nel caso in cui un difetto critico renda inutilizzabile il tuo vecchio contratto e distribuire una nuova logica sia l'opzione più fattibile.

I meccanismi di aggiornamento del contratto operano diversamente, ma lo "schema del proxy" è uno degli approcci più popolari per aggiornare i contratti intelligenti. Gli schemi del proxy dividono lo stato e la logica di un'applicazione tra due contratti. Il primo contratto (detto "contratto proxy") archivia le variabili di stato (es., i saldi degli utenti), mentre il secondo (detto "contratto logico") detiene il codice per l'esecuzione delle funzioni del contratto.

I conti interagiscono con il contratto proxy, che invia le chiamate di tutte le funzioni al contratto logico utilizzando la chiamata di basso livello delegatecall()(opens in a new tab). A differenza di una normale chiamata di messaggio, delegatecall() garantisce che il codice in esecuzione all'indirizzo del contratto logico sia eseguito nel contesto del contratto chiamante. Ciò significa che il contratto logico scriverà sempre sulla memoria del proxy (invece che sulla propria) e che i valori originali di msg.sender e msg.value sono preservati.

Delegare le chiamate al contratto logico richiede l'archiviazione del suo indirizzo nella memoria del contratto proxy. Pertanto, aggiornare la logica del contratto è solo una questione di distribuire un altro contratto logico e archiviare il nuovo indirizzo nel contratto proxy. Poiché le chiamate successive al contratto proxy sono indirizzate automaticamente al nuovo contratto logico, avresti "aggiornato" il contratto senza effettivamente modificare il codice.

Maggiori informazioni sull'aggiornamento dei contratti.

Arresto d'emergenza

Come accennato, i controlli e test estesi non possono verosimilmente scoprire tutti i bug in un contratto intelligente. Se una vulnerabilità appare nel tuo codice dopo la distribuzione, correggerla è impossibile perché non puoi modificare il codice in esecuzione all'indirizzo del contratto. Inoltre, i meccanismi di aggiornamento (es., schemi del proxy) potrebbero richiedere del tempo per l'implementazione (spesso richiedono l'approvazione di parti differenti), dando più tempo agli utenti malevoli di causare più danni.

L'opzione più drastica è implementare una funzione di "arresto d'emergenza" che blocca le chiamate alle funzioni vulnerabili in un contratto. Gli arresti d'emergenza comprendono tipicamente i seguenti componenti:

  1. Una variabile booleana globale che indica se il contratto intelligente è in uno stato d'arresto o no. Questa variabile è impostata a false durante la configurazione del contratto, ma si ripristinerà a true una volta arrestato il contratto.

  2. Funzioni riferite alla variabile booleana nella loro esecuzione. Tali funzioni sono accessibili quando il contratto intelligente non è arrestata e divengono inaccessibili una volta innescata la funzionalità di arresto d'emergenza.

  3. Un'entità avente accesso alla funzione di arresto d'emergenza, che imposta la variabile booleana a true. Per impedire azioni dannose, le chiamate a questa funzione possono essere limitate a un indirizzo fidato (es., il proprietario del contratto).

Una volta che il contratto attiva l'arresto d'emergenza, certe funzioni non potranno essere chiamate. Ciò avviene avvolgendo le funzioni selezionate in un modificatore che fa riferimento alla variabile globale. Di seguito, è riportato un esempio(opens in a new tab) che descrive un'implementazione di questo schema nei contratti:

1// Questo codice non è stato controllato professionalmente e non fa promesse su sicurezza o correttezza. Usalo a tuo rischio.
2
3contract EmergencyStop {
4
5 bool isStopped = false;
6
7 modifier stoppedInEmergency {
8 require(!isStopped);
9 _;
10 }
11
12 modifier onlyWhenStopped {
13 require(isStopped);
14 _;
15 }
16
17 modifier onlyAuthorized {
18 // Check for authorization of msg.sender here
19 _;
20 }
21
22 function stopContract() public onlyAuthorized {
23 isStopped = true;
24 }
25
26 function resumeContract() public onlyAuthorized {
27 isStopped = false;
28 }
29
30 function deposit() public payable stoppedInEmergency {
31 // Deposit logic happening here
32 }
33
34 function emergencyWithdraw() public onlyWhenStopped {
35 // Emergency withdraw happening here
36 }
37}
Mostra tutto
Copia

Questo esempio mostra le caratteristiche fondamentali degli arresti d'emergenza:

  • isStopped è una variabile booleana che restituisce false all'inizio e true quando il contratto entra in modalità d'emergenza.

  • I modificatori della funzione onlyWhenStopped e stoppedInEmergency controllano la variabile isStopped. stoppedInEmergency è usata per controllare le funzioni che non dovrebbero essere accessibili quando il contratto è vulnerabile (es., deposit()). Le chiamate a queste funzioni saranno semplicemente annullate.

onlyWhenStopped è usata per le funzioni che dovrebbero poter essere chiamate durante un'emergenza (es., emergencyWithdraw()). Tali funzioni possono aiutare a risolvere la situazione, da cui la loro esclusione dall'elenco delle "funzioni limitate".

L'utilizzo di una funzionalità di arresto d'emergenza fornisce un palliativo efficiente per affrontare gravi vulnerabilità nel tuo contratto intelligente. Tuttavia, aumenta la necessità che gli utenti si fidino del fatto che gli sviluppatori non la attiveranno per motivi egoistici. A tal fine, decentralizzare il controllo dell'arresto d'emergenza sottoponendolo a un meccanismo di voto sulla catena, blocco temporale o a un'approvazione da un portafoglio multifirma, sono possibili soluzioni.

Monitoraggio degli eventi

Gli eventi(opens in a new tab) ti consentono di tracciare le chiamate alle funzioni del contratto intelligente e di monitorare le modifiche alle variabili di stato. È ideale programmare il tuo contratto intelligente in modo che emetta un evento ogni volta che una qualche parte effettua un'azione critica per la sicurezza (es., prelevare fondi).

Registrare gli eventi e monitorarli al di fuori della catena fornisce dettagli sulle operazioni del contratto e aiuta a scoprire più velocemente le azioni malevoli. Ciò significa che il tuo team può rispondere più velocemente alle violazioni e agire per mitigare l'impatto sugli utenti, ad esempio interrompendo le funzioni o eseguendo un aggiornamento.

Puoi anche optare per uno strumento di monitoraggio standard che inoltri automaticamente degli avvisi ogni volta che qualcuno interagisce con i tuoi contratti. Questi strumenti ti consentiranno di creare avvisi personalizzati basati su diversi inneschi, come il volume delle transazioni, la frequenza delle chiamate alla funzione o le funzioni specifiche coinvolte. Ad esempio, potresti programmare un avviso che si attivi quando l'importo prelevato in una singola transazione sia superiore a una determinata soglia.

7. Progetta sistemi di governance sicuri

Potresti voler decentralizzare la tua applicazione dando il controllo dei contratti intelligenti ai membri della community. In questo caso, il sistema del contratto intelligente includerà un modulo di governance, ossia un meccanismo che consente ai membri della community di approvare le azioni amministrative tramite un sistema di governance sulla catena. Ad esempio, una proposta di aggiornare un contratto proxy a una nuova implementazione potrebbe esser votata dai possessori di token.

La governance decentralizzata può essere vantaggiosa, specialmente poiché si allinea agli interessi degli sviluppatori e degli utenti finali. Tuttavia, i meccanismi di governance del contratto intelligente potrebbero introdurre nuovi rischi, se implementati in modo errato. Uno scenario plausibile è se un utente malevolo acquisisce un enorme potere di voto (misurato in numero di token posseduti) chiedendo un prestito flash e presentando una proposta dannosa.

Un modo di prevenire i problemi correlati alla governance sulla catena è utilizzare un blocco temporale(opens in a new tab). Un blocco temporale impedisce a un contratto intelligente di eseguire certe azioni finché non passa un dato periodo di tempo. Altre strategie includono l'assegnazione di un "peso di voto" a ogni token a seconda del tempo per cui è stato bloccato, o la misurazione del potere di voto di un indirizzo in un dato periodo storico (ad esempio, 2-3 blocchi nel passato) invece che al blocco corrente. Entrambi i metodi riducono la possibilità di accumulare rapidamente potere di voto per influire sui voti sulla catena.

Maggiori informazioni sulla progettazione di sistemi di governance sicuri(opens in a new tab) e sui differenti meccanismi di voto nelle DAO(opens in a new tab).

8. Riduci al minimo la complessità nel codice

Gli sviluppatori di software tradizionali hanno familiarità con il principio KISS ("keep it simple, stupid", letteralmente "tieniti sul semplice, stupido"), che consiglia di non introdurre complessità non necessarie nella progettazione del software. Questo segue il pensiero di vecchia data che i "sistemi complessi falliscono in modi complessi" e che siano più suscettibili a errori costosi.

Mantenere le cose semplici è di particolare importanza nella scrittura dei contratti intelligenti, dato che i contratti intelligenti potrebbero potenzialmente controllare grandi importi di valore. Un suggerimento per ottenere la semplicità durante la scrittura dei contratti intelligenti è riutilizzare le librerie esistenti, come i contratti di OpenZeppelin(opens in a new tab), laddove possibile. Poiché queste librerie sono state controllate e testate ampiamente dagli sviluppatori, utilizzarle riduce le possibilità di introdurre bug rispetto a scrivere nuove funzionalità da zero.

Un altro consiglio comune è scrivere piccole funzioni e mantenere i contratti modulari, dividendo la logica commerciale su più contratti. Non solo la scrittura di codice più semplice può ridurre la superficie di attacco in un contratto intelligente, ma rende anche più semplice ragionare della correttezza del sistema complessivo e rilevare precocemente possibili errori di progettazione.

9. Difenditi dalle vulnerabilità comuni dei contratti intelligenti

Rientranza

L'EVM non consente la concorrenza, il che significa che due contratti coinvolti in una chiamata di messaggio non possono essere eseguiti simultaneamente. Una chiamata esterna interrompe l'esecuzione e la memoria del contratto chiamante fino al ritorno della chiamata, dopodiché l'esecuzione procede normalmente. Questo processo può essere formalmente descritto come il trasferimento del flusso di controllo(opens in a new tab) a un altro contratto.

Sebbene per lo più innocuo, il trasferimento del flusso di controllo a contratti non fidati può causare dei problemi, come la rientranza. Un attacco di rientranza si verifica quando un contratto malevolo viene richiamato in un contratto vulnerabile prima che l'invocazione della funzione originale sia completa. Questo tipo di attacco può essere spiegato meglio con un esempio.

Considera un contratto intelligente semplice ('Victim') che consenta a chiunque di depositare e prelevare Ether:

1// Questo contratto è vulnerabile. Non utilizzarlo in produzione
2
3contract Victim {
4 mapping (address => uint256) public balances;
5
6 function deposit() external payable {
7 balances[msg.sender] += msg.value;
8 }
9
10 function withdraw() external {
11 uint256 amount = balances[msg.sender];
12 (bool success, ) = msg.sender.call.value(amount)("");
13 require(success);
14 balances[msg.sender] = 0;
15 }
16}
Mostra tutto
Copia

Questo contratto espone una funzione withdraw() per consentire agli utenti di prelevare gli ETH precedentemente depositati nel contratto. Elaborando un prelievo, il contratto esegue le seguenti operazioni:

  1. Controlla il saldo di ETH dell'utente
  2. Invia i fondi all'indirizzo chiamante
  3. Ripristina il loro saldo a 0, impedendo ulteriori prelievi dall'utente

La funzione withdraw() nel contratto Victim segue uno schema di "controlli-interazioni-effetti". Verifica se le condizioni necessarie per l'esecuzione sono soddisfatte (cioè, se l'utente ha un saldo di ETH positivo) ed esegue l'interazione inviando gli ETH all'indirizzo del chiamante, prima di applicare gli effetti della transazione (cioè, ridurre il saldo dell'utente).

Se withdraw() viene chiamato da un conto posseduto esternamente (EOA), la funzione si esegue come previsto: msg.sender.call.value() invia gli ETH al chiamante. Tuttavia, se msg.sender è un conto del contratto intelligente e chiama withdraw(), inviare i fondi utilizzando msg.sender.call.value() innescherà anche l'esecuzione del codice archiviato a quell'indirizzo.

Immagina che questo sia il codice distribuito all'indirizzo del contratto:

1 contract Attacker {
2 function beginAttack() external payable {
3 Victim(victim_address).deposit.value(1 ether)();
4 Victim(victim_address).withdraw();
5 }
6
7 function() external payable {
8 if (gasleft() > 40000) {
9 Victim(victim_address).withdraw();
10 }
11 }
12}
Mostra tutto
Copia

Questo contratto è progettato per fare tre cose:

  1. Accettare un deposito da un altro conto (probabilmente l'EOA dell'utente malevolo)
  2. Depositare 1 ETH nel contratto Victim
  3. Prelevare 1 ETH archiviato nel contratto intelligente

Non c'è nulla di sbagliato qui, tranne il fatto che l'Attacker ha un'altra funzione che chiama nuovamente withdraw() in Victim se il gas rimanente da msg.sender.call.value in entrata è superiore a 40.000. Questo consente all'Attacker di rientrare nel contratto Victim e di prelevare ulteriori fondi, prima del completamento della prima invocazione di withdraw. Il ciclo somiglia al seguente:

1- L'EOA dell'Attacker chiama `Attacker.beginAttack()` con 1 ETH
2- `Attacker.beginAttack()` deposita 1 ETH in `Victim`
3- `Attacker` chiama `withdraw() in `Victim`
4- `Victim` controlla il saldo di `Attacker` (1 ETH)
5- `Victim` invia 1 ETH ad `Attacker` (che innesca la funzione di default)
6- `Attacker` chiama di nuovo `Victim.withdraw()` (si noti che `Victim` non ha ridotto il saldo di `Attacker` dopo il primo prelievo)
7- `Victim` controlla il saldo di `Attacker` (che è ancora 1 ETH perché non ha applicato gli effetti della prima chiamata)
8- `Victim` invia 1 ETH ad `Attacker` (che innesca la funzione di default e consente ad `Attacker` di rientrare nella funzione `withdraw`)
9- Il processo si ripete finché `Attacker` rimane senza gas; a quel punto `msg.sender.call.value` ritorna senza innescare alcun prelievo ulteriore
10- `Victim` infine applica i risultati della prima transazione (e delle successive) al proprio stato, così che il saldo di `Attacker` è impostato su 0
Mostra tutto
Copia

Riepilogando: poiché il saldo del chiamante non è impostato a 0 fino al completamento dell'esecuzione della funzione, le invocazioni successive avranno successo, consentendo al chiamante di prelevare il proprio saldo numerose volte. Questo tipo di attacco è utilizzabile per sottrarre a un contratto intelligente i suoi fondi, come è avvenuto nella violazione della DAO del 2016(opens in a new tab). Gli attacchi di rientranza sono ancora oggi un problema critico per i contratti intelligenti, come mostrato dagli elenchi pubblici di exploit di rientranza(opens in a new tab).

Come prevenire gli attacchi di rientranza

Un approccio per affrontare la rientranza è seguire lo schema di controlli-effetti-interazioni(opens in a new tab). Questo schema ordina l'esecuzione delle funzioni in modo che il codice che esegue i controlli necessari prima di procedere all'esecuzione venga per primo, seguito dal codice che manipola lo stato del contratto e il codice che interagisce con gli altri contratti o EOA per ultimo.

Lo schema di controlli-effetti-interazioni è utilizzato in una versione rivista del contratto Victim, mostrato di seguito:

1contract NoLongerAVictim {
2 function withdraw() external {
3 uint256 amount = balances[msg.sender];
4 balances[msg.sender] = 0;
5 (bool success, ) = msg.sender.call.value(amount)("");
6 require(success);
7 }
8}
Copia

Questo contratto esegue un controllo sul saldo dell'utente, applica gli effetti della funzione withdraw() (ripristinando il saldo dell'utente a 0), e procede all'esecuzione dell'interazione (inviando gli ETH all'indirizzo dell'utente). Questo garantisce che il contratto aggiorni la propria memoria prima della chiamata esterna, eliminando la condizione di rientranza che ha consentito il primo attacco. Il contratto Attacker potrebbe ancora richiamare in NoLongerAVictim, ma poiché balances[msg.sender] è stata impostata a 0, ulteriori prelievi genereranno un errore.

Un'altra opzione è utilizzare un blocco di esclusione reciproca (comunemente descritto come "mutex"), che blocca una porzione dello stato di un contratto fino al completamento dell'invocazione di una funzione. Questo è implementato utilizzando una variabile booleana impostata a true prima dell'esecuzione della funzione e ripristinata a false dopo l'invocazione. Come si può vedere nell'esempio seguente, l'utilizzo di un mutex protegge una funzione dalle chiamate ricorsive mentre l'invocazione originale è ancora in elaborazione, interrompendo efficientemente la rientranza.

1pragma solidity ^0.7.0;
2
3contract MutexPattern {
4 bool locked = false;
5 mapping(address => uint256) public balances;
6
7 modifier noReentrancy() {
8 require(!locked, "Blocked from reentrancy.");
9 locked = true;
10 _;
11 locked = false;
12 }
13 // This function is protected by a mutex, so reentrant calls from within `msg.sender.call` cannot call `withdraw` again.
14 // L'istruzione `return` risulta in `true`, pur valutando l'istruzione `locked = false` nel modificatore
15 function withdraw(uint _amount) public payable noReentrancy returns(bool) {
16 require(balances[msg.sender] >= _amount, "Nessun saldo da prelevare.");
17
18 balances[msg.sender] -= _amount;
19 bool (success, ) = msg.sender.call{value: _amount}("");
20 require(success);
21
22 return true;
23 }
24}
Mostra tutto
Copia

Puoi anche utilizzare un sistema di pagamenti pull(opens in a new tab), che richiede agli utenti di prelevare i fondi dai contratti intelligenti, invece di un sistema di "pagamenti push", che invia i fondi ai conti. Ciò rimuove la possibilità di innescare inavvertitamente il codice a indirizzi sconosciuti (e può anche impedire determinati attacchi denial-of-service).

Sottoeccedenze e sovraflussi di interi

Il sovraflusso di un intero si verifica quando i risultati di un'operazione aritmetica ricadono al di fuori dell'intervallo accettabile di valori, causandone un "ripristino" al valore più basso rappresentabile. Ad esempio, un uint8 può memorizzare solo i valori fino a 2^8-1=255. Le operazioni automatiche che risultano in valori superiori a 255 eccederanno e ripristineranno uint a 0, analogamente a come il contachilometri di un'auto si ripristina a zero una volta raggiunto il chilometraggio massimo (999999).

Le sottoeccedenze di interi si verificano per motivi simili: i risultati di un'operazione aritmetica ricadono al di sotto dell'intervallo accettabile. Diciamo che hai provato a ridurre 0 in un uint8, il risultato sarebbe semplicemente il ripristino al valore massimo rappresentabile (255).

Sia le sottoeccedenze che i sovraflussi di interi possono comportare modifiche impreviste alle variabili di stato di un contratto e risultare nell'esecuzione non pianificata. Di seguito è riportato un esempio che mostra come un utente malevolo può sfruttare il sovraflusso aritmetico in un contratto intelligente per eseguire un'operazione non valida:

1pragma solidity ^0.7.6;
2
3// Questo contratto è progettato per agire da caveau temporale.
4// L'utente può depositare in questo contratto, ma non può prelevare per almeno una settimana.
5// L'utente può inoltre estendere il tempo di attesa oltre il periodo di attesa di 1 settimana.
6
7/*
81. Distribuisci TimeLock
92. Distribuisci Attack con l'indirizzo di TimeLock
103. Chiama Attack.attack inviando 1 ether. Potrai immediatamente
11 prelevare i tuoi ether.
12
13Cos'è successo?
14L'attacco ha causato il sovraflusso di TimeLock.lockTime ed è riuscito a prelevare
15prima del periodo di attesa di 1 settimana.
16*/
17
18contract TimeLock {
19 mapping(address => uint) public balances;
20 mapping(address => uint) public lockTime;
21
22 function deposit() external payable {
23 balances[msg.sender] += msg.value;
24 lockTime[msg.sender] = block.timestamp + 1 weeks;
25 }
26
27 function increaseLockTime(uint _secondsToIncrease) public {
28 lockTime[msg.sender] += _secondsToIncrease;
29 }
30
31 function withdraw() public {
32 require(balances[msg.sender] > 0, "Fondi insufficienti");
33 require(block.timestamp > lockTime[msg.sender], "Tempo di blocco non scaduto");
34
35 uint amount = balances[msg.sender];
36 balances[msg.sender] = 0;
37
38 (bool sent, ) = msg.sender.call{value: amount}("");
39 require(sent, "Impossibile inviare Ether");
40 }
41}
42
43contract Attack {
44 TimeLock timeLock;
45
46 constructor(TimeLock _timeLock) {
47 timeLock = TimeLock(_timeLock);
48 }
49
50 fallback() external payable {}
51
52 function attack() public payable {
53 timeLock.deposit{value: msg.value}();
54 /*
55 if t = tempo di blocco corrente, allora dobbiamo trovare x tale che
56 x + t = 2**256 = 0
57 so x = -t
58 2**256 = type(uint).max + 1
59 so x = type(uint).max + 1 - t
60 */
61 timeLock.increaseLockTime(
62 type(uint).max + 1 - timeLock.lockTime(address(this))
63 );
64 timeLock.withdraw();
65 }
66}
Mostra tutto
Come prevenire sottoeccedenze e sovraflussi di interi

Dalla versione 0.8.0, il compilatore di Solidity rifiuta il codice risultante in sottoeccedenze e sovraflussi di interi. Tuttavia, i contratti compilati con una versione inferiore del compilatore dovrebbero eseguire controlli sulle funzioni che comportano operazioni aritmetiche, oppure utilizzare una libreria (es., SafeMath(opens in a new tab)) che controlli sottoeccedenze/sovraflussi.

Manipolazione degli oracoli

Gli oracoli si procurano le informazioni al di fuori della catena e le inviano sulla catena per l'uso dai contratti intelligenti. Con gli oracoli puoi progettare dei contratti intelligenti che interagiscono con sistemi esterni alla catena, come i mercati dei capitali, espandendo notevolmente la loro applicazione.

Ma se l'oracolo è corrotto e invia informazioni errate sulla catena, i contratti intelligenti saranno eseguiti sulla base di input errati, il che può causare problemi. Questa è la base del "problema degli oracoli", che riguarda il compito di assicurarsi che le informazioni provenienti da un oracolo della blockchain siano accurate, aggiornate e tempestive.

Un problema di sicurezza correlato è l'utilizzo di un oracolo sulla catena, come uno scambio decentralizzato, per ottenere il prezzo a pronti per un bene. Le piattaforme di prestito nell'industria della finanza decentralizzata (DeFi) lo fanno spesso per determinare il valore della garanzia di un utente, per determinare quanto possa prendere in prestito.

I prezzi del DEX sono spesso accurati, in gran parte a causa degli arbitri che ripristinano la parità nei mercati. Tuttavia, sono aperti alla manipolazione, in particolare se l'oracolo sulla catena calcola i prezzi dei beni sulla base degli schemi di negoziazione storici (come di solito accade).

Ad esempio, un utente malevolo potrebbe pompare artificialmente il prezzo a pronti di un bene richiedendo un prestito flash poco prima di interagire con il tuo contratto di prestito. L'interrogazione del DEX per sapere il prezzo del bene restituirebbe un valore superiore al normale (a causa del grande "ordine d'acquisto" dell'utente malevolo, distorcendo la domanda per il bene), consentendogli di prendere in prestito più del dovuto. Tali "attacchi di prestito flash" sono stati utilizzati per sfruttare la dipendenza dagli oracoli dei prezzi tra le applicazioni DeFi, costando ai protocolli milioni in fondi perduti.

Come prevenire la manipolazione degli oracoli

Il requisito minimo per evitare la manipolazione degli oracoli è utilizzare una rete di oracoli decentralizzati che richieda le informazioni da fonti multiple per evitare singoli punti di errore. In gran parte dei casi, gli oracoli decentralizzati contengono incentivi criptoeconomici integrati per incoraggiare i nodi dell'oracolo a segnalare le informazioni corrette, rendendoli più sicuri rispetto agli oracoli centralizzati.

Se prevedi di interrogare un oracolo sulla catena per conoscere i prezzi dei beni, valuta di utilizzarne uno che implementi un meccanismo di prezzo medio ponderato per il tempo (TWAP). Un oracolo TWAP(opens in a new tab) interroga il prezzo di un bene in due momenti differenti (che puoi modificare) e calcola il prezzo a pronti a seconda della media ottenuta. La scelta di periodi di tempo più lunghi protegge il tuo protocollo dalla manipolazione dei prezzi poiché i grandi ordini eseguiti di recente non possono influire sui prezzi dei beni.

Risorse di sicurezza dei contratti intelligenti per sviluppatori

Strumenti per analizzare i contratti intelligenti e verificare la correttezza del codice

  • Strumenti e librerie di test: raccolta di strumenti e librerie standard per eseguire unit test, analisi statiche e dinamiche sui contratti intelligenti.

  • Strumenti di verifica formale: strumenti per verificare la correttezza funzionale nei contratti intelligenti e controllare le invarianti.

  • Servizi di controllo dei contratti intelligenti: elenco di organizzazioni che forniscono servizi di controllo dei contratti intelligenti per progetti di sviluppo per Ethereum.

  • Piattaforme di bug bounty: piattaforme per coordinare le ricompense per la segnalazione di bug e ricompensare la divulgazione responsabile delle vulnerabilità critiche nei contratti intelligenti.

  • Fork Checker(opens in a new tab): uno strumento online gratuito per verificare tutte le informazioni disponibili riguardanti un contratto diramato.

  • ABI Encoder(opens in a new tab): un servizio online gratuito per la codifica delle funzioni e degli argomenti del costruttore del tuo contratto in Solidity.

Strumenti per monitorare i contratti intelligenti

Strumenti per l'amministrazione sicura dei contratti intelligenti

Servizi di controllo dei contratti intelligenti

Piattaforme di bug bounty

Pubblicazioni di vulnerabilità e sfruttamenti noti dei contratti intelligenti

Sfide per imparare sulla sicurezza dei contratti intelligenti

Migliori pratiche per proteggere i contratti intelligenti

Tutorial sulla sicurezza dei contratti intelligenti

  • Come scrivere contratti intelligenti sicuri

  • Come usare Slither per trovare i bug dello Smart Contract

  • Come usare Manticore per trovare bug nei contratti intelligenti

  • Linee guida di sicurezza per gli Smart Contract

  • Come integrare in sicurezza il tuo contratto dei token con token arbitrari

Questo articolo è stato utile?