Principali vulnerabilità negli smart contract

Gli smart contract sono i pezzi di codice che stanno alla base di molte applicazioni decentralizzate e della tecnologia blockchain. Il loro funzionamento è basato su una serie di regole programmabili che devono essere rispettate per garantire l’esecuzione delle operazioni previste. Tuttavia, nonostante l’efficacia di questa tecnologia, ci sono alcune vulnerabilità che possono compromettere la sicurezza degli smart contract. Di seguito elencheremo una lista delle principali vulnerabilità di solidity, il linguaggio di programmazione degli smart contract, e per ognuna di esse verrà riportato un esempio di codice vulnerabile e successivamente il codice che ne risolve la vulnerabilità.

Overflow

L’overflow è una vulnerabilità che si verifica quando una variabile supera il valore massimo consentito. In questo caso, il valore della variabile viene riportato al valore minimo e si creano quindi problemi per l’esecuzione dell’applicazione. Questa vulnerabilità può essere risolta aggiungendo una verifica alla variabile che impedisce il superamento del valore massimo consentito.

Esempio di vulnerabilità:

uint8 a = 255;
a = a + 1; // overflow

Il codice è vulnerabile perché causa un overflow numerico. La variabile a viene inizializzata con il valore massimo possibile di uint8, che è 255. Quando viene aggiunto 1 a a, il valore di a supera il valore massimo di uint8, che è 255, e quindi viene effettuato un overflow. In Solidity, quando si verifica un overflow o un underflow numerico, il valore di una variabile può diventare imprevedibile e ciò può portare a comportamenti indesiderati o addirittura a vulnerabilità di sicurezza

Codice corretto:

uint8 a = 255;
require(a < 255);
a = a + 1; 

Il secondo codice è corretto perché utilizza la funzione require per verificare che il valore di a sia inferiore a 255 prima di effettuare l’operazione di incremento. Se il valore di a è uguale a 255, la funzione require interromperà l’esecuzione del contratto e restituirà un errore. In questo modo, si evita l’overflow numerico e si garantisce che il valore di a rimanga sempre all’interno del range consentito per uint8, ovvero compreso tra 0 e 255.

Race condition

La race condition è una vulnerabilità che si verifica quando due o più funzioni competono per accedere alle stesse risorse contemporaneamente. In questo caso, una funzione può modificare le risorse mentre un’altra funzione sta lavorando su di esse, causando l’errore dell’applicazione. Questa vulnerabilità può essere risolta utilizzando la variabile “mutex” che impedisce l’accesso contemporaneo alle risorse.

Esempio di vulnerabilità:

uint balance = 100;
function withdraw(uint amount) public {
   require(balance >= amount);
   msg.sender.transfer(amount);
   balance -= amount;
}
function deposit() public payable {
   balance += msg.value;
}

Il codice è vulnerabile ad un attacco di race condition. L’attaccante può sfruttare questa vulnerabilità chiamando contemporaneamente la funzione withdraw e la funzione deposit in modo che le due funzioni accedano alla variabile balance allo stesso tempo. In questo modo, l’attaccante può prelevare i fondi dal conto balance di contract A più volte di quanto sia consentito.

In particolare, l’attaccante può utilizzare la funzione deposit per incrementare il valore di balance e contemporaneamente utilizzare la funzione withdraw per prelevare i fondi dal conto balance. Se le due funzioni vengono eseguite contemporaneamente, è possibile che l’attaccante possa prelevare più fondi di quanto sia presente nel conto balance di contract A.

Codice corretto:

uint balance = 100;
bool mutex = false;
function withdraw(uint amount) public {
   require(balance >= amount);
   require(mutex == false);
   mutex = true;
   msg.sender.transfer(amount);
   balance -= amount;
   mutex = false;
}
function deposit() public payable {
   require(mutex == false);
   mutex = true;
   balance += msg.value;
   mutex = false;
}

Il secondo codice è corretto perché utilizza una variabile di blocco mutex per garantire che solo una funzione alla volta possa accedere alla variabile balance. Il blocco mutex funziona come un meccanismo di protezione per la variabile balance che assicura che una funzione possa accedere alla variabile solo se nessuna altra funzione la sta già utilizzando.

Nella funzione withdraw, la variabile di blocco mutex viene impostata su true prima di prelevare i fondi dal conto balance. In questo modo, nessuna altra funzione può accedere alla variabile balance mentre la funzione withdraw la sta utilizzando. Dopo aver prelevato i fondi, la variabile mutex viene impostata su false per indicare che la funzione withdraw ha finito di utilizzare la variabile balance.

Nella funzione deposit, la variabile di blocco mutex viene impostata su true prima di incrementare il valore di balance. In questo modo, nessuna altra funzione può accedere alla variabile balance mentre la funzione deposit la sta utilizzando. Dopo aver incrementato il valore di balance, la variabile mutex viene impostata su false per indicare che la funzione deposit ha finito di utilizzare la variabile balance.

In questo modo, si previene qualsiasi tentativo di attacco di race condition e si garantisce che solo una funzione alla volta possa accedere alla variabile balance.

Cross-entity attack

Il cross-entity attack è una vulnerabilità che si verifica quando un contratto accetta input da un’altra entità senza verificare la sua provenienza. In questo caso, un attaccante può utilizzare un contratto malevolo per rubare informazioni da un altro contratto o per compromettere la sicurezza dell’applicazione. Questa vulnerabilità può essere risolta utilizzando la funzione “modifier” che verifica la provenienza dell’input.

Esempio di vulnerabilità:

contract A {
   uint balance = 100;
   function withdraw(uint amount) public {
      require(balance >= amount);
      msg.sender.transfer(amount);
      balance -= amount;
   }
}
contract B {
   function hack(address a) public {
      A(a).withdraw(100);
   }
}

Il codice è vulnerabile perché la funzione withdraw di contract A non prevede alcun controllo sull’autore della transazione. Pertanto, qualsiasi persona o contratto che chiama la funzione withdraw può prelevare i fondi dal conto balance di contract A.

Il contratto B in questo caso rappresenta un possibile attaccante che intende sfruttare questa vulnerabilità. L’attaccante può utilizzare la funzione hack di contract B per chiamare la funzione withdraw di contract A e prelevare i fondi dal conto balance.

Codice corretto:

contract A {
   uint balance = 100;
   function withdraw(uint amount) public onlyOwner {
      require(balance >= amount);
      msg.sender.transfer(amount);
      balance -= amount;
   }j
   modifier onlyOwner {
      require(msg.sender == owner);
      _;
   }
}
contract B {
   function hack(address a) public {
      A(a).withdraw(100);
   }
}

Il secondo codice è corretto perché utilizza un modificatore onlyOwner per garantire che solo il proprietario del contratto possa chiamare la funzione withdraw. In questo modo, si previene qualsiasi tentativo di attacco come quello descritto sopra.

Il modificatore onlyOwner controlla che la transazione sia stata inviata dal proprietario del contratto, che deve essere definito nella definizione del contratto come una variabile address. Se la condizione require all’interno del modificatore non viene soddisfatta, la transazione viene interrotta e non viene eseguita la chiamata alla funzione withdraw.

Inoltre, è stata aggiunta la parola chiave onlyOwner nella definizione della funzione withdraw per indicare che solo il proprietario del contratto può chiamarla. Così, il modificatore onlyOwner viene automaticamente eseguito prima di eseguire la funzione withdraw.

Man in the middle attack

La man in the middle attack è una vulnerabilità che si verifica quando un attaccante intercetta la comunicazione tra due entità e modifica i dati trasmessi. In questo caso, un attaccante può compromettere la sicurezza dell’applicazione modificando i dati trasmessi tra gli smart contract. Questa vulnerabilità può essere risolta utilizzando la crittografia per proteggere la comunicazione tra gli smart contract.

Esempio di vulnerabilità:

contract A {
   uint balance = 100;
   function withdraw(uint amount) public {
      require(balance >= amount);
      msg.sender.transfer(amount);
      balance -= amount;
   }
}
contract B {
   address a = 0x123;
   function hack() public {
      A(a).withdraw(100);
   }
}

Il codice è vulnerabile ad un attacco di impersonificazione. La funzione withdraw di contract A può essere chiamata da chiunque abbia accesso all’indirizzo del contratto A. L’attaccante nel contratto B potrebbe creare una istanza di A con lo stesso indirizzo del contratto originale e utilizzare la funzione withdraw per prelevare i fondi dal conto balance di contract A.

In particolare, l’attaccante può creare una istanza di A utilizzando lo stesso indirizzo del contratto originale e poi utilizzare la funzione withdraw per prelevare i fondi dal conto balance. Questo è possibile perché il contratto B ha accesso all’indirizzo del contratto A.

Codice corretto:

contract A {
   uint balance = 100;
   function withdraw(uint amount) public {
      require(balance >= amount);
      msg.sender.transfer(amount);
      balance -= amount;
   }
}
contract B {
   address a = 0x123;
   function hack() public {
      bytes4 func = bytes4(keccak256("withdraw(uint256)"));
      uint amount = 100;
      a.call.value(0)(abi.encodeWithSelector(func, amount));
   }
}

Il secondo codice è corretto perché utilizza la funzione call per chiamare la funzione withdraw invece di utilizzare la sintassi di chiamata diretta del contratto. La funzione call permette di specificare il nome della funzione e i parametri attraverso il quale la funzione viene chiamata.

In questo modo, l’attaccante non può più impersonificare il contratto A poiché il contratto B sta chiamando la funzione withdraw utilizzando la funzione call e specificando il nome della funzione e i parametri. Inoltre, il contratto B non ha accesso al codice sorgente del contratto A, quindi non può utilizzare il nome della funzione o i parametri per chiamare la funzione withdraw in modo errato.

Reentrancy attack

La reentrancy attack è una vulnerabilità che si verifica quando un contratto richiama una funzione di un altro contratto prima di completare l’esecuzione della funzione iniziale. In questo caso, un attaccante può utilizzare un contratto malevolo per richiamare la funzione del primo contratto più volte, causando il crollo dell’applicazione. Questa vulnerabilità può essere risolta utilizzando la funzione “mutex” che impedisce l’accesso contemporaneo alle risorse.

Esempio di vulnerabilità:

contract A {
   uint balance = 100;
   function withdraw(uint amount) public {
      require(balance >= amount);
      msg.sender.transfer(amount);
      balance -= amount;
   }
}
contract B {
   address a = 0x123;
   function hack() public {
      A(a).withdraw(100);
   }
   function() payable {
      A(a).withdraw(100);
   }
}

Il codice è vulnerabile ad un attacco di “fallback function”. La funzione hack() di contract B chiama la funzione withdraw() del contratto A e passa un valore fisso di 100. Tuttavia, se l’attaccante invia ETH al contratto B, il fallback function verrà attivata automaticamente e invocherà la funzione withdraw() del contratto A con un importo arbitrario di ETH. Questo permette all’attaccante di prelevare fondi dal conto balance del contratto A.

Codice corretto:

contract A {
   uint balance = 100;
   bool mutex = false;
   function withdraw(uint amount) public {
      require(balance >= amount);
      require(mutex == false);
      mutex = true;
      msg.sender.transfer(amount);
      balance -= amount;
      mutex = false;
   }
}
contract B {
   address a = 0x123;
   function hack() public {
      A(a).withdraw(100);
   }
   function() payable {
      A(a).withdraw(100);
   }
}

Il secondo codice è corretto perché utilizza un mutex per prevenire attacchi di concorrenza alla funzione withdraw(). Il mutex garantisce che solo una chiamata alla funzione withdraw() possa essere eseguita alla volta. In questo modo, anche se la fallback function viene attivata, l’attaccante non può prelevare i fondi dal conto balance del contratto A perché la funzione withdraw() è protetta da un mutex che garantisce che solo una transazione possa essere eseguita alla volta.

Inoltre, il secondo codice non ha bisogno della fallback function, quindi può essere rimossa dal contratto B. Questo previene ulteriori vulnerabilità e rende il codice più leggibile.

Deprecation

La deprecation è una vulnerabilità che si verifica quando una funzione viene utilizzata dopo essere stata deprecata. In questo caso, l’applicazione può essere compromessa se la funzione deprecata viene utilizzata da un attaccante. Questa vulnerabilità può essere risolta utilizzando la funzione “modifier” che indica quando una funzione è stata deprecata.

Esempio di vulnerabilità:

contract A {
   uint balance = 100;
   function withdraw(uint amount) public {
      require(balance >= amount);
      msg.sender.transfer(amount);
      balance -= amount;
   }
   function withdrawAll() public {
      msg.sender.transfer(balance);
      balance = 0;
   }
}

Il codice originale è vulnerabile perché la funzione withdrawAll() consente a qualsiasi utente di trasferire l’intero bilancio dell’account A senza alcun controllo. Ciò potrebbe consentire a un utente malintenzionato di svuotare completamente l’account A.

Codice corretto:

contract A {
   uint balance = 100;
   bool deprecated = false;
   function withdraw(uint amount) public {
      require(balance >= amount);
      msg.sender.transfer(amount);
      balance -= amount;
   }
   function withdrawAll() public {
      require(deprecated == false);
      deprecated = true;
      msg.sender.transfer(balance);
      balance = 0;
   }
}

Il codice corretto introduce una variabile deprecated che funziona come un interruttore. Quando viene chiamata la funzione withdrawAll(), controlla se l’interruttore è attivato. Se l’interruttore è disattivato, la funzione viene eseguita normalmente e il bilancio viene trasferito. Dopo il trasferimento, l’interruttore viene attivato. Successive chiamate alla funzione withdrawAll() saranno bloccate a causa del controllo sull’interruttore. In questo modo, è possibile evitare che l’account venga svuotato più di una volta.

Tuttavia, questo approccio non è completamente sicuro in quanto l’interruttore potrebbe essere riattivato da un utente malintenzionato. Per mitigare questo rischio, si consiglia di implementare anche un periodo di blocco dopo la prima chiamata alla funzione withdrawAll(), durante il quale la funzione non può essere chiamata di nuovo.

Conclusioni

Gli smart contract sono una tecnologia che sta rivoluzionando il modo in cui le applicazioni decentralizzate vengono sviluppate e gestite. Tuttavia, come abbiamo visto in questo articolo, ci sono alcune vulnerabilità che possono compromettere la sicurezza degli smart contract. Per proteggere i tuoi investimenti, è importante utilizzare buone pratiche di programmazione e utilizzare soluzioni di sicurezza per garantire la sicurezza dell’applicazione.