El principal problema con el crecimiento de la pila es que tiene que sentarse al principio de la memoria y tener un límite de tamaño superior forzado, o sentarse en la parte superior de la memoria y tener una ubicación forzada. Ambas situaciones limitan la utilización máxima de la memoria. Si tanto la pila como el montón crecen uno hacia el otro desde los extremos opuestos, solo te quedas sin memoria cuando literalmente no queda nada. En otras configuraciones, puede quedarse sin pila o montón a pesar de tener memoria libre.
La idea de "apilar hacia atrás" ha sido lanzada por bastante tiempo, y generalmente no se ve como una solución sólida. Para empezar, se encontrará con problemas si está modificando los buffers de pila que se encuentran en marcos anteriores. Inicialmente, esto no parece que sea común, pero considera que asignas buffers para muchas funciones antes que los pasas:
void foo(char* src)
{
char dest[20]; // local allocated within frame of foo
strcpy(dest, src); // new stack frame, so return ptr AFTER dest
// blah blah rest of code...
if (dest[0] == 'X')
printf("First char is X\n");
}
Observe que el marco de pila para strcpy
se ubicará numéricamente sobre el marco de pila para foo
. En un sistema típico, strcpy
escribiría más allá del búfer, sobre el puntero de retorno de foo
, lo que llevaría al control del puntero de instrucción después de que foo
devuelva. Con una pila hacia atrás, strcpy
se acumularía, hasta el final del marco de pila de foo
, en la parte inferior del marco de pila de strcpy
, lo que provocaría una sobrescritura de la dirección de retorno de strcpy
, y nuevamente dando el control del puntero de instrucción.
Una solución que muchos consideran bastante segura es utilizar una arquitectura que tenga una pila para locales y parámetros (la pila de datos) y otra para marcos de pila y punteros de retorno (la pila de control). Este aislamiento ayuda a garantizar que los punteros de retorno no se sobrescriban de forma casual cuando se desborda un búfer local. Nuevamente tiene problemas con la administración de memoria, aunque solo una pila debe tener un tamaño máximo fijo, la otra puede crecer en el montón.
Por supuesto, esta arquitectura tampoco es completamente robusta desde el punto de vista de la seguridad. Considera lo siguiente:
int int_sorter( const void *val_a, const void *val_b )
{
// this code isn't important here
int first = *(int*)val_a;
int second = *(int*)val_b;
if ( first == second )
return 0;
else return (first < second) ? -1 : 1;
}
void bar(char* message, int (*sorter)(const void*,const void*))
{
int array[10];
char dest[32];
// do something with array
// ...
strcpy(dest, message);
qsort(array, 10, sizeof( int ), sorter);
}
En este caso, asumiendo un par de pilas clásicas de crecimiento descendente en una arquitectura de doble pila, message
se desborda dest
en la pila de datos, copiando el puntero de función sorter
que se presionó como segundo parámetro codificar%. La llamada a bar
regresa normalmente (la pila de control está intacta) pero luego la llamada a strcpy
contiene una instrucción para saltar a la función qsort
, lo que nuevamente lleva al control sobre el puntero de la instrucción.
Al final del día, no va a encontrar una solución total al problema. Lo mejor que puedes hacer es utilizar buenas prácticas de codificación (o enseñar a tus desarrolladores a hacerlo) y habilitar protecciones como ASLR (a.k.a. PIE), DEP / NX, apilar canarios, etc.