Para una aplicación de Windows de 32 bits, ¿es válido usar memoria de pila debajo de ESP para espacio de intercambio temporal sin disminuir explícitamente ESP?

Considere una función que devuelve un valor de coma flotante en ST(0). Si nuestro valor está actualmente en EAX, por ejemplo,

PUSH   EAX
FLD    [ESP]
ADD    ESP,4  // or POP EAX, etc
// return...

O sin modificar el registro ESP, podríamos simplemente:

MOV    [ESP-4], EAX
FLD    [ESP-4]
// return...

En ambos casos ocurre lo mismo, excepto que en el primer caso nos encargamos de disminuir el puntero de la pila antes de usar la memoria, y luego incrementarlo después. En este último caso no lo hacemos.

A pesar de cualquier necesidad real de persistir este valor en la pila (problemas de reentrada, llamadas de función entre PUSH y volver a leer el valor, etc.), ¿hay alguna razón fundamental por la que escribir en la pila debajo de ESP no sea válido?

11
J... 10 sep. 2018 a las 16:00

3 respuestas

En general (no específicamente relacionado con ningún sistema operativo); no es seguro escribir debajo de ESP si:

  • Es posible que el código se interrumpa y el controlador de interrupción se ejecutará en el mismo nivel de privilegio. Nota: Esto suele ser muy poco probable para el código de "espacio de usuario", pero extremadamente probable para el código del núcleo.

  • Usted llama a cualquier otro código (donde el call o la pila utilizada por la rutina llamada pueden destruir los datos que almacenó debajo de ESP)

  • Algo más depende del uso de pila "normal". Esto puede incluir manejo de señal, desenrollado de excepciones (basado en el idioma), depuradores, "protector de aplastamiento de la pila"

Es seguro escribir debajo de ESP si no es "no segura".

Tenga en cuenta que para el código de 64 bits, escribir debajo de RSP está integrado en el x86-64 ABI ("zona roja"); y se hace seguro por su soporte en cadenas de herramientas / compiladores y todo lo demás.

6
Brendan 10 sep. 2018 a las 13:15

Cuando se crea un subproceso, Windows reserva una región contigua de memoria virtual de un tamaño configurable (el valor predeterminado es 1 MB) para la pila del subproceso. Inicialmente, la pila se ve así (la pila crece hacia abajo):

--------------
|  committed |
--------------
| guard page |
--------------
|     .      |
| reserved   |
|     .      |
|     .      |
|            |
--------------

ESP apuntará a algún lugar dentro de la página confirmada. La página de protección se utiliza para admitir el crecimiento automático de la pila. La región de páginas reservadas garantiza que el tamaño de pila solicitado esté disponible en la memoria virtual.

Considere las dos instrucciones de la pregunta:

MOV    [ESP-4], EAX
FLD    [ESP-4]

Hay tres posibilidades:

  • La primera instrucción se ejecuta con éxito. No hay nada que use la pila en modo de usuario que pueda ejecutarse entre las dos instrucciones. Entonces, la segunda instrucción usará el valor correcto (@RbMm declaró esto en los comentarios bajo su respuesta y estoy de acuerdo).
  • La primera instrucción genera una excepción y un controlador de excepción no devuelve EXCEPTION_CONTINUE_EXECUTION. Mientras la segunda instrucción sea inmediatamente posterior a la primera (no está en el controlador de excepciones ni se coloca después de ella), la segunda instrucción no se ejecutará. Entonces todavía estás a salvo. La ejecución continúa desde el marco de la pila donde existe el controlador de excepciones.
  • La primera instrucción genera una excepción y un controlador de excepción devuelve EXCEPTION_CONTINUE_EXECUTION. La ejecución continúa desde la misma instrucción que provocó la excepción (potencialmente con un contexto modificado por el controlador). En este ejemplo particular, el primero se volverá a ejecutar para escribir un valor debajo de ESP. No hay problema. Si la segunda instrucción generó una excepción o hay más de dos instrucciones, entonces la excepción puede ocurrir un lugar después de que se escriba un valor debajo de ESP. Cuando se llama al controlador de excepciones, puede sobrescribir el valor y luego devolver EXCEPTION_CONTINUE_EXECUTION. Pero cuando se reanuda la ejecución, se supone que el valor escrito todavía está allí, pero ya no está. Esta es una situación en la que no es seguro escribir debajo de ESP. Esto se aplica incluso si todas las instrucciones se colocan consecutivamente. Gracias a @RaymondChen por señalar esto.

En general, si las dos instrucciones no se colocan consecutivamente, si está escribiendo en ubicaciones más allá de ESP, no hay garantía de que los valores escritos no se corrompan ni se sobrescriban. Un caso en el que puedo pensar dónde podría suceder esto es el manejo estructurado de excepciones (SEH). Si se produce una excepción definida por hardware (como dividir entre cero), se invocará el controlador de excepciones del kernel (KiUserExceptionDispatcher) en modo kernel, que invocará el lado del controlador en modo de usuario ({{X2} }). Al cambiar del modo usuario al modo kernel y luego volver al modo usuario, cualquier valor que esté en ESP se guardará y restaurará. Sin embargo, el controlador de modo de usuario en sí usa la pila de modo de usuario e iterará sobre una lista registrada de controladores de excepción, cada uno de los cuales usa la pila de modo de usuario. Estas funciones modificarán ESP según sea necesario. Esto puede llevar a perder los valores que ha escrito más allá de ESP. Una situación similar ocurre cuando se usan excepciones definidas por software (throw en VC ++).

Creo que puede lidiar con esto registrando su propio controlador de excepciones antes que cualquier otro controlador de excepciones (para que se llame primero). Cuando se llama a su controlador, puede guardar sus datos más allá de ESP en otro lugar. Más tarde, durante el desenrollado, obtiene la limpieza oportunidad de restaurar sus datos en la misma ubicación (o cualquier otra ubicación) en la pila.

También debe tener cuidado con las llamadas a procedimientos asincrónicos (APC) y las devoluciones de llamadas.

3
Hadi Brais 11 sep. 2018 a las 17:27

En el caso general (plataforma x86 / x64 ): la interrupción se puede ejecutar en cualquier momento, lo que sobrescribe la memoria debajo del puntero de la pila (si se ejecuta en la pila actual). porque esto, incluso temporal, guarda algo debajo del puntero de la pila, no es válido en el modo kernel: la interrupción usará la pila kernel actual. pero en la situación del modo de usuario, otra: la tabla de interrupción de compilación de Windows (IDT) de tal manera que cuando se genera una interrupción, siempre se ejecutará en modo de kernel y en la pila de kernel. como resultado, la pila del modo de usuario (debajo del puntero de la pila) no se verá afectada. y posible uso temporal de algún espacio de pila debajo del puntero, hasta que no realice ninguna llamada a funciones. si la excepción será (digamos por dirección inválida de acceso) - también se sobrescribirá el puntero de la pila de espacio debajo de la pila - la excepción de la CPU, por supuesto, comienza a ejecutarse en modo kernel y pila kernel, pero entonces el kernel ejecuta la devolución de llamada en el espacio del usuario a través de ntdll.KiDispatchExecption ya activado espacio de pila actual. así que, en general, esto es válido en el modo de usuario de Windows (en la implementación actual), pero necesita comprender bien lo que está haciendo. Sin embargo, esto es muy raro, creo que se utiliza


Por supuesto, lo correcto que se observa en los comentarios que podemos, en el modo de usuario de Windows , escribir debajo del puntero de la pila, es solo el comportamiento de implementación actual. Esto no está documentado ni garantizado.

Pero esto es muy fundamental: es poco probable que se modifique: las interrupciones siempre se ejecutarán solo en modo de núcleo privilegiado. y el modo kernel se usará solo en la pila del modo kernel. el contexto del modo de usuario no es confiable en absoluto. ¿Qué será si el programa de modo de usuario establece un puntero de pila incorrecto? decir por mov rsp,1 o mov esp,1? y justo después de esta instrucción, se levantará la interrupción. ¿Qué será si comienza a ejecutarse en un esp / rsp no válido? Todo el sistema operativo acaba de estrellarse. exactamente porque esta interrupción se ejecutará solo en la pila del kernel. y no sobrescribir el espacio de pila del usuario.

También es necesario tener en cuenta que la pila es un espacio limitado (incluso en modo de usuario), acceda debajo de 1 página (4Kb) ya es un error (es necesario hacer una prueba de pila página por página, para mover la página de protección hacia abajo).

Y, finalmente, realmente no hay necesidad de acceder [ESP-4], EAX, ¿en qué problema disminuyo ESP primero? incluso si necesitamos acceso al espacio de la pila en el bucle gran cantidad de tiempo: el puntero de disminución de la pila solo necesita una vez: 1 instrucción adicional (no en el bucle), nada cambia en el rendimiento o el tamaño del código.

Así que a pesar de ser formal, este será el trabajo correcto en el modo de usuario de Windows, mejor (y no necesario) usar esto


Por supuesto la documentación formal dice:

Uso de la pila

Toda la memoria más allá de la dirección actual de RSP se considera volátil.

Pero esto es para casos comunes, incluido el modo kernel también. Escribí sobre el modo de usuario y basado en la implementación actual


Es posible en ventanas futuras y agregue apc "directo" o algunas señales "directas": parte del código se ejecutará mediante devolución de llamada justo después de que el hilo ingrese al kernel (durante la interrupción habitual del hardware). después de esto, todo lo que se muestra a continuación será indefinido. pero hasta que esto no exista. hasta que este código funcione siempre (en las versiones actuales) correcto.

7
RbMm 10 sep. 2018 a las 22:27