Se l'heap è inizializzato a zero per motivi di sicurezza, perché lo stack è semplicemente non inizializzato?


15

Sul mio sistema Debian GNU / Linux 9, quando viene eseguito un binario,

  • lo stack non è inizializzato ma
  • l'heap è inizializzato a zero.

Perché?

Presumo che l'inizializzazione zero promuova la sicurezza ma, se per l'heap, perché non anche per lo stack? Anche lo stack non ha bisogno di sicurezza?

La mia domanda non è specifica per Debian per quanto ne so.

Codice C di esempio:

#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;
}

Produzione:

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

Lo standard C non richiede malloc()di cancellare la memoria prima di allocarla, ovviamente, ma il mio programma C è solo a scopo illustrativo. La domanda non è una domanda su C o sulla libreria standard di C. Piuttosto, la domanda è una domanda sul perché il kernel e / o il caricatore di runtime stanno azzerando l'heap ma non lo stack.

UN ALTRO ESPERIMENTO

La mia domanda riguarda il comportamento osservabile di GNU / Linux piuttosto che i requisiti dei documenti standard. Se non sei sicuro di cosa intendo, prova questo codice, che invoca ulteriori comportamenti indefiniti ( indefiniti, vale a dire, per quanto riguarda lo standard C) per illustrare il punto:

#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;
}

Uscita dalla mia macchina:

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

Per quanto riguarda lo standard C, il comportamento non è definito, quindi la mia domanda non riguarda lo standard C. Una chiamata malloc()non deve necessariamente restituire lo stesso indirizzo ogni volta ma, poiché questa chiamata a malloc()restituisce effettivamente lo stesso indirizzo ogni volta, è interessante notare che la memoria, che si trova nell'heap, viene azzerata ogni volta.

La pila, al contrario, non sembrava essere stata azzerata.

Non so che cosa farà l'ultimo codice sulla tua macchina, dal momento che non so quale strato del sistema GNU / Linux sta causando il comportamento osservato. Puoi solo provarlo.

AGGIORNARE

@Kusalananda ha osservato nei commenti:

Per quello che vale, il tuo codice più recente restituisce diversi indirizzi e dati (occasionali) non inizializzati (diversi da zero) quando eseguito su OpenBSD. Questo ovviamente non dice nulla sul comportamento a cui stai assistendo su Linux.

Che il mio risultato differisca da quello su OpenBSD è davvero interessante. Apparentemente, i miei esperimenti stavano scoprendo non un protocollo di sicurezza del kernel (o linker), come avevo pensato, ma un semplice artefatto implementativo.

In questa luce, credo che, insieme, le risposte di seguito di @mosvy, @StephenKitt e @AndreasGrapentin risolvano la mia domanda.

Vedi anche su Stack Overflow: Perché malloc inizializza i valori su 0 in gcc? (credito: @bta).


2
Per quello che vale, il tuo codice più recente restituisce diversi indirizzi e dati (occasionali) non inizializzati (diversi da zero) quando eseguito su OpenBSD. Questo ovviamente non dice nulla sul comportamento a cui stai assistendo su Linux.
Kusalananda

Non modificare l'ambito della domanda e non tentare di modificarla per rendere ridondanti risposte e commenti. In C, l '"heap" non è altro che la memoria restituita da malloc () e calloc (), e solo quest'ultimo azzera la memoria; l' newoperatore in C ++ (anche "heap") è su Linux solo un wrapper per malloc (); il kernel non sa né importa quale sia il "mucchio".
mosvy,

3
Il tuo secondo esempio sta semplicemente esponendo un artefatto dell'implementazione malloc in glibc; se lo fai ripetendo malloc / free con un buffer maggiore di 8 byte, vedrai chiaramente che vengono azzerati solo i primi 8 byte.
mosvy,

@Kusalananda capisco. Che il mio risultato differisca da quello su OpenBSD è davvero interessante. Apparentemente, tu e Mosvy avete dimostrato che i miei esperimenti non stavano scoprendo un protocollo di sicurezza del kernel (o linker), come avevo pensato, ma un semplice artefatto attuativo.
martedì

@thb Credo che questa possa essere un'osservazione corretta, sì.
Kusalananda

Risposte:


28

La memoria restituita da malloc () non è inizializzata a zero. Non dare per scontato che lo sia.

Nel tuo programma di test, è solo un colpo di fortuna: immagino che abbia malloc()appena ottenuto un nuovo blocco mmap(), ma non fare affidamento neanche su quello.

Ad esempio, se eseguo il programma sul mio computer in questo modo:

$ 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

Il tuo secondo esempio sta semplicemente esponendo un artefatto mallocdell'implementazione in glibc; se lo fai ripetuto malloc/ freecon un buffer maggiore di 8 byte, vedrai chiaramente che vengono azzerati solo i primi 8 byte, come nel seguente codice di esempio.

#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;
}

Produzione:

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

2
Bene, sì, ma è per questo che ho posto la domanda qui piuttosto che su Stack Overflow. La mia domanda non riguardava lo standard C ma il modo in cui i moderni sistemi GNU / Linux in genere collegano e caricano i binari. Il tuo LD_PRELOAD è divertente ma risponde a un'altra domanda rispetto alla domanda che avevo intenzione di porre.
thb

19
Sono felice di averti fatto ridere, ma i tuoi presupposti e pregiudizi non sono affatto divertenti. Su un "moderno sistema GNU / Linux", i binari sono in genere caricati da un linker dinamico, che esegue costruttori da librerie dinamiche prima di accedere alla funzione main () dal programma. Sul tuo stesso sistema Debian GNU / Linux 9, sia malloc () che free () saranno chiamati più di una volta prima della funzione main () dal tuo programma, anche quando non usi librerie precaricate.
mosvy,

23

Indipendentemente da come viene inizializzato lo stack, non si vede uno stack incontaminato, perché la libreria C fa un numero di cose prima di chiamare maine toccano lo stack.

Con la libreria GNU C, su x86-64, l'esecuzione inizia dal punto di ingresso _start , che chiama __libc_start_mainper impostare le cose e quest'ultima finisce per chiamare main. Ma prima di chiamare main, chiama una serie di altre funzioni, che causano la scrittura nello stack di vari dati. Il contenuto dello stack non viene cancellato tra le chiamate di funzione, quindi quando si entra main, lo stack contiene gli avanzi delle precedenti chiamate di funzione.

Questo spiega solo i risultati che ottieni dallo stack, vedi le altre risposte riguardanti il ​​tuo approccio generale e le tue assunzioni.


Si noti che, quando main()verrà chiamato, le routine di inizializzazione potrebbero benissimo aver modificato la memoria restituita malloc(), specialmente se le librerie C ++ sono collegate. Supponendo che l'heap sia inizializzato su qualcosa, è un presupposto davvero, davvero negativo.
Andrew Henle,

La tua risposta insieme al Mosvy risolve la mia domanda. Il sistema purtroppo mi consente di accettare solo uno dei due; altrimenti, accetterei entrambi.
martedì

18

In entrambi i casi, ottieni memoria non inizializzata e non puoi fare ipotesi sul suo contenuto.

Quando il sistema operativo deve assegnare una nuova pagina al processo (che sia per il suo stack o per l'arena utilizzata da malloc()), garantisce che non esporrà i dati da altri processi; il solito modo per assicurarsi che sia riempirlo di zeri (ma è ugualmente valido per sovrascrivere con qualsiasi altra cosa, inclusa anche una pagina di valore /dev/urandom- in effetti alcune malloc()implementazioni di debug scrivono schemi diversi da zero, per catturare ipotesi errate come la tua).

Se malloc()può soddisfare la richiesta dalla memoria già utilizzata e rilasciata da questo processo, il suo contenuto non verrà cancellato (in effetti, la cancellazione non ha nulla a che fare con malloc()e non può essere - deve avvenire prima che la memoria sia mappata in il tuo spazio indirizzo). È possibile ottenere memoria che è stata precedentemente scritta dal processo / programma (ad es. Prima main()).

Nel tuo programma di esempio, stai vedendo una malloc()regione che non è stata ancora scritta da questo processo (cioè è diretta da una nuova pagina) e uno stack in cui è stato scritto (tramite pre- main()codice nel tuo programma). Se esamini più della pila, troverai che è riempito di zero più in basso (nella sua direzione di crescita).

Se vuoi davvero capire cosa sta succedendo a livello di sistema operativo, ti consiglio di bypassare il livello C Library e interagire usando chiamate di sistema come brk()e mmap()invece.


1
Una settimana o due fa, ho provato un esperimento diverso, chiamando malloc()e free()ripetutamente. Sebbene nulla richieda il malloc()riutilizzo della stessa memoria recentemente liberata, nell'esperimento è malloc()successo a farlo. È capitato di restituire lo stesso indirizzo ogni volta, ma ha anche annullato la memoria ogni volta, cosa che non mi aspettavo. Questo è stato interessante per me. Ulteriori esperimenti hanno portato alla domanda di oggi.
martedì

1
@thb, forse non sono abbastanza chiaro - la maggior parte delle implementazioni di malloc()non fanno assolutamente nulla con la memoria che ti danno - è utilizzata in precedenza o appena assegnata (e quindi azzerata dal sistema operativo). Nel tuo test, hai evidentemente ottenuto quest'ultimo. Allo stesso modo, la memoria dello stack viene assegnata al processo nello stato cancellato, ma non la si esamina abbastanza lontano per vedere parti che il processo non ha ancora toccato. La memoria dello stack viene cancellata prima di essere assegnata al processo.
Toby Speight,

2
@TobySpeight: brk e sbrk sono obsoleti di mmap. pubs.opengroup.org/onlinepubs/7908799/xsh/brk.html dice LEGACY proprio in alto.
Joshua,

2
Se hai bisogno di memoria inizializzata usando callocpotrebbe essere un'opzione (invece di memset)
controlla il

2
@thb e Toby: fatto divertente: le nuove pagine del kernel sono spesso pigramente allocate e semplicemente copiate su scrittura mappate su una pagina azzerata condivisa. Questo accade a mmap(MAP_ANONYMOUS)meno che tu non usi anche tu MAP_POPULATE. Si spera che le nuove pagine dello stack siano supportate da nuove pagine fisiche e cablate (mappate nelle tabelle delle pagine dell'hardware, così come nel puntatore / elenco di mappature della lunghezza del kernel) quando crescono, perché normalmente la nuova memoria dello stack viene scritta quando viene toccata per la prima volta . Ma sì, il kernel deve evitare in qualche modo la perdita di dati e l'azzeramento è il più economico e utile.
Peter Cordes,

9

La tua premessa è sbagliata.

Ciò che descrivi come "sicurezza" è in realtà riservatezza , nel senso che nessun processo può leggere un'altra memoria di processi, a meno che questa memoria non sia esplicitamente condivisa tra questi processi. In un sistema operativo, questo è un aspetto dell'isolamento di attività o processi simultanei.

Ciò che il sistema operativo sta facendo per garantire questo isolamento, è ogni volta che il processo richiede allocazioni di memoria per allocazioni di heap o stack, questa memoria proviene da una regione della memoria fisica che è riempita con zero o che è piena di spazzatura che è proveniente dallo stesso processo .

Questo ti assicura di vedere sempre zero o la tua spazzatura, quindi la riservatezza è garantita e sia l' heap che lo stack sono "sicuri", sebbene non necessariamente (zero) inizializzati.

Stai leggendo troppo nelle tue misurazioni.


1
La sezione Aggiornamento della domanda ora fa esplicito riferimento alla tua risposta illuminante.
1919
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.