Analyse d'un programme potentiellement dangereux

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

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.

Pour le 2. La question peut être formulée différemment: quel est l’intérêt à spécifier le comportement de choses qui sont clairement des erreurs de programmation? Récupérer d’une erreur de programmation n’est pas une chose aussi simple ou exploitable que l’on pourrait croire; cf. http://joeduffyblog.com/2016/02/07/the-error-model/#bugs-arent-recoverable-errors

Alors certes, on pourrait émettre des erreurs de compilation, mais ce n’est pas aussi simple que ça car les conditions peuvent dépendre de données dynamiques qui n’ont pas été validée dans la logique du code.

Pour les curieux, il y a de très bons articles de John Regehr et d’autres synthèses.

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 ?

Les OS modernes, en tout cas Linux, l’autorisent car un programme alloue souvent de la RAM sans avoir besoin de toute cette mémoire au moment de l’allocation. Par exemple les machines virtuelles diverses ou gros programmes type Oracle qui gèrent leurs propres mémoire et cache finement.

Du coup Linux l’autorise en espérant que quand la RAM sera réellement réclamée, d’autres programmes auront libéré de leur côté voire auront été fermés. Ou alors que cette allocation ne soit jamais totalement exigée en fait.

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 ».

Il y a plusieurs choses.

Déjà le C et C++ ont une culture de la compatibilité ascendante assez forte. Elle n’est évidemment par parfaite mais plutôt bien respectée. Cela simplifie l’usage de programmes anciens avec des compilateurs modernes et avec du code qui l’est tout autant.

C’est peut être aussi le défaut du C++ qui devient un langage assez complexe avec une syntaxe assez monstrueuse car elle doit garder la compatibilité avec une syntaxe plus ancienne et qui est finalement désuète.

Pour le cas du C, cela a aussi un intérêt. Le C est le nouvel assembleur grosso modo. Garder les comportements indéfinis tels quel simplifie l’écriture de compilateurs pour ces plateformes aussi.

D’autant qu’hélas, beaucoup de programmes ont (volontairement ou pas) des UD dans leur code. Si tu graves dans le marbre le comportement de ces cas-ci, ces programmes seront à changer possiblement massivement et rapidement.

On pourrait dire qu’un UD ne devrait pas être présent, donc réécrire le programme ne devrait pas être un soucis. Sauf qu’en réalité beaucoup de programmes se basent sur le comportement de leur compilateur préféré qui est souvent stable dans le temps.

+1 -0

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).

lthms

Je ne me souviens pas d’un clause comme ça dans la norme du langage. Du coup, j’ai vérifié. En C99 il n’y a rien à ce sujet. En C11 par-contre !

An iteration statement whose controlling expression is not a constant expression156 that performs no input/output operations, does not access volatile objects, and performs no synchronization or atomic operations in its body, controlling expression, or (in the case of a for statement) its expression-3, may be assumed by the implementation to terminate157

Du coup en C11, clang a effectivement le droit de faire sauter cette boucle.

@SpaceFox: Je ne comprend pas trop ta question. Comment l’OS distinguerait-il un programme qui sature la mémoire juste pour saturer la mémoire d’un programme qui sature la mémoire parce-qu’il en a besoin ?

Ensuite, les UB, sont définit UB dans la norme. Car le comportement n’a pas de raison de donner un résultat cohérent. Impossible de choisir un résultat cohérent.

+0 -0

Je ne comprend pas trop ta question. Comment l’OS distinguerait-il un programme qui sature la mémoire juste pour saturer la mémoire d’un programme qui sature la mémoire parce-qu’il en a besoin

ache

Je ne parle absolument pas de faire cette différence : je parle d’interdire de saturer la mémoire, que ce besoin soit légitime ou pas. Ma question, c’était de dire que si l’OS sait qu’il reste X Mo de RAM disponible, il pourrait interdire d’allouer strictement plus que X Mo – ce qui garantit que la mémoire allouée est réellement disponible.

Ce que dit @Renault si j’ai bien compris, c’est que les programmes ont tendance à allouer beaucoup plus de mémoire qu’ils en ont vraiment besoin, et que donc l’OS laisse faire en espérant que cette mémoire en trop ne sera jamais vraiment utilisée.

J’ai l’impression que les navigateurs web sont spécialistes dans ce genre de montage idiot, si j’en crois mon htop maintenant :

De ce que je comprends de vos réponses sur les UB : ça devrait être des cas qui ne se présentent jamais et ne se trouver dans aucun programme, et donc idéalement lancer une erreur à la compilation. Mais à cause de la nature des langages et de l’historique (notamment le fait qu’on en trouve dans la vraie vue), on peut difficilement lancer des erreurs, et ça ne serait même pas vraiment souhaitable.

Dites-moi si je me trompe, ça fait trèèèès longtemps que je n’ai pas touché à quoi que ce soit de bas niveau :D

Ce que dit @Renault si j’ai bien compris, c’est que les programmes ont tendance à allouer beaucoup plus de mémoire qu’ils en ont vraiment besoin, et que donc l’OS laisse faire en espérant que cette mémoire en trop ne sera jamais vraiment utilisée.

Oui, ou que quand elle le sera, de la mémoire sera libérée en quantité suffisante. C’est une optimisation fonctionnelle en ne bloquant pas l’usage de l’ordinateur alors que finalement cela ne pose pas de soucis, mais aussi en terme de performances car la gestion des pages de cette allocation ne sera réellement gérée qu’à partir du moment où la mémoire sera utilisée et pas avant.

J’ai l’impression que les navigateurs web sont spécialistes dans ce genre de montage idiot, si j’en crois mon htop maintenant :

Les navigateurs modernes étant des mini-OS ce n’est pas surprenant. Ils ont des machines virtuelles internes, leur propre gestionnaire de mémoires, doivent gérer des tas de choses…

Un programme qui gère de grosses données type Oracle pour la base de données a un comportement similaire par exemple. Ou la machine virtuelle de n’importe quel langage.

De ce que je comprends de vos réponses sur les UB : ça devrait être des cas qui ne se présentent jamais et ne se trouver dans aucun programme, et donc idéalement lancer une erreur à la compilation.

Idéalement oui. Après certains programmes comme le noyau ou en contexte micro-contrôlleur, tu ne respectent jamais le standard à 100% par simplicité.

Mais à cause de la nature des langages et de l’historique (notamment le fait qu’on en trouve dans la vraie vue), on peut difficilement lancer des erreurs, et ça ne serait même pas vraiment souhaitable.

Rompre cette compatibilité ascendante serait gênante oui. Et en gros cela va mener au fait que beaucoup de compilateurs vont inclure des extensions de C pour revenir au comportement d’avant. Après c’est vrai que cela permettrait de facilement voir si un programme est compatible au C strictement ou pas sans comportements indéfinis. Mais ce n’est pas une transition simple à faire, le C étant partout…

+0 -0

D’autant qu’hélas, beaucoup de programmes ont (volontairement ou pas) des UD dans leur code. Si tu graves dans le marbre le comportement de ces cas-ci, ces programmes seront à changer possiblement massivement et rapidement.

On pourrait dire qu’un UD ne devrait pas être présent, donc réécrire le programme ne devrait pas être un soucis. Sauf qu’en réalité beaucoup de programmes se basent sur le comportement de leur compilateur préféré qui est souvent stable dans le temps.

Justement, ceci est une faute, et c’est une chose sur laquelle John Regehr et d’autres insistent régulièrement: d’une version à l’autre, un même compilo pourrait exploiter un UB pour faire autre chose. Et même chez les comportements non spécifiés (!= non définis), je ne suis pas sûr que l’on puisse supposer qu’il y aura une stabilité dans le comportement.

"Working" code that uses undefined behavior can "break" as the compiler evolves or changes

—[ What Every C Programmer Should Know About Undefined Behavior #2/3


Dites-moi si je me trompe, ça fait trèèèès longtemps que je n’ai pas touché à quoi que ce soit de bas niveau :D

Je ne trouve pas que le principe derrière les UBs soit si bas niveau que ça. Au contraire, je rapproche ça de la programmation par contrat. Il y a un ensemble de règles dans le contrat du langage, et nous, développeurs ne sommes pas censés ignorer la loi. Et si on ne la respecte pas, n’importe quoi peut arriver.

Alors, certes, les clauses sont écrites en tout petit, c’est abscons, vaguement bas niveau, mais la conséquence respecte une méta-règle: UB => chaque compilo fait ce qu’il veut. Et de plus en plus souvent (au fil des versions), il va supposer que l’on n’est pas si bêtes que ça pour écrire du code volontairement UB, et il va ignorer les branches correspondantes à défaut de pouvoir émettre un warning quand il remarque que l’on a été étourdi.

Si on veut acheter un croissant avec un billet de monopoly, c’est avec un pain ou une tarte que l’on risque de ressortir. On n’aura probablement pas ce que l’on espère.

Je ne comprend pas trop ta question. Comment l’OS distinguerait-il un programme qui sature la mémoire juste pour saturer la mémoire d’un programme qui sature la mémoire parce-qu’il en a besoin

ache

Je ne parle absolument pas de faire cette différence : je parle d’interdire de saturer la mémoire, que ce besoin soit légitime ou pas. Ma question, c’était de dire que si l’OS sait qu’il reste X Mo de RAM disponible, il pourrait interdire d’allouer strictement plus que X Mo – ce qui garantit que la mémoire allouée est réellement disponible.

Désolé, ne le prend pas mal, mais je n’ai toujours pas compris ta question.
S’il y a X Mo de RAM disponible alors l’OS n’allouera que X Mo de RAM, par plus (le swap est compris dans la mémoire RAM là). Le reste du temps il échouera à alouer de la RAM car il n’y en aura plus. Concrètement malloc retournera NULL.

@Renault: Je n’aime pas ton argument de la compatibilité ascendante. C’est faux, C strict où non, la compatibilité n’est que moyenne. Grossomodo, ça marche mais, c’est bien plus compliqué que ça.

+0 -0

@ache cf mon screenshot précédent : rien que sur les 6 processus les plus gourmands, ils se sont autorisés respectivement 26,5 Go, 15,5 Go, 14,4 Go, 11,6 Go, 9,7 Go et 9,6 Go. J’ai beau avoir beaucoup de RAM sur cet ordinateur, je n’ai que 32 Go et 8 Go de swap, et ces 40 Go seraient très loin de suffire si les processus en question utilisaient réellement les 87,3 Go qu’ils se sont alloués.

@SpaceFox: Ok, je ne comprenais pas. Il ne faut pas sommer comme ça. Ils utilisent très très certainement de la mémoire partagée. Du coup, si tu sommes tu comptes plusieurs fois le même segment.

Utilise free -hs pour voir ce qu’il en ai vraiment, mais j’en suis quasi certain.

+0 -0

@ache : c’est de la mémoire allouée et pas utilisée, ni en propre, ni en partagée – cf les deux colonnes suivantes sur le screenshot :

Si je prends juste le premier processus, j’ai un onglet de Firefox qui a demandé à l’OS le droit de prendre jusqu’à 26,5 Go de mémoire – ce que l’OS a visiblement accepté – pour n’utiliser que 153 Mo en propre et 81 Mo partagés avec d’autres processus.

@Green nulle part, c’est juste que @ache cite un bout de la doc qui concerne les boucles avec une expression de contrôle non-constante et détaille les conditions pour qu’un compilo puisse court-circuiter une boucle. Je vois pas le rapport avec le cas discuté dans ce topic où l’expression de contrôle me parait justement constante. Je faisais part de mon étonnement, au cas où je rate un truc.

+0 -0

An iteration statement whose controlling expression is not a constant expression

1 n’est pas une expression constante ? o_O

adri1

Ah si tout à fait ! J’ai lu trop vite. J’ai lu ce que je voulais lire je pense et du coup, j’ai loupé le « not ».

int c = 1;

while(c);

Ça clang peut effectivement le squeezer.

+0 -0

Je ne parle absolument pas de faire cette différence : je parle d’interdire de saturer la mémoire, que ce besoin soit légitime ou pas. Ma question, c’était de dire que si l’OS sait qu’il reste X Mo de RAM disponible, il pourrait interdire d’allouer strictement plus que X Mo – ce qui garantit que la mémoire allouée est réellement disponible.

SpaceFox

En fait, c’est un comportement assez pratique pour faire de l’émulation, ou pour mapper beaucoup de données. Le faire avec des allocations sparses, ça oblige logiciellement à déréférencer les différents niveaux de pointeur, c’est long. Et ça veut dire faire un appel système à chaque allocation mémoire. En faisant une grande allocation, on peut placer les données comme on le souhaite, et on n’a qu’à calculer son adresse pour déréférencer la donnée en une fois (en repoussant le travail sur la MMU, dont le TLB, la mémoire interne, a une taille limitée, et qui a possiblement un mapping hiérarchique de la mémoire, mais niveau perfs, ça marche mieux).

Et sinon je me suis amusé avec le code suivant:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main (void)
{
        int iter = 0;
        for(;;)
        {
                if (malloc(1 << 30))
                {
                        iter ++;
                        //printf("I allocated 1GB %d times !\n", iter);
                }
                else
                {
                        printf("malloc failed after %d iterations\n", iter);
                        sleep(30);
                        return 0;
                }
        }
}

qui finit par: malloc failed after 131061 iterations et htop qui m’indique que le programme a un espace virtuel de 127TB, et 9.7GB de mémoire réservée. Ce dernier point m’étonne, parce que je descends à 50MB si je fais mes allocations 32GB par 32GB. Je soupçonne un comportement de la libc, comme des tables de translation allouées et remplies parce que j’utilise des allocations trop petites (plus petites que le mapping de rang 1), mais j’ai la flemme de chercher ce soir (en plus j’y connais rien à x86_64).

+1 -0

Il part surtout du principe que les unités comme Ko, Mo et Go (= KB, MB et GB en anglais avec B pour byte) sont historiquement utilisées en comptant de 1024 en 1024 (même s’il y a eu une refonte du système SI qui dit qu’il faut compter de 1000 en 1000 pour ces unités, et écrire Kio, Mio et Gio lorsqu’on compte de 1024 en 1024…) et cela au contraire de Kb, Mb et Gb (avec b = bit = un huitième de byte) pour lesquelles il est beaucoup plus commun de compter de 1000 en 1000.

L’opérateur << (shift left, ou SHL, décalage des bits vers la gauche) est couramment utilisé pour faire des puissances de deux (en binaire, décaler un bit vers la gauche fait des puissances de deux).

1024 équivaut à 2102^{10}, donc à 1 << 10 (un bit décalé dix fois vers la gauche). Cela peut servir à convertir un octet en kilooctet.

Si on considère qu’un kilooctet fait 1024 octets, et qu’un mégaoctet fait 1024 kilooctets, et qu’un gigaoctet fait 1024 mégaoctets, alors il faut faire cette opération 3 fois : donc décaler de 30 bits vers la gauche pour convertir les octets en gigaoctets (ce qui équivaut à multiplier par 2302^{30}, ou par 1 073 741 824).

On peut aussi décaler de 30 bits vers la droite (avec l’opérateur >>) pour faire l’opération inverse, diviser par 2302^{30} pour convertir les gigaoctets en octets (mais avec une division entière, où on ignore les chiffres après la virgule, autrement on peut obtenir le reste en utilisant le modulo (%) avec les opérateurs arithmétiques ou bien le AND binaire (&) avec les opérateurs binaires mais c’est encore une autre histoire…).

Pour résumer, c’est juste une multiplication capillotractée, qui revient à faire une petite optimisation, que tu n’auras pas forcément besoin de faire avec les compilateurs récents puisqu’ils la feront probablement pour toi (elle revient à écrire * 1024 * 1024 * 1024). Mais les « vieux » programmeurs sont souvent habitués à faire ce genre de chose, notamment parce que c’est un tout petit peu plus rapide à exécuter/écrire en assembleur ou ce genre de choses.

+1 -0

@Renault: Je n’aime pas ton argument de la compatibilité ascendante. C’est faux, C strict où non, la compatibilité n’est que moyenne. Grossomodo, ça marche mais, c’est bien plus compliqué que ça.

Je n’ai pas dit que cette compatibilité était parfaite, mais elle est là et globalement ils sont frileux (à raison) à chambouler la syntaxe et les règles de base.

Et c’est normal, le C et C++ sont partout dans la base d’un système. Dans mon métier je le constate fréquemment quand GCC durci un peu certaines règles par défaut (ce qu’elle a fait depuis 10 ans régulièrement), le travail que cela induit derrière est énorme.

+0 -0

@Renault: Je n’aime pas ton argument de la compatibilité ascendante. C’est faux, C strict où non, la compatibilité n’est que moyenne. Grossomodo, ça marche mais, c’est bien plus compliqué que ça.

Je n’ai pas dit que cette compatibilité était parfaite, mais elle est là et globalement ils sont frileux (à raison) à chambouler la syntaxe et les règles de base.

Et c’est normal, le C et C++ sont partout dans la base d’un système. Dans mon métier je le constate fréquemment quand GCC durci un peu certaines règles par défaut (ce qu’elle a fait depuis 10 ans régulièrement), le travail que cela induit derrière est énorme.

Renault

C’est vaguement présent, mais il suffit d’une nouvelle optimisation pour faire péter un code buggué qui tombait en marche. Ce ne sont pas des "règles de bases", ce sont des comportements aux limites que nous avions observés et que nous avons supposés, à tord, comme étant pérennes.

Il ne faut en aucun cas supposer que le code produit sur un comportement non défini sera toujours identique avec les versions futures d’un même compilateur.

Maintenant on commence à avoir des warnings qui s’améliorent de version en version, des sanitiseurs, des compilos alternatifs pour compiler les tests, des outils d’analyse de code. Il faut profiter de tout cela pour réduire les risques d’UB dans nos codes, et d’avoir des produits qui tombent en marche.

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