Si le tas est initialisé à zéro pour des raisons de sécurité, pourquoi la pile est-elle simplement non initialisée?

15

Sur mon système Debian GNU / Linux 9, lorsqu'un binaire est exécuté,

  • la pile n'est pas initialisée mais
  • le tas est initialisé à zéro.

Pourquoi?

Je suppose que l'initialisation à zéro favorise la sécurité mais, si pour le tas, pourquoi pas aussi pour la pile? La pile n'a-t-elle pas non plus besoin de sécurité?

Pour autant que je sache, ma question n'est pas spécifique à Debian.

Exemple de code C:

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

const size_t n = 8;

// --------------------------------------------------------------------
// UNINTERESTING CODE
// --------------------------------------------------------------------
static void print_array(
  const int *const p, const size_t size, const char *const name
)
{
    printf("%s at %p: ", name, p);
    for (size_t i = 0; i < size; ++i) printf("%d ", p[i]);
    printf("\n");
}

// --------------------------------------------------------------------
// INTERESTING CODE
// --------------------------------------------------------------------
int main()
{
    int a[n];
    int *const b = malloc(n*sizeof(int));
    print_array(a, n, "a");
    print_array(b, n, "b");
    free(b);
    return 0;
}

Production:

a at 0x7ffe118997e0: 194 0 294230047 32766 294230046 32766 -550453275 32713 
b at 0x561d4bbfe010: 0 0 0 0 0 0 0 0 

La norme C ne demande pas malloc()d'effacer la mémoire avant de l'allouer, bien sûr, mais mon programme C est simplement à titre d'illustration. La question n'est pas une question sur C ou sur la bibliothèque standard de C. Au contraire, la question est de savoir pourquoi le noyau et / ou le chargeur d'exécution mettent à zéro le tas mais pas la pile.

UNE AUTRE EXPÉRIENCE

Ma question concerne le comportement GNU / Linux observable plutôt que les exigences des documents de normes. Si vous ne savez pas ce que je veux dire, essayez ce code, qui invoque d'autres comportements non définis ( non définis, c'est-à-dire en ce qui concerne la norme C) pour illustrer le point:

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

const size_t n = 4;

int main()
{
    for (size_t i = n; i; --i) {
        int *const p = malloc(sizeof(int));
        printf("%p %d ", p, *p);
        ++*p;
        printf("%d\n", *p);
        free(p);
    }
    return 0;
}

Sortie de ma machine:

0x555e86696010 0 1
0x555e86696010 0 1
0x555e86696010 0 1
0x555e86696010 0 1

En ce qui concerne la norme C, le comportement n'est pas défini, donc ma question ne concerne pas la norme C. Un appel à malloc()ne pas avoir besoin de renvoyer la même adresse à chaque fois mais, puisque cet appel à malloc()arrive effectivement à renvoyer la même adresse à chaque fois, il est intéressant de remarquer que la mémoire, qui est sur le tas, est mise à zéro à chaque fois.

La pile, en revanche, ne semblait pas être mise à zéro.

Je ne sais pas ce que ce dernier code fera sur votre machine, car je ne sais pas quelle couche du système GNU / Linux est à l'origine du comportement observé. Vous ne pouvez que l'essayer.

MISE À JOUR

@Kusalananda a observé dans les commentaires:

Pour ce qu'il vaut, votre code le plus récent renvoie différentes adresses et données (occasionnelles) non initialisées (non nulles) lorsqu'il est exécuté sur OpenBSD. Cela ne dit évidemment rien sur le comportement que vous observez sous Linux.

Que mon résultat diffère du résultat sur OpenBSD est en effet intéressant. Apparemment, mes expériences ne découvraient pas un protocole de sécurité du noyau (ou éditeur de liens), comme je l'avais pensé, mais un simple artefact d'implémentation.

Dans cette optique, je pense qu'ensemble, les réponses ci-dessous de @mosvy, @StephenKitt et @AndreasGrapentin tranchent ma question.

Voir aussi sur Stack Overflow: Pourquoi malloc initialise-t-il les valeurs à 0 dans gcc? (crédit: @bta).

thb
la source
2
Pour ce qu'il vaut, votre code le plus récent renvoie différentes adresses et données (occasionnelles) non initialisées (non nulles) lorsqu'il est exécuté sur OpenBSD. Cela ne signifie évidemment pas dire quoi que ce soit sur le comportement que vous assistez sur Linux.
Kusalananda
Veuillez ne pas modifier la portée de votre question et n'essayez pas de la modifier afin de rendre les réponses et les commentaires redondants. En C, le "tas" n'est rien d'autre que la mémoire retournée par malloc () et calloc (), et seul ce dernier remet à zéro la mémoire; l' newopérateur en C ++ (également "tas") est sous Linux juste un wrapper pour malloc (); le noyau ne sait ni ne se soucie de ce qu'est le "tas".
Mosvy
3
Votre deuxième exemple est simplement d'exposer un artefact de l'implémentation de malloc dans la glibc; si vous répétez malloc / free avec un tampon supérieur à 8 octets, vous verrez clairement que seuls les 8 premiers octets sont mis à zéro.
Mosvy
@Kusalananda je vois. Que mon résultat diffère du résultat sur OpenBSD est en effet intéressant. Apparemment, Mosvy et vous avez montré que mes expériences ne découvraient pas un protocole de sécurité du noyau (ou éditeur de liens), comme je l'avais pensé, mais un simple artefact d'implémentation.
thb
@thb Je pense que cela peut être une observation correcte, oui.
Kusalananda

Réponses:

28

Le stockage renvoyé par malloc () n'est pas initialisé à zéro. Ne présumez jamais que c'est le cas.

Dans votre programme de test, ce n'est qu'un coup de chance: je suppose que le malloc()nouveau bloc vient d'être retiré mmap(), mais ne vous fiez pas non plus à cela.

Par exemple, si j'exécute votre programme sur ma machine de cette façon:

$ echo 'void __attribute__((constructor)) p(void){
    void *b = malloc(4444); memset(b, 4, 4444); free(b);
}' | cc -include stdlib.h -include string.h -xc - -shared -o pollute.so

$ LD_PRELOAD=./pollute.so ./your_program
a at 0x7ffd40d3aa60: 1256994848 21891 1256994464 21891 1087613792 32765 0 0
b at 0x55834c75d010: 67372036 67372036 67372036 67372036 67372036 67372036 67372036 67372036

Votre deuxième exemple est simplement d'exposer un artefact de l' mallocimplémentation dans la glibc; si vous répétez malloc/ freeavec un tampon supérieur à 8 octets, vous verrez clairement que seuls les 8 premiers octets sont mis à zéro, comme dans l'exemple de code suivant.

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

const size_t n = 4;
const size_t m = 0x10;

int main()
{
    for (size_t i = n; i; --i) {
        int *const p = malloc(m*sizeof(int));
        printf("%p ", p);
        for (size_t j = 0; j < m; ++j) {
            printf("%d:", p[j]);
            ++p[j];
            printf("%d ", p[j]);
        }
        free(p);
        printf("\n");
    }
    return 0;
}

Production:

0x55be12864010 0:1 0:1 0:1 0:1 0:1 0:1 0:1 0:1 0:1 0:1 0:1 0:1 0:1 0:1 0:1 0:1 
0x55be12864010 0:1 0:1 1:2 1:2 1:2 1:2 1:2 1:2 1:2 1:2 1:2 1:2 1:2 1:2 1:2 1:2 
0x55be12864010 0:1 0:1 2:3 2:3 2:3 2:3 2:3 2:3 2:3 2:3 2:3 2:3 2:3 2:3 2:3 2:3 
0x55be12864010 0:1 0:1 3:4 3:4 3:4 3:4 3:4 3:4 3:4 3:4 3:4 3:4 3:4 3:4 3:4 3:4
mosvy
la source
2
Eh bien, oui, mais c'est pourquoi j'ai posé la question ici plutôt que sur Stack Overflow. Ma question n'était pas sur la norme C mais sur la façon dont les systèmes GNU / Linux modernes lient et chargent généralement les binaires. Votre LD_PRELOAD est plein d'humour, mais répond à une autre question que celle que j'avais l'intention de poser.
thb
19
Je suis heureux de vous avoir fait rire, mais vos hypothèses et préjugés ne sont pas drôles du tout. Sur un "système GNU / Linux moderne", les binaires sont généralement chargés par un éditeur de liens dynamiques, qui exécute des constructeurs à partir de bibliothèques dynamiques avant d'accéder à la fonction main () de votre programme. Sur votre système Debian GNU / Linux 9, malloc () et free () seront appelés plus d'une fois avant la fonction main () de votre programme, même si vous n'utilisez pas de bibliothèques préchargées.
Mosvy
23

Quelle que soit la façon dont la pile est initialisée, vous ne voyez pas une pile vierge, car la bibliothèque C fait un certain nombre de choses avant d'appeler main, et elles touchent la pile.

Avec la bibliothèque GNU C, sur x86-64, l'exécution commence au point d'entrée _start , qui appelle __libc_start_mainpour configurer les choses, et ce dernier finit par appeler main. Mais avant d'appeler main, il appelle un certain nombre d'autres fonctions, ce qui entraîne l'écriture de divers éléments de données dans la pile. Le contenu de la pile n'est pas effacé entre les appels de fonction, donc lorsque vous entrez main, votre pile contient les restes des appels de fonction précédents.

Cela explique seulement les résultats que vous obtenez de la pile, voir les autres réponses concernant votre approche générale et vos hypothèses.

Stephen Kitt
la source
Notez qu'au moment où main()est appelé, les routines d'initialisation peuvent très bien avoir modifié la mémoire retournée par malloc()- en particulier si les bibliothèques C ++ sont liées. Supposer que le "tas" est initialisé à n'importe quoi est une très, très mauvaise hypothèse.
Andrew Henle
Votre réponse et celle du Mosvy règlent ma question. Le système me permet malheureusement d'accepter un seul des deux; sinon, j'accepterais les deux.
thb
18

Dans les deux cas, vous obtenez de la mémoire non initialisée et vous ne pouvez faire aucune hypothèse sur son contenu.

Lorsque le système d'exploitation doit attribuer une nouvelle page à votre processus (que ce soit pour sa pile ou pour l'arène utilisée par malloc()), il garantit qu'il n'exposera pas les données d'autres processus; la façon habituelle de le faire est de le remplir de zéros (mais il est également valable de l'écraser avec autre chose, y compris même une page valant /dev/urandom- en fait, certaines malloc()implémentations de débogage écrivent des modèles non nuls, pour capturer des hypothèses erronées comme la vôtre).

Si malloc()peut satisfaire la demande de la mémoire déjà utilisée et libérée par ce processus, son contenu ne sera pas effacé (en fait, l'effacement n'a rien à voir avec malloc()ce qu'il ne peut pas être - il doit se produire avant que la mémoire ne soit mappée dans votre espace d'adressage). Vous pouvez obtenir de la mémoire qui a déjà été écrite par votre processus / programme (par exemple avant main()).

Dans votre exemple de programme, vous voyez une malloc()région qui n'a pas encore été écrite par ce processus (c'est-à-dire directement à partir d'une nouvelle page) et une pile qui a été écrite (par précodage main()dans votre programme). Si vous examinez davantage la pile, vous constaterez qu'elle est remplie de zéros plus bas (dans son sens de croissance).

Si vous voulez vraiment comprendre ce qui se passe au niveau du système d'exploitation, je vous recommande de contourner la couche Bibliothèque C et d'interagir à l'aide d'appels système tels que brk()et à la mmap()place.

Toby Speight
la source
1
Il y a une semaine ou deux, j'ai essayé une expérience différente, en appelant malloc()et à free()plusieurs reprises. Bien que rien ne nécessite malloc()de réutiliser le même stockage récemment libéré, dans l'expérience, malloc()cela s'est produit. Il est arrivé de renvoyer la même adresse à chaque fois, mais a également annulé la mémoire à chaque fois, ce à quoi je ne m'attendais pas. C'était intéressant pour moi. De nouvelles expériences ont conduit à la question d'aujourd'hui.
thb
1
@thb, je ne suis peut-être pas assez clair - la plupart des implémentations de malloc()ne font absolument rien avec la mémoire qu'ils vous remettent - c'est soit déjà utilisé, soit fraîchement assigné (et donc mis à zéro par le système d'exploitation). Dans votre test, vous avez évidemment obtenu ce dernier. De même, la mémoire de pile est donnée à votre processus à l'état effacé, mais vous ne l'examinez pas assez loin pour voir les parties que votre processus n'a pas encore touchées. La mémoire de votre pile est effacée avant d'être transmise à votre processus.
Toby Speight
2
@TobySpeight: brk et sbrk sont obsolètes par mmap. pubs.opengroup.org/onlinepubs/7908799/xsh/brk.html dit LEGACY tout en haut.
Joshua
2
Si vous avez besoin d'une mémoire initialisée, l'utilisation callocpeut être une option (au lieu de memset)
vérifie
2
@thb et Toby: fait amusant: les nouvelles pages du noyau sont souvent allouées paresseusement, et simplement copiées sur écriture mappées sur une page partagée mise à zéro. Cela se produit à mmap(MAP_ANONYMOUS)moins que vous ne l'utilisiez MAP_POPULATEégalement. Les nouvelles pages de pile sont, espérons-le, soutenues par de nouvelles pages physiques et câblées (mappées dans les tables de pages du matériel, ainsi que la liste des pointeurs / longueurs des mappages du noyau) lors de leur croissance, car normalement, la nouvelle mémoire de la pile est écrite lors de la première utilisation. . Mais oui, le noyau doit éviter les fuites de données d'une manière ou d'une autre, et la mise à zéro est la moins chère et la plus utile.
Peter Cordes
9

Votre prémisse est fausse.

Ce que vous décrivez comme «sécurité» est vraiment de la confidentialité , ce qui signifie qu'aucun processus ne peut lire la mémoire d'un autre processus, à moins que cette mémoire ne soit explicitement partagée entre ces processus. Dans un système d'exploitation, il s'agit d'un aspect de l' isolement d'activités ou de processus simultanés.

Ce que le système d'exploitation fait pour garantir cette isolation, c'est chaque fois que la mémoire est demandée par le processus d'allocation de tas ou de pile, cette mémoire provient soit d'une région de la mémoire physique qui est remplie de zéros, ou qui est remplie d'ordure qui est provenant du même processus .

Cela garantit que vous ne voyez que des zéros ou vos propres ordures, de sorte que la confidentialité est garantie et que le tas et la pile sont `` sécurisés '', bien qu'ils ne soient pas nécessairement (zéro) initialisés.

Vous lisez trop dans vos mesures.

Andreas Grapentin
la source
1
La section Mise à jour de la question fait désormais explicitement référence à votre réponse éclairante.
thb