Technologies utilisées dans l'analyseur de code PVS-Studio pour rechercher des erreurs et des vulnérabilités potentielles

Technologie et magie

Une brève description des technologies utilisées dans l'outil PVS-Studio qui peuvent détecter efficacement un grand nombre de modèles d'erreur et de vulnérabilités potentielles. L'article décrit la mise en œuvre de l'analyseur pour le code C et C ++, cependant, les informations ci-dessus sont également valables pour les modules chargés d'analyser le code C # et Java.

Présentation


Il existe des idées fausses selon lesquelles les analyseurs de code statiques sont des programmes assez simples, qui sont basés sur la recherche de modèles de code à l'aide d'expressions régulières. C'est loin de la vérité. De plus, l'identification de la grande majorité des erreurs à l'aide d'expressions régulières n'est tout simplement pas possible .

L'erreur s'est produite sur la base de l'expérience des programmeurs lorsqu'ils travaillaient avec certains outils qui existaient il y a 10 à 20 ans. Le travail des outils se résumait souvent à trouver des modèles dangereux de code et des fonctions telles que strcpy , strcat , etc. En tant que représentant de cette classe d'outils, on peut appeler RATS .

De tels outils, bien qu'ils puissent être utiles, étaient généralement stupides et inefficaces. C'est à partir de cette époque que de nombreux programmeurs ont encore des souvenirs que les analyseurs statiques sont des outils très inutiles qui interfèrent plus avec le travail que de l'aider.

Le temps a passé et les analyseurs statiques ont commencé à constituer des solutions complexes qui effectuent une analyse approfondie du code et détectent les erreurs qui restent dans le code même après un examen minutieux du code. Malheureusement, en raison de l'expérience négative passée, de nombreux programmeurs considèrent toujours la méthodologie d'analyse statique inutile et ne sont pas pressés de l'introduire dans le processus de développement.

Dans cet article, je vais essayer de corriger un peu la situation. Je demande aux lecteurs de prendre 15 minutes pour se familiariser avec les technologies utilisées dans l'analyseur de code statique PVS-Studio pour détecter les erreurs. Peut-être qu'après cela, vous jeterez un nouveau regard sur les outils d'analyse statique et voudrez les appliquer dans votre travail.

Analyse du flux de données


L'analyse du flux de données vous permet de trouver une variété d'erreurs. Parmi eux: sortir des limites d'un tableau, fuites de mémoire, toujours des conditions vraies / fausses, déréférencer un pointeur nul, etc.

En outre, l'analyse des données peut être utilisée pour rechercher des situations lorsque des données non vérifiées provenant du programme de l'extérieur sont utilisées. Un attaquant peut préparer un tel ensemble de données d'entrée pour faire fonctionner le programme comme il le souhaite. En d'autres termes, il peut utiliser l'erreur de contrôle d'entrée insuffisant comme vulnérabilité. Pour rechercher l'utilisation de données non vérifiées dans PVS-Studio, le diagnostic spécialisé V1010 a été implémenté et continue de s'améliorer.

L'analyse du flux de données ( Data-Flow Analysis ) consiste à calculer les valeurs possibles des variables en différents points d'un programme informatique. Par exemple, si le pointeur est déréférencé et qu'il est connu qu'à ce moment il peut être nul, alors c'est une erreur et l'analyseur statique le signalera.

Regardons un exemple pratique d'utilisation de l'analyse de flux de données pour rechercher des erreurs. Nous avons devant nous une fonction du projet Protocol Buffers (protobuf), conçue pour vérifier l'exactitude de la date.

static const int kDaysInMonth[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; bool ValidateDateTime(const DateTime& time) { if (time.year < 1 || time.year > 9999 || time.month < 1 || time.month > 12 || time.day < 1 || time.day > 31 || time.hour < 0 || time.hour > 23 || time.minute < 0 || time.minute > 59 || time.second < 0 || time.second > 59) { return false; } if (time.month == 2 && IsLeapYear(time.year)) { return time.month <= kDaysInMonth[time.month] + 1; } else { return time.month <= kDaysInMonth[time.month]; } } 

L'analyseur PVS-Studio a détecté deux erreurs logiques dans la fonction à la fois et affiche les messages suivants:

  • V547 / CWE-571 L'expression 'time.month <= kDaysInMonth [time.month] + 1' est toujours vraie. time.cc 83
  • V547 / CWE-571 L'expression 'time.month <= kDaysInMonth [time.month]' est toujours vraie. time.cc 85

Notez la sous-expression «time.month <1 || time.month> 12 ". Si la valeur du mois est en dehors de la plage [1..12], la fonction arrête son travail. L'analyseur en tient compte et sait que si la deuxième instruction if a commencé à être exécutée, alors la valeur du mois se situe exactement dans la plage [1..12]. De même, il connaît l'éventail d'autres variables (année, jour, etc.), mais elles ne nous intéressent pas pour le moment.

Voyons maintenant deux opérateurs identiques pour accéder aux éléments du tableau: kDaysInMonth [time.month] .

Le tableau est défini statiquement et l'analyseur connaît les valeurs de tous ses éléments:

 static const int kDaysInMonth[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; 

Comme les mois sont numérotés à partir de 1, l'analyseur ne prend pas en compte 0 au début du tableau. Il s'avère qu'une valeur dans la plage [28..31] peut être extraite du tableau.

Selon que l'année est bissextile ou non, 1 est ajouté au nombre de jours, mais ce n'est pas non plus intéressant pour nous maintenant. Les comparaisons elles-mêmes sont importantes:

 time.month <= kDaysInMonth[time.month] + 1; time.month <= kDaysInMonth[time.month]; 

La plage [1..12] (nombre de mois) est comparée au nombre de jours du mois.

En considérant que dans le premier cas le mois est toujours février ( time.month == 2 ), on obtient que les plages suivantes sont comparées:

  • 2 <= 29
  • [1..12] <= [28..31]

Comme vous pouvez le voir, le résultat de la comparaison est toujours vrai, ce à quoi l'analyseur PVS-Studio met en garde. En effet, le code contient deux fautes de frappe identiques. Le côté gauche de l'expression doit utiliser un membre de la classe de jour , pas un mois du tout.

Le code correct devrait être comme ceci:

 if (time.month == 2 && IsLeapYear(time.year)) { return time.day <= kDaysInMonth[time.month] + 1; } else { return time.day <= kDaysInMonth[time.month]; } 

L'erreur discutée ici a également été précédemment décrite dans l'article « 31 février ».

Exécution symbolique


Dans la section précédente, nous avons considéré une méthode où l'analyseur calcule les valeurs possibles des variables. Cependant, pour trouver des erreurs, il n'est pas nécessaire de connaître les valeurs des variables. L'exécution symbolique signifie la résolution d'équations sous forme symbolique.

Je n'ai pas trouvé de démo appropriée dans notre base de données d'erreurs , alors considérez un exemple de code synthétique.

 int Foo(int A, int B) { if (A == B) return 10 / (A - B); return 1; } 

L'analyseur PVS-Studio génère un avertissement V609 / CWE-369 Diviser par zéro. Dénominateur 'A - B' == 0. test.cpp 12

Les valeurs des variables A et B sont inconnues de l'analyseur. Mais l'analyseur sait qu'au moment du calcul de l'expression 10 / (A - B), les variables A et B sont égales. Par conséquent, la division par 0 se produira.

J'ai dit que les valeurs de A et B sont inconnues. Pour le cas général, cela est vrai. Cependant, si l'analyseur voit un appel de fonction avec des valeurs spécifiques des arguments réels, il en tiendra compte. Prenons un exemple:

 int Div(int X) { return 10 / X; } void Foo() { for (int i = 0; i < 5; ++i) Div(i); } 

L'analyseur PVS-Studio avertit de la division par zéro: V609 CWE-628 Diviser par zéro. Dénominateur 'X' == 0. La fonction 'Div' traite la valeur '[0..4]'. Inspectez le premier argument. Vérifiez les lignes: 106, 110. consoleapplication2017.cpp 106

Un mélange de technologies fonctionne déjà ici: l'analyse des flux de données, l'exécution symbolique et l'annotation automatique des méthodes (nous discuterons de cette technologie dans la section suivante). L'analyseur voit que la variable X est utilisée comme diviseur dans la fonction Div . Sur cette base, une annotation spéciale est automatiquement créée pour la fonction Div . Il est en outre pris en compte qu'une plage de valeurs [0..4] est passée à la fonction comme argument X. L'analyseur conclut que la division par 0 devrait se produire.

Annotations de méthode


Notre équipe a annoté des milliers de fonctions et de cours fournis dans:

  • Winapi
  • Bibliothèque standard C
  • bibliothèque de modèles standard (STL),
  • glibc (bibliothèque GNU C)
  • Qt
  • Mfc
  • zlib
  • libpng
  • Openssl
  • et ainsi de suite

Toutes les fonctions sont annotées manuellement, ce qui vous permet de définir de nombreuses caractéristiques importantes en termes de recherche d'erreurs. Par exemple, il est spécifié que la taille du tampon transmis à la fonction fread ne doit pas être inférieure au nombre d'octets dont la lecture est prévue dans le fichier. La relation entre les 2e, 3e arguments et la valeur que la fonction peut renvoyer est également indiquée. Tout ressemble à ceci:

PVS-Studio: balisage de fonction

Grâce à cette annotation, le code suivant, qui utilise la fonction fread , révélera immédiatement deux erreurs.

 void Foo(FILE *f) { char buf[100]; size_t i = fread(buf, sizeof(char), 1000, f); buf[i] = 1; .... } 

Avertissements de PVS-Studio:
  • V512 CWE-119 Un appel de la fonction 'fread' entraînera un débordement de la mémoire tampon 'buf'. test.cpp 116
  • V557 CWE-787 Le dépassement de matrice est possible. La valeur de l'indice «i» pourrait atteindre 1000. test.cpp 117

Tout d'abord, l'analyseur a multiplié les 2e et 3e arguments réels et calculé que la fonction peut lire jusqu'à 1 000 octets de données. Dans ce cas, la taille de la mémoire tampon n'est que de 100 octets et elle peut déborder.

Deuxièmement, comme la fonction peut lire jusqu'à 1 000 octets, la plage de valeurs possibles de la variable i est [0..1000]. Par conséquent, l'accès au tableau peut se produire au mauvais index.

Regardons un autre exemple simple d'erreur, dont la détection a été rendue possible grâce au balisage de la fonction memset . Voici un extrait de code du projet CryEngine V.

 void EnableFloatExceptions(....) { .... CONTEXT ctx; memset(&ctx, sizeof(ctx), 0); .... } 

L'analyseur PVS-Studio a trouvé une faute de frappe: V575 La fonction 'memset' traite les éléments '0'. Inspectez le troisième argument. crythreadutil_win32.h 294

Confondu les 2e et 3e arguments de la fonction. Par conséquent, la fonction traite 0 octet et ne fait rien. L'analyseur remarque cette anomalie et en avertit les programmeurs. Plus tôt, nous avons déjà décrit cette erreur dans l'article "Le contrôle tant attendu de CryEngine V ".

L'analyseur PVS-Studio n'est pas limité aux annotations que nous avons définies manuellement. De plus, il essaie indépendamment de créer des annotations en étudiant les corps de fonctions. Cela vous permet de trouver des erreurs d'utilisation incorrecte des fonctions. Par exemple, l'analyseur se souvient qu'une fonction peut retourner nullptr. Si le pointeur renvoyé par cette fonction est utilisé sans vérification préalable, l'analyseur en avertira. Un exemple:

 int GlobalInt; int *Get() { return (rand() % 2) ? nullptr : &GlobalInt; } void Use() { *Get() = 1; } 

Avertissement: V522 CWE-690 Il peut y avoir un déréférencement d'un pointeur nul potentiel 'Get ()'. test.cpp 129

Remarque Vous pouvez aborder la recherche de l'erreur qui vient d'être examinée de la manière opposée. Je ne me souviens de rien et chaque fois qu'un appel à la fonction Get est rencontré, analysez-le en connaissant les arguments réels. Un tel algorithme vous permet théoriquement de trouver plus d'erreurs, mais il a une complexité exponentielle. Le temps d'analyse du programme augmente des centaines de milliers de fois, et nous considérons cette approche comme une impasse d'un point de vue pratique. Dans PVS-Studio, nous développons le sens de l'annotation automatique des fonctions.

Correspondance de motifs


La technologie correspondant à un motif, à première vue, peut sembler être une recherche d'expressions régulières. En fait, ce n'est pas le cas, et tout est beaucoup plus compliqué.

Premièrement, comme je l'ai déjà dit , les expressions régulières sont généralement sans valeur. Deuxièmement, les analyseurs ne fonctionnent pas avec des lignes de texte, mais avec des arbres de syntaxe, ce qui permet de reconnaître des modèles d'erreur plus complexes et de haut niveau.

Prenons deux exemples, un plus simple et un plus complexe. La première erreur que j'ai trouvée lors de la vérification du code source pour Android.

 void TagMonitor::parseTagsToMonitor(String8 tagNames) { std::lock_guard<std::mutex> lock(mMonitorMutex); if (ssize_t idx = tagNames.find("3a") != -1) { ssize_t end = tagNames.find(",", idx); char* start = tagNames.lockBuffer(tagNames.size()); start[idx] = '\0'; .... } .... } 

L'analyseur PVS-Studio reconnaît le modèle d'erreur classique associé à la conception erronée d'un programmeur concernant la priorité des opérations en C ++: V593 / CWE-783 Envisagez de revoir l'expression du type 'A = B! = C'. L'expression est calculée comme suit: «A = (B! = C)». TagMonitor.cpp 50

Jetez un œil à cette ligne:

 if (ssize_t idx = tagNames.find("3a") != -1) { 

Le programmeur suppose qu'une affectation est effectuée au début, puis seulement une comparaison avec -1 . En fait, la comparaison vient en premier. Classique Cette erreur est décrite plus en détail dans l' article consacré à la vérification Android (voir le chapitre "Autres erreurs").

Considérons maintenant une option de correspondance de modèle de niveau supérieur.

 static inline void sha1ProcessChunk(....) { .... quint8 chunkBuffer[64]; .... #ifdef SHA1_WIPE_VARIABLES .... memset(chunkBuffer, 0, 64); #endif } 

Avertissement PVS-Studio: V597 CWE-14 Le compilateur peut supprimer l'appel de fonction 'memset', qui est utilisé pour vider le tampon 'chunkBuffer'. La fonction RtlSecureZeroMemory () doit être utilisée pour effacer les données privées. sha1.cpp 189

L'essence du problème est qu'après avoir rempli un tampon avec des zéros en utilisant la fonction memset , ce tampon n'est utilisé nulle part. Lors de la compilation de code avec des indicateurs d'optimisation, le compilateur décidera que cet appel de fonction est redondant et le supprimera. Il en a le droit, car du point de vue du langage C ++, appeler une fonction n'a pas de comportement observable sur le programme. Immédiatement après avoir rempli le tampon chunkBuffer , la fonction sha1ProcessChunk se termine. Étant donné que le tampon est créé sur la pile, après avoir quitté la fonction, il deviendra indisponible. Par conséquent, du point de vue du compilateur, cela n'a aucun sens de le remplir de zéros.

Par conséquent, quelque part sur la pile restera des données privées, ce qui peut entraîner des problèmes. Ce sujet est traité plus en détail dans l'article " Nettoyage sécurisé des données privées ".

Ceci est un exemple de correspondance de modèle de haut niveau. Tout d'abord, l'analyseur doit être conscient de l'existence de cette faille de sécurité, classée selon l'énumération des faiblesses communes comme CWE-14: suppression du code par le compilateur pour effacer les tampons .

Deuxièmement, il doit trouver dans le code tous les endroits où le tampon est créé sur la pile, il est effacé à l'aide de la fonction memset et n'est utilisé nulle part ailleurs.

Conclusion


Comme vous pouvez le voir, l'analyse statique est une méthodologie très intéressante et utile. Il vous permet d'éliminer un grand nombre d'erreurs et de vulnérabilités potentielles dès les premières étapes (voir SAST ). Si vous n'êtes toujours pas complètement imprégné par l'analyse statique, je vous invite à lire notre blog , où nous analysons régulièrement les erreurs trouvées en utilisant PVS-Studio dans divers projets. Vous ne pouvez tout simplement pas rester indifférent.

Nous serons heureux de voir votre entreprise parmi nos clients et vous aiderons à rendre vos applications meilleures, plus fiables et plus sécurisées.



Si vous souhaitez partager cet article avec un public anglophone, veuillez utiliser le lien vers la traduction: Andrey Karpov. Technologies utilisées dans l'analyseur de code PVS-Studio pour trouver des bogues et des vulnérabilités potentielles .

Source: https://habr.com/ru/post/fr430604/


All Articles