Passer au contenu principal

Comment utiliser Manticore pour trouver des bugs dans les contrats intelligents

Solidity
contrats intelligents
sécurité
tests
vérification formelle
Avancé
Trailofbits
13 janvier 2020
12 minutes de lecture

L'objectif de ce tutoriel est de montrer comment utiliser Manticore pour trouver automatiquement des bugs dans les contrats intelligents.

Installation

Manticore nécessite Python >= 3.6. Il peut être installé via pip ou en utilisant Docker.

Manticore via Docker

docker pull trailofbits/eth-security-toolbox
docker run -it -v "$PWD":/home/training trailofbits/eth-security-toolbox

La dernière commande exécute eth-security-toolbox dans un conteneur Docker qui a accès à votre répertoire actuel. Vous pouvez modifier les fichiers depuis votre hôte et exécuter les outils sur les fichiers depuis Docker.

À l'intérieur de Docker, exécutez :

solc-select 0.5.11
cd /home/trufflecon/

Manticore via pip

pip3 install --user manticore

solc 0.5.11 est recommandé.

Exécuter un script

Pour exécuter un script Python avec Python 3 :

python3 script.py

Introduction à l'exécution symbolique dynamique

L'exécution symbolique dynamique en bref

L'exécution symbolique dynamique (DSE) est une technique d'analyse de programme qui explore un espace d'état avec un haut degré de conscience sémantique. Cette technique est basée sur la découverte de « chemins de programme », représentés sous forme de formules mathématiques appelées path predicates. Conceptuellement, cette technique opère sur les prédicats de chemin en deux étapes :

  1. Ils sont construits en utilisant des contraintes sur les entrées du programme.
  2. Ils sont utilisés pour générer des entrées de programme qui provoqueront l'exécution des chemins associés.

Cette approche ne produit aucun faux positif dans le sens où tous les états de programme identifiés peuvent être déclenchés lors d'une exécution concrète. Par exemple, si l'analyse trouve un dépassement de capacité d'entier, il est garanti d'être reproductible.

Exemple de prédicat de chemin

Pour avoir un aperçu du fonctionnement de la DSE, considérez l'exemple suivant :

function f(uint a){

  if (a == 65) {
      // Un bug est présent
  }

}

Comme f() contient deux chemins, une DSE construira deux prédicats de chemin différents :

  • Chemin 1 : a == 65
  • Chemin 2 : Not (a == 65)

Chaque prédicat de chemin est une formule mathématique qui peut être donnée à ce qu'on appelle un solutionneur SMT (opens in a new tab), qui essaiera de résoudre l'équation. Pour Path 1, le solutionneur dira que le chemin peut être exploré avec a = 65. Pour Path 2, le solutionneur peut donner à a n'importe quelle valeur autre que 65, par exemple a = 0.

Vérification des propriétés

Manticore permet un contrôle total sur toute l'exécution de chaque chemin. Par conséquent, il vous permet d'ajouter des contraintes arbitraires à presque n'importe quoi. Ce contrôle permet la création de propriétés sur le contrat.

Considérez l'exemple suivant :

function unsafe_add(uint a, uint b) returns(uint c){
  c = a + b; // aucune protection contre le dépassement de capacité
  return c;
}

Ici, il n'y a qu'un seul chemin à explorer dans la fonction :

  • Chemin 1 : c = a + b

En utilisant Manticore, vous pouvez vérifier le dépassement de capacité et ajouter des contraintes au prédicat de chemin :

  • c = a + b AND (c < a OR c < b)

S'il est possible de trouver une évaluation de a et b pour laquelle le prédicat de chemin ci-dessus est réalisable, cela signifie que vous avez trouvé un dépassement de capacité. Par exemple, le solutionneur peut générer l'entrée a = 10 , b = MAXUINT256.

Si vous considérez une version corrigée :

function safe_add(uint a, uint b) returns(uint c){
  c = a + b;
  require(c>=a);
  require(c>=b);
  return c;
}

La formule associée avec la vérification de dépassement de capacité serait :

  • c = a + b AND (c >= a) AND (c=>b) AND (c < a OR c < b)

Cette formule ne peut pas être résolue ; en d'autres termes, c'est une preuve que dans safe_add, c augmentera toujours.

La DSE est donc un outil puissant, qui peut vérifier des contraintes arbitraires sur votre code.

Exécution sous Manticore

Nous allons voir comment explorer un contrat intelligent avec l'API Manticore. La cible est le contrat intelligent suivant example.sol (opens in a new tab) :

Exécuter une exploration autonome

Vous pouvez exécuter Manticore directement sur le contrat intelligent avec la commande suivante (project peut être un fichier Solidity ou un répertoire de projet) :

$ manticore project

Vous obtiendrez la sortie des cas de test comme celle-ci (l'ordre peut changer) :

Sans informations supplémentaires, Manticore explorera le contrat avec de nouvelles transactions symboliques jusqu'à ce qu'il n'explore plus de nouveaux chemins sur le contrat. Manticore n'exécute pas de nouvelles transactions après un échec (par exemple : après une annulation).

Manticore affichera les informations dans un répertoire mcore_*. Entre autres, vous trouverez dans ce répertoire :

  • global.summary : couverture et avertissements du compilateur
  • test_XXXXX.summary : couverture, dernière instruction, soldes des comptes par cas de test
  • test_XXXXX.tx : liste détaillée des transactions par cas de test

Ici, Manticore trouve 7 cas de test, qui correspondent à (l'ordre des noms de fichiers peut changer) :

Transaction 0Transaction 1Transaction 2Résultat
test_00000000.txCréation de contratf(!=65)f(!=65)STOP
test_00000001.txCréation de contratfonction de repliREVERT
test_00000002.txCréation de contratRETURN
test_00000003.txCréation de contratf(65)REVERT
test_00000004.txCréation de contratf(!=65)STOP
test_00000005.txCréation de contratf(!=65)f(65)REVERT
test_00000006.txCréation de contratf(!=65)fonction de repliREVERT

Le résumé de l'exploration f(!=65) indique que f est appelée avec n'importe quelle valeur différente de 65.

Comme vous pouvez le remarquer, Manticore génère un cas de test unique pour chaque transaction réussie ou annulée.

Utilisez l'indicateur --quick-mode si vous souhaitez une exploration rapide du code (cela désactive les détecteurs de bugs, le calcul du gaz, ...)

Manipuler un contrat intelligent via l'API

Cette section décrit en détail comment manipuler un contrat intelligent via l'API Python de Manticore. Vous pouvez créer un nouveau fichier avec l'extension Python *.py et écrire le code nécessaire en ajoutant les commandes de l'API (dont les bases seront décrites ci-dessous) dans ce fichier, puis l'exécuter avec la commande $ python3 *.py. Vous pouvez également exécuter les commandes ci-dessous directement dans la console Python ; pour lancer la console, utilisez la commande $ python3.

Création de comptes

La première chose que vous devez faire est d'initier une nouvelle chaîne de blocs avec les commandes suivantes :

from manticore.ethereum import ManticoreEVM

m = ManticoreEVM()

Un compte non contractuel est créé en utilisant m.create_account (opens in a new tab) :

user_account = m.create_account(balance=1000)

Un contrat Solidity peut être déployé en utilisant m.solidity_create_contract (opens in a new tab) :

Résumé

Exécution de transactions

Manticore prend en charge deux types de transactions :

  • Transaction brute : toutes les fonctions sont explorées
  • Transaction nommée : une seule fonction est explorée

Transaction brute

Une transaction brute est exécutée en utilisant m.transaction (opens in a new tab) :

m.transaction(caller=user_account,
              address=contract_account,
              data=data,
              value=value)

L'appelant, l'adresse, les données ou la valeur de la transaction peuvent être soit concrets, soit symboliques :

Par exemple :

symbolic_value = m.make_symbolic_value()
symbolic_data = m.make_symbolic_buffer(320)
m.transaction(caller=user_account,
              address=contract_address,
              data=symbolic_data,
              value=symbolic_value)

Si les données sont symboliques, Manticore explorera toutes les fonctions du contrat pendant l'exécution de la transaction. Il sera utile de consulter l'explication de la fonction de repli dans l'article Hands on the Ethernaut CTF (opens in a new tab) pour comprendre comment fonctionne la sélection de fonction.

Transaction nommée

Les fonctions peuvent être exécutées via leur nom. Pour exécuter f(uint var) avec une valeur symbolique, depuis user_account, et avec 0 ether, utilisez :

symbolic_var = m.make_symbolic_value()
contract_account.f(symbolic_var, caller=user_account, value=0)

Si value de la transaction n'est pas spécifié, il est de 0 par défaut.

Résumé

  • Les arguments d'une transaction peuvent être concrets ou symboliques
  • Une transaction brute explorera toutes les fonctions
  • Les fonctions peuvent être appelées par leur nom

Espace de travail

m.workspace est le répertoire utilisé comme répertoire de sortie pour tous les fichiers générés :

print("Results are in {}".format(m.workspace))

Terminer l'exploration

Pour arrêter l'exploration, utilisez m.finalize() (opens in a new tab). Aucune autre transaction ne doit être envoyée une fois cette méthode appelée et Manticore génère des cas de test pour chacun des chemins explorés.

Résumé : Exécution sous Manticore

En rassemblant toutes les étapes précédentes, nous obtenons :

Vous pouvez trouver tout le code ci-dessus dans le example_run.py (opens in a new tab)

Obtenir les chemins d'erreur

Nous allons maintenant générer des entrées spécifiques pour les chemins soulevant une exception dans f(). La cible est toujours le contrat intelligent suivant example.sol (opens in a new tab) :

pragma solidity >=0.4.24 <0.6.0;
contract Simple {
    function f(uint a) payable public{
        if (a == 65) {
            revert();
        }
    }
}

Utilisation des informations d'état

Chaque chemin exécuté a son état de la chaîne de blocs. Un état est soit prêt, soit tué, ce qui signifie qu'il atteint une instruction THROW ou REVERT :

for state in m.all_states:
    # faire quelque chose avec l'état

Vous pouvez accéder aux informations d'état. Par exemple :

  • state.platform.get_balance(account.address) : le solde du compte
  • state.platform.transactions : la liste des transactions
  • state.platform.transactions[-1].return_data : les données renvoyées par la dernière transaction

Les données renvoyées par la dernière transaction sont un tableau, qui peut être converti en une valeur avec ABI.deserialize, par exemple :

data = state.platform.transactions[0].return_data
data = ABI.deserialize("uint", data)

Comment générer un cas de test

Utilisez m.generate_testcase(state, name) (opens in a new tab) pour générer un cas de test :

m.generate_testcase(state, 'BugFound')

Résumé

  • Vous pouvez itérer sur l'état avec m.all_states
  • state.platform.get_balance(account.address) renvoie le solde du compte
  • state.platform.transactions renvoie la liste des transactions
  • transaction.return_data correspond aux données renvoyées
  • m.generate_testcase(state, name) génère des entrées pour l'état

Résumé : Obtenir le chemin d'erreur

Vous pouvez trouver tout le code ci-dessus dans le example_run.py (opens in a new tab)

Notez que nous aurions pu générer un script beaucoup plus simple, car tous les états renvoyés par terminated_state ont REVERT ou INVALID dans leur résultat : cet exemple visait uniquement à démontrer comment manipuler l'API.

Ajout de contraintes

Nous allons voir comment contraindre l'exploration. Nous ferons l'hypothèse que la documentation de f() indique que la fonction n'est jamais appelée avec a == 65, donc tout bug avec a == 65 n'est pas un vrai bug. La cible est toujours le contrat intelligent suivant example.sol (opens in a new tab) :

pragma solidity >=0.4.24 <0.6.0;
contract Simple {
    function f(uint a) payable public{
        if (a == 65) {
            revert();
        }
    }
}

Opérateurs

Le module Operators (opens in a new tab) facilite la manipulation des contraintes, il fournit entre autres :

  • Operators.AND,
  • Operators.OR,
  • Operators.UGT (strictement supérieur non signé),
  • Operators.UGE (supérieur ou égal non signé),
  • Operators.ULT (strictement inférieur non signé),
  • Operators.ULE (inférieur ou égal non signé).

Pour importer le module, utilisez ce qui suit :

from manticore.core.smtlib import Operators

Operators.CONCAT est utilisé pour concaténer un tableau à une valeur. Par exemple, le return_data d'une transaction doit être modifié en une valeur pour être vérifié par rapport à une autre valeur :

last_return = Operators.CONCAT(256, *last_return)

Contraintes

Vous pouvez utiliser des contraintes globalement ou pour un état spécifique.

Contrainte globale

Utilisez m.constrain(constraint) pour ajouter une contrainte globale. Par exemple, vous pouvez appeler un contrat à partir d'une adresse symbolique, et restreindre cette adresse à des valeurs spécifiques :

symbolic_address = m.make_symbolic_value()
m.constraint(Operators.OR(symbolic == 0x41, symbolic_address == 0x42))
m.transaction(caller=user_account,
              address=contract_account,
              data=m.make_symbolic_buffer(320),
              value=0)

Contrainte d'état

Utilisez state.constrain(constraint) (opens in a new tab) pour ajouter une contrainte à un état spécifique. Cela peut être utilisé pour contraindre l'état après son exploration afin de vérifier une propriété sur celui-ci.

Vérification de contrainte

Utilisez solver.check(state.constraints) pour savoir si une contrainte est toujours réalisable. Par exemple, ce qui suit contraindra symbolic_value à être différent de 65 et vérifiera si l'état est toujours réalisable :

state.constrain(symbolic_var != 65)
if solver.check(state.constraints):
    # l'état est réalisable

Résumé : Ajout de contraintes

En ajoutant la contrainte au code précédent, nous obtenons :

Vous pouvez trouver tout le code ci-dessus dans le example_run.py (opens in a new tab)