Jeśli stos jest inicjowany na zero dla bezpieczeństwa, to dlaczego stos jest po prostu niezainicjowany?

15

W moim systemie Debian GNU / Linux 9, gdy plik binarny jest wykonywany,

  • stos jest niezainicjowany, ale
  • sterta jest inicjowana zerem.

Dlaczego?

Zakładam, że inicjalizacja zera promuje bezpieczeństwo, ale jeśli dla stosu, to dlaczego nie także dla stosu? Czy stos też nie potrzebuje bezpieczeństwa?

O ile wiem, moje pytanie nie jest specyficzne dla Debiana.

Przykładowy kod 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;
}

Wynik:

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

malloc()Oczywiście standard C nie wymaga wyczyszczenia pamięci przed jej przydzieleniem, ale mój program C służy jedynie do ilustracji. Pytanie nie jest pytaniem o C ani o standardową bibliotekę C. Raczej pytanie dotyczy tego, dlaczego jądro i / lub moduł ładujący w czasie wykonywania zerują stertę, ale nie stos.

KOLEJNY DOŚWIADCZENIE

Moje pytanie dotyczy obserwowalnego zachowania GNU / Linuksa, a nie wymagań dokumentów standardów. Jeśli nie jestem pewien, co mam na myśli, wypróbuj ten kod, który wywołuje dalsze niezdefiniowane zachowanie ( niezdefiniowane, to znaczy w odniesieniu do standardu C), aby zilustrować tę kwestię:

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

Dane wyjściowe z mojej maszyny:

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

Jeśli chodzi o standard C, zachowanie jest niezdefiniowane, więc moje pytanie nie dotyczy normy C. Wywołanie, które malloc()nie musi zwracać tego samego adresu za każdym razem, ale ponieważ to wezwanie malloc()rzeczywiście rzeczywiście zwraca ten sam adres za każdym razem, interesujące jest zauważenie, że pamięć, która jest na stercie, jest zerowana za każdym razem.

Natomiast stos nie wydawał się wyzerowany.

Nie wiem, co ten ostatni kod zrobi na twoim komputerze, ponieważ nie wiem, która warstwa systemu GNU / Linux powoduje obserwowane zachowanie. Możesz tylko spróbować.

AKTUALIZACJA

@Kusalananda zauważył w komentarzach:

Jeśli chodzi o wartość, najnowszy kod zwraca różne adresy i (sporadycznie) niezainicjowane (niezerowe) dane, gdy jest uruchamiany na OpenBSD. To oczywiście nie mówi nic o zachowaniu, którego obserwujesz w Linuksie.

To, że mój wynik różni się od wyniku na OpenBSD, jest naprawdę interesujące. Najwyraźniej moje eksperymenty odkrywały nie protokół bezpieczeństwa jądra (lub linkera), jak myślałem, ale zwykły artefakt implementacyjny.

W tym świetle uważam, że łącznie poniższe odpowiedzi @mosvy, @StephenKitt i @AndreasGrapentin rozstrzygają moje pytanie.

Zobacz także Przepełnienie stosu: dlaczego malloc inicjuje wartości na 0 w gcc? (kredyt: @bta).

thb
źródło
2
Jeśli chodzi o wartość, najnowszy kod zwraca różne adresy i (sporadycznie) niezainicjowane (niezerowe) dane, gdy jest uruchamiany na OpenBSD. To oczywiście nie mówi nic o zachowaniu, którego obserwujesz w Linuksie.
Kusalananda
Nie zmieniaj zakresu pytania i nie próbuj go edytować, aby zbędne były odpowiedzi i komentarze. W C „sterta” to nic innego, jak pamięć zwrócona przez malloc () i calloc (), a tylko ta ostatnia zeruje pamięć; newoperator C ++ (także „sterty”) jest Linux tylko owijka malloc (); jądro nie wie ani nie obchodzi, czym jest „sterta”.
mosvy
3
Drugi przykład to po prostu odsłonięcie artefaktu implementacji malloc w glibc; jeśli powtórzysz Malloc / free z buforem większym niż 8 bajtów, zobaczysz, że tylko pierwsze 8 bajtów jest zerowanych.
mosvy
@Kusalananda Rozumiem. To, że mój wynik różni się od wyniku na OpenBSD, jest naprawdę interesujące. Najwyraźniej ty i Mosvy pokazaliście, że moje eksperymenty odkrywały nie protokół bezpieczeństwa jądra (lub linkera), jak myślałem, ale zwykły artefakt implementacyjny.
thb
@thb Uważam, że może to być poprawna obserwacja, tak.
Kusalananda

Odpowiedzi:

28

Pamięć zwrócona przez malloc () nie jest inicjowana zerem. Nigdy nie zakładaj, że tak jest.

W twoim programie testowym jest to tylko przypadek: Myślę, że malloc()właśnie dostałem świeżą blokadę mmap(), ale też nie polegaj na tym.

Na przykład, jeśli uruchomię twój program na moim komputerze w ten sposób:

$ 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

Drugi przykład to po prostu odsłonięcie artefaktu mallocimplementacji w glibc; jeśli powtórzysz malloc/ freez buforem większym niż 8 bajtów, zobaczysz, że tylko pierwsze 8 bajtów jest zerowanych, jak w poniższym przykładowym kodzie.

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

Wynik:

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
źródło
2
Cóż, tak, ale dlatego zadałem pytanie tutaj, a nie na Stack Overflow. Moje pytanie nie dotyczyło standardu C, ale sposobu, w jaki nowoczesne systemy GNU / Linux zazwyczaj łączą i ładują pliki binarne. Twój LD_PRELOAD jest dowcipny, ale odpowiada na inne pytanie niż pytanie, które chciałem zadać.
Thb
19
Cieszę się, że cię rozśmieszyłem, ale twoje założenia i uprzedzenia wcale nie są śmieszne. W „nowoczesnym systemie GNU / Linux” pliki binarne są zazwyczaj ładowane przez dynamiczny linker, który uruchamia konstruktory z bibliotek dynamicznych przed przejściem do funkcji main () z twojego programu. W twoim systemie Debian GNU / Linux 9 zarówno malloc (), jak i free () będą wywoływane więcej niż jeden raz przed funkcją main () z twojego programu, nawet jeśli nie korzystasz z żadnych fabrycznie załadowanych bibliotek.
mosvy
23

Niezależnie od tego, w jaki sposób inicjowany jest stos, nie widzisz nieskazitelnego stosu, ponieważ biblioteka C wykonuje wiele czynności przed wywołaniem maini dotykają stosu.

W bibliotece GNU C na x86-64 wykonywanie rozpoczyna się w punkcie wejścia _start , który wywołuje __libc_start_mainkonfigurację, a ten ostatni kończy się wywołaniem main. Ale przed wywołaniem mainwywołuje szereg innych funkcji, które powodują zapisywanie różnych danych na stosie. Zawartość stosu nie jest usuwana między wywołaniami funkcji, więc gdy się w nią wejdziesz main, stos zawiera resztki poprzednich wywołań funkcji.

To tłumaczy tylko wyniki uzyskane ze stosu, zobacz inne odpowiedzi dotyczące twojego ogólnego podejścia i założeń.

Stephen Kitt
źródło
Zauważ, że do czasu main()wywołania procedury inicjujące mogą równie dobrze zwrócić zmodyfikowaną pamięć malloc()- szczególnie jeśli biblioteki C ++ są połączone. Założenie, że „sterty” jest inicjowane na cokolwiek, jest naprawdę złym założeniem.
Andrew Henle,
Twoja odpowiedź wraz z Mosvy'ego rozwiązuje moje pytanie. System niestety pozwala mi zaakceptować tylko jeden z dwóch; w przeciwnym razie zaakceptowałbym oba.
thb
18

W obu przypadkach otrzymujesz niezainicjowaną pamięć i nie możesz przyjmować żadnych założeń dotyczących jej zawartości.

Kiedy system operacyjny musi przypisać nową stronę do twojego procesu (czy to ze względu na stos, czy arenę, z której korzysta malloc()), gwarantuje, że nie ujawni danych z innych procesów; jest to zwykły sposób na wypełnienie go zerami (ale równie ważne jest zastąpienie go czymkolwiek innym, w tym nawet wartością strony /dev/urandom- w rzeczywistości niektóre malloc()implementacje debugujące zapisują niezerowe wzorce, aby uchwycić błędne założenia, takie jak twoje).

Jeśli malloc()może zaspokoić żądanie z pamięci, która została już wykorzystana i zwolniona przez ten proces, jego zawartość nie zostanie wyczyszczona (w rzeczywistości czyszczenie nie ma nic wspólnego malloc()i nie może być - musi się zdarzyć, zanim pamięć zostanie zmapowana w Twoja przestrzeń adresowa). Możesz dostać pamięć, która została wcześniej zapisana przez twój proces / program (np. Wcześniej main()).

W twoim przykładowym programie widzisz malloc()region, który nie został jeszcze zapisany przez ten proces (tj. Jest bezpośrednio z nowej strony) i stos, do którego został zapisany (przez main()kod wstępny w twoim programie). Jeśli przyjrzysz się większej ilości stosu, zobaczysz, że jest on wypełniony zerami w dół (w kierunku wzrostu).

Jeśli naprawdę chcesz zrozumieć, co dzieje się na poziomie systemu operacyjnego, zalecamy ominięcie warstwy biblioteki C i interakcję za pomocą wywołań systemowych, takich jak brk()i mmap()zamiast.

Toby Speight
źródło
1
Tydzień lub dwa lata temu kilkakrotnie próbowałem innego eksperymentu, wzywając malloc()i free()wielokrotnie. Chociaż nic nie wymaga malloc()ponownego użycia tej samej pamięci ostatnio zwolnionej, w eksperymencie malloc()zdarzyło się to zrobić. Zdarzyło się, że za każdym razem zwracał ten sam adres, ale za każdym razem zerował pamięć, czego się nie spodziewałem. To było dla mnie interesujące. Dalsze eksperymenty doprowadziły do ​​dzisiejszego pytania.
thb
1
@thb, Być może nie jestem wystarczająco jasny - większość implementacji malloc()nie robi absolutnie nic z pamięcią, którą ci dają - jest albo wcześniej używana, albo świeżo przypisana (a zatem zerowana przez system operacyjny). W teście najwyraźniej masz ten drugi. Podobnie pamięć stosu jest przekazywana procesowi w stanie wyczyszczonym, ale nie sprawdza się go wystarczająco daleko, aby zobaczyć części, których proces jeszcze nie dotknął. Pamięć stosu jest czyszczona, zanim zostanie przekazana procesowi.
Toby Speight
2
@TobySpeight: brk i sbrk są nieaktualne przez mmap. pubs.opengroup.org/onlinepubs/7908799/xsh/brk.html mówi LEGACY na samej górze.
Joshua
2
Jeśli potrzebujesz zainicjowanej pamięci, callocopcją może być (zamiast memset)
eckes
2
@thb i Toby: fajny fakt: nowe strony z jądra są często leniwie przydzielane, a jedynie kopiowane przy zapisie mapowane na współdzieloną stronę zerowaną. Dzieje się tak, mmap(MAP_ANONYMOUS)chyba że używasz MAP_POPULATErównież. Mam nadzieję, że nowe strony stosu są wspierane przez świeże strony fizyczne i łączone (mapowane w tabelach stron sprzętowych, a także wskaźnik / lista długości mapowań jądra) podczas powiększania, ponieważ normalnie nowa pamięć stosu jest zapisywana po pierwszym dotknięciu . Ale tak, jądro musi jakoś uniknąć wycieku danych, a zerowanie jest najtańszym i najbardziej użytecznym.
Peter Cordes
9

Twoje założenie jest złe.

To, co określasz jako „bezpieczeństwo”, jest naprawdę poufnością , co oznacza, że ​​żaden proces nie może odczytać pamięci innych procesów, chyba że pamięć ta jest jawnie dzielona między te procesy. W systemie operacyjnym jest to jeden aspekt izolacji jednoczesnych działań lub procesów.

System operacyjny robi to, aby zapewnić tę izolację, za każdym razem, gdy proces żąda pamięci dla alokacji stosu lub stosu, pamięć ta pochodzi albo z obszaru pamięci fizycznej wypełnionego zerami, albo wypełnionego śmieciami, które są pochodzący z tego samego procesu .

Zapewnia to, że zawsze widzisz tylko zera lub własne śmieci, więc zapewniona jest poufność, a zarówno stos, jak i stos są „bezpieczne”, choć niekoniecznie (zerowo) inicjowane.

Za dużo czytasz w swoich pomiarach.

Andreas Grapentin
źródło
1
Sekcja Aktualizacja pytania wyraźnie odnosi się teraz do twojej pouczającej odpowiedzi.
Thb