Se o heap é inicializado com zero por segurança, por que a pilha é apenas não inicializada?

15

No meu sistema Debian GNU / Linux 9, quando um binário é executado,

  • a pilha não foi inicializada, mas
  • o heap é inicializado com zero.

Por quê?

Suponho que a inicialização zero promova segurança, mas, se for para o heap, por que não também para a pilha? A pilha também não precisa de segurança?

Minha pergunta não é específica para o Debian, tanto quanto eu sei.

Código C de exemplo:

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

Resultado:

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

O padrão C não pede malloc()para limpar a memória antes de alocá-la, é claro, mas meu programa C é meramente ilustrativo. A questão não é sobre C ou sobre a biblioteca padrão de C. Em vez disso, a pergunta é sobre por que o kernel e / ou o carregador de tempo de execução estão zerando a pilha, mas não a pilha.

OUTRA EXPERIÊNCIA

Minha pergunta diz respeito ao comportamento observável do GNU / Linux, e não aos requisitos dos documentos de padrões. Se não souber o que quero dizer, tente este código, que invoca um comportamento indefinido adicional ( indefinido, isto é, no que diz respeito ao padrão C) para ilustrar o ponto:

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

Saída da minha máquina:

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

No que diz respeito ao padrão C, o comportamento é indefinido, portanto minha pergunta não considera o padrão C. Uma chamada para malloc()não precisar retornar o mesmo endereço todas as vezes, mas, como essa chamada malloc()realmente retorna o mesmo endereço toda vez, é interessante notar que a memória, que está na pilha, é zerada toda vez.

A pilha, por outro lado, não parecia zerada.

Não sei o que o último código fará em sua máquina, pois não sei qual camada do sistema GNU / Linux está causando o comportamento observado. Você pode apenas tentar.

ATUALIZAR

@Kusalananda observou nos comentários:

Pelo que vale a pena, seu código mais recente retorna endereços diferentes e dados (não ocasionais) não inicializados (diferentes de zero) quando executados no OpenBSD. Obviamente, isso não diz nada sobre o comportamento que você está testemunhando no Linux.

Que meu resultado seja diferente do resultado no OpenBSD é realmente interessante. Aparentemente, meus experimentos estavam descobrindo não um protocolo de segurança do kernel (ou vinculador), como eu pensava, mas um mero artefato de implementação.

Nesta perspectiva, acredito que, juntas, as respostas abaixo de @mosvy, @StephenKitt e @AndreasGrapentin resolvam minha pergunta.

Veja também no Stack Overflow: Por que o malloc inicializa os valores para 0 em gcc? (crédito: @bta).

thb
fonte
2
Pelo que vale a pena, seu código mais recente retorna endereços diferentes e dados (não ocasionais) não inicializados (diferentes de zero) quando executados no OpenBSD. Obviamente, isso não diz nada sobre o comportamento que você está testemunhando no Linux.
Kusalananda
Não altere o escopo da sua pergunta e não tente editá-la para tornar as respostas e comentários redundantes. Em C, o "heap" nada mais é do que a memória retornada por malloc () e calloc (), e apenas o último está zerando a memória; o newoperador em C ++ (também "heap") está no Linux apenas um invólucro para malloc (); o kernel não sabe nem se importa com o que é o "heap".
mosvy
3
Seu segundo exemplo é simplesmente expor um artefato da implementação do malloc na glibc; se você fizer isso malloc / free repetido com um buffer maior que 8 bytes, verá claramente que apenas os 8 primeiros bytes são zerados.
mosvy
@Kusalananda eu vejo. Que meu resultado seja diferente do resultado no OpenBSD é realmente interessante. Aparentemente, você e Mosvy mostraram que minhas experiências não estavam descobrindo um protocolo de segurança do kernel (ou vinculador), como eu pensava, mas um mero artefato de implementação.
thb
@ thb Eu acredito que esta pode ser uma observação correta, sim.
Kusalananda

Respostas:

28

O armazenamento retornado por malloc () não é inicializado com zero. Nunca assuma que seja.

No seu programa de teste, é apenas um acaso: acho que malloc()acabamos de ganhar um novo bloco mmap(), mas também não confie nisso.

Por exemplo, se eu executar o seu programa na minha máquina desta maneira:

$ 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

Seu segundo exemplo é simplesmente expor um artefato da mallocimplementação na glibc; se você fizer isso repetido malloc/ freecom um buffer maior que 8 bytes, verá claramente que apenas os 8 primeiros bytes são zerados, como no código de exemplo a seguir.

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

Resultado:

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
fonte
2
Bem, sim, mas é por isso que fiz a pergunta aqui, e não no Stack Overflow. Minha pergunta não era sobre o padrão C, mas sobre a maneira como os sistemas GNU / Linux modernos normalmente vinculam e carregam binários. Seu LD_PRELOAD é engraçado, mas responde a outra pergunta que não a que eu pretendia fazer.
thb
19
Estou feliz por ter feito você rir, mas suas suposições e preconceitos não são engraçados. Em um "sistema GNU / Linux moderno", os binários normalmente são carregados por um vinculador dinâmico, que executa os construtores das bibliotecas dinâmicas antes de acessar a função main () do seu programa. No seu próprio sistema Debian GNU / Linux 9, malloc () e free () serão chamados mais de uma vez antes da função main () do seu programa, mesmo quando não estiver usando nenhuma biblioteca pré-carregada.
mosvy
23

Independentemente de como a pilha é inicializada, você não está vendo uma pilha intocada, porque a biblioteca C faz várias coisas antes da chamada maine elas tocam na pilha.

Com a biblioteca GNU C, em x86-64, a execução inicia no ponto de entrada _start , que chama __libc_start_mainpara configurar as coisas, e a última acaba chamando main. Porém, antes de chamar main, ele chama várias outras funções, o que faz com que vários dados sejam gravados na pilha. O conteúdo da pilha não é limpo entre as chamadas de função; portanto, quando você entra main, sua pilha contém as sobras das chamadas de função anteriores.

Isso explica apenas os resultados que você obtém da pilha; veja as outras respostas sobre sua abordagem geral e suposições.

Stephen Kitt
fonte
Observe que no momento em que main()é chamado, as rotinas de inicialização podem muito bem ter modificado a memória retornada por malloc()- especialmente se as bibliotecas C ++ estiverem vinculadas. Supondo que o "heap" seja inicializado para qualquer coisa é uma suposição muito, muito ruim.
Andrew Henle
Sua resposta, juntamente com o Mosvy, resolve minha pergunta. Infelizmente, o sistema me permite aceitar apenas um dos dois; caso contrário, eu aceitaria os dois.
THB
18

Nos dois casos, você obtém memória não inicializada e não pode fazer nenhuma suposição sobre seu conteúdo.

Quando o sistema operacional precisa distribuir uma nova página ao seu processo (seja para a pilha ou para a arena usada malloc()), garante que não exporá dados de outros processos; a maneira usual de garantir isso é preenchê-lo com zeros (mas é igualmente válido substituí-lo por qualquer outra coisa, incluindo até uma página /dev/urandom- de fato, algumas malloc()implementações de depuração escrevem padrões diferentes de zero, para capturar suposições equivocadas como a sua).

Se malloc()puder satisfazer a solicitação da memória já usada e liberada por esse processo, seu conteúdo não será limpo (na verdade, a limpeza não tem nada a ver com malloc()isso e não pode - ela tem que acontecer antes que a memória seja mapeada para seu espaço de endereço). Você pode obter memória que foi gravada anteriormente pelo seu processo / programa (por exemplo, antes main()).

No seu exemplo de programa, você está vendo uma malloc()região que ainda não foi gravada por esse processo (ou seja, é direta a partir de uma nova página) e uma pilha que foi gravada (por pré- main()código no seu programa). Se você examinar mais da pilha, verá que ela é preenchida com zero mais abaixo (em sua direção de crescimento).

Se você realmente deseja entender o que está acontecendo no nível do sistema operacional, recomendo que você ignore a camada da Biblioteca C e interaja usando chamadas do sistema como brk()e mmap().

Toby Speight
fonte
1
Há uma ou duas semanas, tentei um experimento diferente, ligando malloc()e free()repetidamente. Embora nada exija malloc()reutilizar o mesmo armazenamento liberado recentemente, no experimento, malloc()isso aconteceu. Por acaso, retornava o mesmo endereço todas as vezes, mas também anulava a memória todas as vezes, o que eu não esperava. Isso foi interessante para mim. Outras experiências levaram à pergunta de hoje.
thb
1
@ thb, talvez eu não esteja sendo claro o suficiente - a maioria das implementações malloc()não faz absolutamente nada com a memória que eles lhe entregam - seja usada anteriormente ou recém-atribuída (e, portanto, zerada pelo sistema operacional). No seu teste, você evidentemente conseguiu o último. Da mesma forma, a memória da pilha é fornecida ao seu processo no estado limpo, mas você não a examina o suficiente para ver as partes que o seu processo ainda não tocou. A memória da pilha é limpa antes de ser entregue ao seu processo.
Toby Speight
2
@TobySpeight: brk e sbrk são obsoletos pelo mmap. pubs.opengroup.org/onlinepubs/7908799/xsh/brk.html diz LEGACY bem no topo.
Joshua
2
Se você precisa de memória inicializado utilizando callocpode ser uma opção (em vez de memset)
Eckes
2
@thb e Toby: fato interessante: as novas páginas do kernel geralmente são alocadas preguiçosamente, e apenas a cópia na gravação é mapeada para uma página zerada compartilhada. Isso acontece a mmap(MAP_ANONYMOUS)menos que você use MAP_POPULATEtambém. Espera-se que novas páginas de pilha sejam apoiadas por novas páginas físicas e conectadas (mapeadas nas tabelas de páginas de hardware, bem como na lista de mapeamentos de ponteiro / comprimento do kernel) ao crescer, porque normalmente uma nova memória de pilha está sendo gravada quando tocada pela primeira vez . Mas sim, o kernel deve evitar o vazamento de dados de alguma forma, e zerar é o mais barato e mais útil.
Peter Cordes
9

Sua premissa está errada.

O que você descreve como 'segurança' é realmente confidencialidade , significando que nenhum processo pode ler a memória de outro processo, a menos que essa memória seja explicitamente compartilhada entre esses processos. Em um sistema operacional, esse é um aspecto do isolamento de atividades ou processos simultâneos.

O que o sistema operacional está fazendo para garantir esse isolamento é quando a memória é solicitada pelo processo para alocações de heap ou pilha, essa memória é proveniente de uma região na memória física preenchida com zeros ou preenchida com lixo eletrônico provenientes do mesmo processo .

Isso garante que você apenas veja zeros ou seu próprio lixo, para garantir a confidencialidade, e o heap e a pilha são 'seguros', embora não sejam necessariamente inicializados com zero.

Você está lendo muito em suas medidas.

Andreas Grapentin
fonte
1
A seção Atualização da pergunta agora faz referência explícita à sua resposta esclarecedora.
THB