Las razones principales son que es inconveniente, da como resultado un código subóptimo, requiere un conocimiento previo del tamaño de la pila de funciones, limita el rendimiento, viola la especificación de muchas convenciones de llamadas y, en realidad, no impide que los desbordamientos tengan éxito.
La forma habitual en que una función llama a otra función, al menos en x86, es (como era de esperar) con la instrucción call
. Esta instrucción empuja la dirección de retorno a la pila y redirige el puntero de la instrucción a la dirección de destino. La pila es un concepto que el procesador en sí entiende y facilita, y no podemos cambiar el comportamiento del procesador.
Para facilitar la colocación del puntero de retorno antes de las asignaciones de pila, deberíamos asignar el espacio de pila de la función antes de que se ejecute la instrucción call
. Esto requiere que la función que llama (la persona que llama) asigne la pila para la función llamada (el que recibe la llamada), y por lo tanto requiere que la persona que llama limpie la pila de la persona que llama. Esto es posible en convenciones de llamada como cdecl
(también en algunos casos con thiscall
), que utilizan la limpieza del llamante, pero es una violación de las especificaciones de la convención de llamada para stdcall
, fastcall
, vectorcall
, pascal
, y una serie de otras convenciones.
Es posible que se esté preguntando por qué es importante si la persona que llama o la persona que llama realiza la asignación de la pila. Bueno, en general, existe una función porque contiene código que se usa en más de un lugar (¡es por eso que tenemos optimizaciones en línea!). En un modelo de limpieza de llamada, el código para configurar el espacio de asignación de pila de la función y limpiarlo al final es parte de la función de llamada en sí. La función de llamada no necesita conocer los requisitos de memoria de la pila de la función de llamada. Esto es útil porque no terminas duplicando la lógica de administración de pila para cada llamada en esa función. Imagine, por ejemplo, un programa que se llama memcpy
en 10,000 ubicaciones diferentes: para la limpieza de la persona que llama, solo hay una instancia del código de administración de la pila para la función memcpy
, mientras que para la limpieza de la persona que llama ahora hay 10,000 copias. Incluso si esto es solo 10 o 20 bytes de código cada vez que ya son cientos de kilobytes de código redundante.
Otro problema es el crecimiento de la pila. Usted mencionó que el compilador sabe cuánta pila utilizará una función, esto no es estrictamente cierto. Las asignaciones dinámicas de la pila son bastante comunes, y su modelo propuesto emplaza la pila del destinatario entre el marco de la pila de la función de llamada y la dirección de retorno del destinatario:
| return addr | saved *bp | stackalloc space | return addr | saved bp | ...
| (callee) | (callee) | (callee) | (caller) | (caller) |
*bp+0 *bp+N *bp+2N *bp+?N ...
Esto significa que si la función requiere que su espacio de pila crezca dinámicamente, debe mover el puntero de retorno y el puntero de marco de pila (guardado *bp
) cuando lo haga, y también modificar el puntero de marco de pila en el proceso. Esto es innecesariamente complejo e introduce algunos problemas de rendimiento.
Es posible que también te hayas dado cuenta de por qué esto no es una solución para los desbordamientos de búfer. Al desbordar un búfer en el espacio de stackalloc, no sobrescribe la dirección de retorno de la persona que llama, pero puede sobrescribir la dirección de retorno de la persona que llama. Esto realmente no complica la explotación.