Analyse d'un programme potentiellement dangereux

Le problème exposé dans ce sujet a été résolu.

Salut,

Je me demande comment va s’exécuter le programme C suivant :

/**
  *     /!\ DANGER /!\
  *
  *     CE PROGRAMME PEUT POTENTIELLEMENT
  *     SATURER VOTRE MEMOIRE VIVE
  **/


int main(void)
{
    while (1)
    {
        int a = 42;
    }

    return 0;
}

Si j’interprète strictement ce qui est écrit, la boucle while va allouer (sur la pile) à chaque tour de boucle de la mémoire pour un nouvel entier et y stocker le nombre 42. Par conséquent, si l’on laisse ce programme s’exécuter trop longtemps, je pense qu’il y a un risque de saturer la mémoire vive de l’ordinateur et donc de geler l’ordinateur.

Mon analyse est-elle correcte ?
Si oui, un anti-virus serait-il capable de détecter l’exécutable du programme comme malveillant ?

Bonjour,

Je ne m’y connais pas beaucoup mais je crois que seuls les appels de fonctions réallouent un stackframe pour leurs variables locales.

En l’occurrence le int a est considéré comme une variable locale de la fonction dont la portée est limitée au bloc while.

Salut,

Je ne pense pas que ton analyse soit bonne, ça m’étonnerait que que l’espace alloué pour la variable ne soit pas libéré en fin d’itération, vu qu’il n’est plus utile.

Aussi, il y a des protections par le système contre cela, tu ne vas pas remplir la RAM. Le plus simple c’est de faire une récursion infinie dans un programme : tu remplies effectivement l’espace alloué pour la pile du programme, et donc tu obtiens une erreur stack overflow (et oui, c’est de là que vient le nom du célèbre site) ou segmentation fault.

Salut.

La variable que tu déclares est localisée sur la pile.

Toutes les variables allouées sur la pile sont détruites en fin de bloc (fonction, boucle, etc).

Ça, en revanche, c’est dangereux :

#include <stdlib.h>

int main(void)
{
    while (1)
    {
        malloc(1);
    }

    return 0;
}

Car on alloue de la mémoire sur le tas ; ce qui nécessite une libération manuelle de cette mémoire au moyen de free.

Edit :

Si oui, un anti-virus serait-il capable de détecter l’exécutable du programme comme malveillant ?

Ce n’est pas un code malveillant. Et la majorité des antivirus fonctionnent avec des signatures, donc ça peut être facile à détecter, oui.

+0 -0

J’ai testé le programme et ça ne sature pas la mémoire. Ça met un peu le processeur au boulot (~ 12 %) à cause de la boucle infinie cependant.

Outre les explications données plus haut qui expliquent pourquoi ça ne sature pas la mémoire de base, dans ce cas précis comme la variable ne sert à rien, le compilateur est malin et va en fait le virer du binaire et donc pour sûr il ne pourra rien se passer à son sujet.

+2 -0

Ici, on a une variable automatique. Sa portée détermine donc sa durée de vie et donc au final, on aura toujours qu’une variable. Le seul truc qui va chauffer c’est l’utilisation du CPU.

Même si la définition de la variable appelée récursivement par exemple:

int main(void) {
    int a = 11;
    main();
}

Alors on aurait statuerait la structure qu’utilise le système d’exploitation pour gérer le processus. C’est la stack comme l’explique entwanne.

Pour saturer la ram il faudrait appeler malloc.

#include<stdlib.h>

int main(void) {
    while(malloc(1));
}

Ceci va effectivement saturer ta RAM, et ce très rapidement, quelques secondes. Le programme va quitter et si tout se passe bien, le système d’exploitation va libérer la RAM. Le programme de @Ge0 par-contre est un peu plus problématique car la mémoire est monopolisée par le processus et doit être arrêter manuellement ce qui peut être plus au moins compliqué vu que la RAM est saturée.

Bien-sûr, ce genre de cas (malloc non libéré, peuvent être détecter automatiquement par exemple par Valgrind.

+1 -0

J’ai testé le programme et ça ne sature pas la mémoire. Ça met un peu le processeur au boulot (~ 12 %) à cause de la boucle infinie cependant.

Outre les explications données plus haut qui expliquent pourquoi ça ne sature pas la mémoire de base, dans ce cas précis comme la variable ne sert à rien, le compilateur est malin et va en fait le virer du binaire et donc pour sûr il ne pourra rien se passer à son sujet.

Renault

C’est vrai si on compile avec les flags d’optimisation: sans, ça donne

 5fe:   c7 45 fc 2a 00 00 00    movl   $0x2a,-0x4(%rbp) //a = 42
 605:   eb f7                   jmp    5fe <main+0x4> //Étant la ligne juste au dessus

Avec -O1:

5fa:   eb fe                   jmp    5fa <main>

Je pensais qu’il aurait supprimé la boucle vu qu’elle pourrait être considérée comme inutile, mais visiblement il jump à l’infini.

Il me semble que clang avec -O3 va faire sauter la pile (cf. un article sur le sujet dans la liste des billets). À tester !

lthms

S’il fait ça, il viole totalement le standard. Peut-on encore parler de compilation du C dans ce cas ?

+0 -0

Merci pour toutes les réponse !

Le programme de @Ge0 par-contre est un peu plus problématique car la mémoire est monopolisée par le processus et doit être arrêter manuellement ce qui peut être plus au moins compliqué vu que la RAM est saturée.

Je ne comprends pas très bien, pour moi les deux programmes sont identiques dans leur fonctionnement ?

Il me semble que clang avec -O3 va faire sauter la pile (cf. un article sur le sujet dans la liste des billets). À tester !

C’est facile à vérifier sur godbolt et non, ce n’est pas le cas: https://godbolt.org/z/aEyyQh

De plus, cela n’aurait aucun sens, même sans la boucle infinie: sur la fin de portée (}), les variables automatiques sont dépilées. Au pire on ferait du yoyo avec un mauvais compilo. Aujourd’hui, la réservation de l’espace, on peut considérer qu’elle est faite au niveau fonction.

Maintenant, truc qui me surprend sur le résultat godbolt, ce code est pour moi UB (undefined behaviour): en effet, il y a une boucle infinie sans conséquence visible (printf…). Le compilateur a donc le droit d’en faire ce qu’il veut. Aujourd’hui, la conséquence première aurait dû être de le flagguer unreachable avec tous les chemins qui y conduisent.

Or, on voit une boucle.

Je ne comprends pas très bien, pour moi les deux programmes sont identiques dans leur fonctionnement ?

Green

L’un vérifie le retour de malloc et s’arrête en cas d’erreur (provoquant la fin du programme et la libération des ressources), l’autre continue de boucler (obligeant le système à intervenir pour couper le programme).

S’il fait ça, il viole totalement le standard. Peut-on encore parler de compilation du C dans ce cas ?

ache

Je me suis mal exprimé (j’ai voulu aller un peu vite) ; il fait sauter la boucle, pas la pile. Et il le fait parce qu’une boucle infinie qui manipule uniquement des variables locales est un comportement indéfini selon le standard C (ou quelque chose comme ça).

+0 -0

J’ai testé le programme et ça ne sature pas la mémoire. Ça met un peu le processeur au boulot (~ 12 %) à cause de la boucle infinie cependant.

Outre les explications données plus haut qui expliquent pourquoi ça ne sature pas la mémoire de base, dans ce cas précis comme la variable ne sert à rien, le compilateur est malin et va en fait le virer du binaire et donc pour sûr il ne pourra rien se passer à son sujet.

Renault

C’est vrai si on compile avec les flags d’optimisation: sans, ça donne

 5fe:   c7 45 fc 2a 00 00 00    movl   $0x2a,-0x4(%rbp) //a = 42
 605:   eb f7                   jmp    5fe <main+0x4> //Étant la ligne juste au dessus

Avec -O1:

5fa:   eb fe                   jmp    5fa <main>

Je pensais qu’il aurait supprimé la boucle vu qu’elle pourrait être considérée comme inutile, mais visiblement il jump à l’infini.

FantasMaths

En plus ici tu vois clairement que c’est le même espace mémoire qui est réutilisé. Je me suis donc trompé quelque part en disant que la variable était détruite (sauf si on considère que mes propos sont corrects si on ne descend pas trop dans les détails).

Dans mon esprit il y avait des opérations sur les registres de pointeur de pile mais après… « Qu’importe le flacon pourvu qu’on ait l’ivresse » n’est-ce pas ? ;)

Oui le compilateur optimise en général pour réutiliser ses variables d’une boucle à l’autre dans l’espace qu’il réserve dans la pile, avec réinitialisation si nécessaire (peut-être seulement en C++ la réinitialisation ceci-dis).

C’est aussi pour ça qu’on conseille d’éviter de sortir ces variables "temporaires" de boucles en amont des boucles en se disant "ça évite la réalloc", le compilo le fait déjà et garder la variable dans la boucle assure la visibilité locale de celle-ci.

Et pour revenir sur les 12% de proc ("Ça met un peu le processeur au boulot (~ 12 %) "). Ca le met bien plus au bout du rouleau, ça sature complètement un coeur, donc pour les processeurs 8 coeurs, un coeur occupé à 100% c’est 1/8e (=12.5%) de la capacité de ton ordi.

+2 -0

Comme dit plus haut, non seulement il n’y a qu’une seule allocation de faite sur la pile, mais j’ajouterais en plus que la taille de la pile est très limitée, sous Linux la taille par défaut de la pile est de 2 à 8 méga-octets par thread. Sous Windows, c’est plus 1 méga-octet. Tu peux l’agrandir dans le programme avec des fonctions comme setrlimit ou pthread_attr_setstacksize mais tu vas te heurter à d’autres limites au niveau du système (sauf si tu les modifies plus haut, etc.).

Par contre, si tu appelles la même fonction en boucle par elle-même, là tu vas saturer ta pile (mais pas la mémoire du système du coup).

Et si tu appelles malloc() en boucle, tu vas saturer la mémoire du système, donc la heap et non la stack. Ton système ralentira, peut-être au point d’être gelé selon ses réglages s’il fait appel au swap (déport de la mémoire vive sur une partition spéciale du disque dur en cas de remplissage de celle-ci), puis ton processus crashera ou sera tué au bout d’un moment (sous Linux, c’est normalement la fonction noyau oom-killer qui s’en charge).

+0 -0
#include <stdlib.h>

int main(void)
{
    int a;

    while (1)
    {
        malloc(1);
        main();
    }

    return 0;
}

Je me demande ce qui va faire planter le programme en premier :

  • Stack overflow ?
  • Saturation de mémoire tas ?
  • Trop d’appel récursif à la fonction main ?

Chercher à anticiper comment va se comporter un morceau de code avec UB ne sert à rien. Le compilateur n’a qu’une seule obligation: générer un binaire dont le comportement observable est conforme à un code bien défini — oui, il y a beaucoup de sous-entendus p.ex. comme remplacer des boucles ou des récursions par des calculs directs, faire sauter des allocations non utilisées…

A partir du moment où il y a UB, le compilo a le droit de faire ce qu’il veut, comme p.ex. remplacer cette horreur par un violent plantage genre "ud2". Peut-être pas aujourd’hui, ni demain, mais après demain il le pourrait. Il en a le droit.

D’ailleurs on voit un appel récursif avec gcc, et il ne reste rien chez clang. Bref. UB => pas prévisible => ne pas se faire des noeuds au cerveau, mais corriger.

Je me demande ce qui va faire planter le programme en premier :

  • Stack overflow ?
  • Trop d’appel récursif à la fonction main ?

Oui, si l’appel récursif est gardé.

  • Saturation de mémoire tas ?

Non, un système récent a souvent entre entre 4 et 8 Go de heap alors que la stack d’un programme fait entre 1 et 8 Mo. Un malloc(1) ne va certes pas allouer qu’un seul octet, vu qu’il y aura les structures de l’allocateur (Glibc ou autre) en plus, mais empiler une adresse de retour à chaque appel de fonction va bien plus vite saturer la stack qu’allouer un bloc mémoire va saturer la heap.

+0 -0

Je sors un peu du sujet, quoique peut-être pas totalement en fait. Je n’ai jamais fait de C ou C++ au-delà des études (et encore, c’était du C pour microcontrôleur il y a 15 ans), et j’ai toujours été surpris par deux notions :

  1. Que l’on puisse saturer la mémoire à coup de malloc sur un système embarqué directement programmé, OK. Mais quel est l’intérêt d’autoriser ça sur un système muni d’un OS ? Pourquoi l’OS n’envoie pas chier une demande d’allocation qui tenterait d’allouer plus de mémoire que disponible ?
  2. Quel est l’intérêt de conserver des comportements indéfinis dans les versions successives des normes ? Je peux comprendre qu’on en ait eu au début parce que ça correspondait à des cas non couverts, non prévus, impossibles sur le matériel de l’époque etc. Mais quel est l’intérêt de conserver ça dans les versions récentes des normes ? On pourrait imaginer que C et C++ disent « OK, on se met d’accord pour dire que ces cas maintenant sont standardisés au comportement X ».

Dans les deux cas j’imagine qu’il y a une bonne raison qui fait que l’on reste à ces comportements, mais elle m’échappe.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte