Si el montón no tiene inicialización de seguridad, ¿por qué la pila simplemente no se inicializa?

15

En mi sistema Debian GNU / Linux 9, cuando se ejecuta un binario,

  • la pila no está inicializada pero
  • el montón está inicializado en cero.

¿Por qué?

Supongo que la inicialización cero promueve la seguridad, pero, si es para el montón, ¿por qué no también para la pila? ¿La pila tampoco necesita seguridad?

Mi pregunta no es específica de Debian hasta donde yo sé.

Código C de muestra:

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

Salida:

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

El estándar C no pide malloc()borrar memoria antes de asignarlo, por supuesto, pero mi programa C es meramente ilustrativo. La pregunta no es una pregunta sobre C o sobre la biblioteca estándar de C. Más bien, la pregunta es por qué el kernel y / o el cargador de tiempo de ejecución están poniendo a cero el montón pero no la pila.

OTRO EXPERIMENTO

Mi pregunta se refiere al comportamiento observable de GNU / Linux en lugar de los requisitos de los documentos estándar. Si no está seguro de lo que quiero decir, intente este código, que invoca un comportamiento indefinido adicional ( indefinido, es decir, en lo que respecta al estándar C) para ilustrar el 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;
}

Salida de mi máquina:

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

En lo que respecta al estándar C, el comportamiento no está definido, por lo que mi pregunta no tiene en cuenta el estándar C. No es necesario que una llamada malloc()devuelva la misma dirección cada vez, pero dado que esta llamada malloc()sí devuelve la misma dirección cada vez, es interesante notar que la memoria, que está en el montón, se pone a cero cada vez.

La pila, por el contrario, no parecía estar puesta a cero.

No sé qué hará este último código en su máquina, ya que no sé qué capa del sistema GNU / Linux está causando el comportamiento observado. Solo puedes intentarlo.

ACTUALIZAR

@Kusalananda ha observado en comentarios:

Por lo que vale, su código más reciente devuelve diferentes direcciones y datos (no ocasionales) no inicializados (no cero) cuando se ejecuta en OpenBSD. Obviamente, esto no dice nada sobre el comportamiento que está presenciando en Linux.

Que mi resultado difiere del resultado en OpenBSD es realmente interesante. Aparentemente, mis experimentos descubrieron no un protocolo de seguridad del núcleo (o enlazador), como había pensado, sino un mero artefacto de implementación.

En este sentido, creo que, juntas, las respuestas a continuación de @mosvy, @StephenKitt y @AndreasGrapentin resuelven mi pregunta.

Ver también en Desbordamiento de pila: ¿Por qué malloc inicializa los valores a 0 en gcc? (crédito: @bta).

thb
fuente
2
Por lo que vale, su código más reciente devuelve diferentes direcciones y datos (no ocasionales) no inicializados (no cero) cuando se ejecuta en OpenBSD. Obviamente, esto no dice nada sobre el comportamiento que está presenciando en Linux.
Kusalananda
No cambie el alcance de su pregunta y no intente editarla para que las respuestas y comentarios sean redundantes. En C, el "montón" no es más que la memoria devuelta por malloc () y calloc (), y solo este último está poniendo a cero la memoria; el newoperador en C ++ (también "heap") está en Linux solo como un contenedor para malloc (); el núcleo no sabe ni le importa cuál es el "montón".
mosvy
3
Su segundo ejemplo es simplemente exponer un artefacto de la implementación de malloc en glibc; si hace eso malloc / free repetido con un búfer de más de 8 bytes, verá claramente que solo los primeros 8 bytes están a cero.
mosvy
@Kusalananda ya veo. Que mi resultado difiere del resultado en OpenBSD es realmente interesante. Aparentemente, usted y Mosvy han demostrado que mis experimentos no estaban descubriendo un protocolo de seguridad del núcleo (o enlazador), como había pensado, sino un mero artefacto de implementación.
THB
@thb Creo que esta puede ser una observación correcta, sí.
Kusalananda

Respuestas:

28

El almacenamiento devuelto por malloc () no tiene inicialización cero. Nunca asumas que es así.

En su programa de prueba, es solo una casualidad: supongo que malloc()acaba de obtener un nuevo bloqueo mmap(), pero tampoco confíe en eso.

Por ejemplo, si ejecuto su programa en mi máquina de esta manera:

$ 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

Su segundo ejemplo es simplemente exponer un artefacto de la mallocimplementación en glibc; si hace eso repetido malloc/ freecon un búfer de más de 8 bytes, verá claramente que solo los primeros 8 bytes están a cero, como en el siguiente código de muestra.

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

Salida:

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
fuente
2
Bueno, sí, pero es por eso que hice la pregunta aquí en lugar de sobre Stack Overflow. Mi pregunta no era sobre el estándar C sino sobre la forma en que los sistemas GNU / Linux modernos generalmente vinculan y cargan binarios. Su LD_PRELOAD es gracioso pero responde a otra pregunta que no era la pregunta que pretendía hacerle.
THB
19
Me alegra haberte hecho reír, pero tus suposiciones y prejuicios no son graciosos en absoluto. En un "sistema GNU / Linux moderno", los binarios generalmente se cargan mediante un vinculador dinámico, que ejecuta constructores de bibliotecas dinámicas antes de acceder a la función main () desde su programa. En su propio sistema Debian GNU / Linux 9, tanto malloc () como free () serán llamados más de una vez antes de la función main () de su programa, incluso cuando no esté usando ninguna biblioteca precargada.
mosvy
23

Independientemente de cómo se inicializa la pila, no está viendo una pila prístina, porque la biblioteca C hace varias cosas antes de llamar main, y tocan la pila.

Con la biblioteca GNU C, en x86-64, la ejecución comienza en el punto de entrada _start , que llama __libc_start_mainpara configurar las cosas, y este último termina llamando main. Pero antes de llamar main, llama a una serie de otras funciones, lo que hace que se escriban varios datos en la pila. El contenido de la pila no se borra entre las llamadas a funciones, por lo que cuando ingresa main, su pila contiene los restos de las llamadas a funciones anteriores.

Esto solo explica los resultados que obtiene de la pila, vea las otras respuestas con respecto a su enfoque general y suposiciones.

Stephen Kitt
fuente
Tenga en cuenta que para cuando main()se llama, las rutinas de inicialización pueden muy bien haber devuelto la memoria modificada malloc(), especialmente si las bibliotecas de C ++ están vinculadas. Asumir que el "montón" se inicializa a cualquier cosa es una suposición muy, muy mala.
Andrew Henle
Su respuesta junto con los Mosvy's resuelven mi pregunta. Desafortunadamente, el sistema me permite aceptar solo uno de los dos; de lo contrario, aceptaría ambos.
THB
18

En ambos casos, obtienes memoria no inicializada y no puedes hacer suposiciones sobre su contenido.

Cuando el sistema operativo tiene que distribuir una nueva página a su proceso (ya sea para su pila o para la arena utilizada por malloc()), garantiza que no expondrá los datos de otros procesos; la forma habitual de asegurarse es llenarlo con ceros (pero es igualmente válido sobrescribir con cualquier otra cosa, incluso una página que valga la pena /dev/urandom; de hecho, algunas malloc()implementaciones de depuración escriben patrones distintos de cero, para detectar suposiciones erróneas como la suya).

Si malloc()puede satisfacer la solicitud de la memoria ya utilizada y liberada por este proceso, su contenido no se borrará (de hecho, la limpieza no tiene nada que ver malloc()y no puede serlo; tiene que suceder antes de que la memoria se asigne a su espacio de direcciones) Puede obtener memoria que ha sido escrita previamente por su proceso / programa (por ejemplo, antes main()).

En su programa de ejemplo, está viendo una malloc()región que aún no ha sido escrita por este proceso (es decir, es directa desde una página nueva) y una pila en la que se ha escrito (por main()código previo en su programa). Si examina más de la pila, encontrará que está llena de cero más abajo (en su dirección de crecimiento).

Si realmente quiere comprender lo que está sucediendo a nivel del sistema operativo, le recomiendo que omita la capa de la Biblioteca C e interactúe utilizando llamadas del sistema como brk()y en su mmap()lugar.

Toby Speight
fuente
1
Hace una o dos semanas, intenté un experimento diferente, llamando malloc()y free()repetidamente. Aunque nada requiere malloc()reutilizar el mismo almacenamiento recientemente liberado, en el experimento, malloc()sucedió eso. Sucedió que devolvía la misma dirección cada vez, pero también anuló la memoria cada vez, lo que no había esperado. Esto fue interesante para mi. Otros experimentos han llevado a la pregunta de hoy.
THB
1
@thb, tal vez no estoy siendo lo suficientemente claro, la mayoría de las implementaciones de malloc()no hacen absolutamente nada con la memoria que le entregan, ya sea que se haya utilizado previamente o esté recién asignado (y, por lo tanto, el sistema operativo lo haya puesto a cero). En su prueba, evidentemente obtuvo el último. Del mismo modo, la memoria de la pila se entrega a su proceso en el estado borrado, pero no la examina lo suficiente como para ver partes que su proceso aún no ha tocado. Su memoria de pila se borra antes de que se le dé a su proceso.
Toby Speight
2
@TobySpeight: brk y sbrk están obsoletos por mmap. pubs.opengroup.org/onlinepubs/7908799/xsh/brk.html dice LEGACY justo en la parte superior.
Joshua
2
Si necesita memoria inicializada, callocpodría ser una opción (en lugar de memset)
eckes el
2
@thb y Toby: hecho curioso: las páginas nuevas del kernel a menudo se asignan perezosamente, y simplemente se copian en una página compartida compartida. Esto sucede a mmap(MAP_ANONYMOUS)menos que lo use MAP_POPULATEtambién. Con suerte, las nuevas páginas de pila están respaldadas por páginas físicas nuevas y conectadas (mapeadas en las tablas de páginas de hardware, así como la lista de punteros / longitud del mapeo del núcleo) cuando crecen, porque normalmente se escribe nueva memoria de pila cuando se toca por primera vez . Pero sí, el núcleo debe evitar la pérdida de datos de alguna manera, y la reducción a cero es la más económica y útil.
Peter Cordes
9

Tu premisa está mal.

Lo que usted describe como 'seguridad' es realmente confidencialidad , lo que significa que ningún proceso puede leer la memoria de otros procesos, a menos que esta memoria se comparta explícitamente entre estos procesos. En un sistema operativo, este es un aspecto del aislamiento de actividades o procesos concurrentes.

Lo que está haciendo el sistema operativo para garantizar este aislamiento es cuando el proceso solicita memoria para asignaciones de montón o pila, esta memoria proviene de una región en memoria física que se llena con ceros, o que se llena con basura que es viniendo del mismo proceso .

Esto garantiza que solo verá ceros, o su propia basura, por lo que se garantiza la confidencialidad, y tanto el montón como la pila son 'seguros', aunque no necesariamente inicializados (cero).

Estás leyendo demasiado en tus medidas.

Andreas Grapentin
fuente
1
La sección Actualización de la pregunta ahora hace referencia explícita a su respuesta esclarecedora.
THB