ENSAMBLADOR
En este artículo vamos a ver la teoría básica detrás del lenguaje ensamblador orientada a Buffer Overflow.
Artículo copiado de la Fundación Sadosky escrito por Teresa Alberto.
PRIMER PROGRAMA EN ASSEMBLER
Un programa en assembler se compone de una serie de instrucciones de máquina que al ejecutarse se almacenan en la memoria del proceso. Cada instrucción es un flujo de bytes que interpretados por el procesador modifican el estado del programa.
El código de un programa escrito en lenguaje ensamblador o assembler permite trabajar con una representación simbólica de ese flujo de bytes. Por ejemplo, la instrucción en assembler add eax, 0x1
suma 1 al contenido del registro de memoria eax
. Y add eax, 0x1
es en realidad una representación simbólica del número: 83 c0 01
(en hexadecimal) o lo que es lo mismo 1000 0011 1100 0000 0000 0001
. Este último es el contenido binario efectivamente almacenado en la memoria.
Por facilidad de lectura el contenido escrito en memoria se representará en hexadecimal y no en binario.
Registros de propósito general
Consideraciones:
Tener en cuenta que a lo largo de esta guía se trabaja con la arquitectura Intel x86.
Y que el código assembler se presenta con la sintaxis Intel. Las diferencias con la sintaxis AT&T son numerosas, una de las más importantes es que en la sintaxis Intel el registro destino se indica primero y luego el registro fuente, al revés que en AT&T.
La CPU, dependiendo de su arquitectura, tiene una cierta cantidad de registros. Los registros pueden entenderse como celdas de memoria de muy alta velocidad que es utilizada para almacenar datos o direcciones durante la ejecución de un programa (como si fueran variables globales). A través de instrucciones en assembler se les asigna un valor y sobre ellos se realizan operaciones aritméticas.
En una computadora de 32 bits, el número más grande que se puede almacenar en un registro es de 32 bits y de 64 bits para una computadora de 64.
Dentro de los denominados registros de propósito general de 32 bits están: eax
, ebx
, ecx
y edx
. (Por convención cuando los registros son de 64 bits se denominan: rax
, rbx
, rcx
y rdx
). A ellos se suman los registros esi
, edi
entre otros también considerados de propósito general de los que hablaremos más adelante.
Una instrucción puede, dado el caso, manipular sólo una porción de los 32 bits de un registro, para ello accede únicamente a esa porción de bytes utilizando nombres como: ax
, ah
y al
según el criterio que indica la imagen:
Por ejemplo, para modificar el byte menos significativo de eax
una instrucción usaría directamente al
. Como se puede ver con ello la instrucción ocupa menos espacio en memoria. (Parece un detalle pero en la escritura de exploits la cuestión del espacio se vuelve muy relevante).
Consideraciones: En el ejemplo anterior cuando se quería copiar el número 4 en
mov eax,0x4
, éste se almacenaba en formato little endian bajo la forma04 00 00 00
. Cuando nos referimos a little endian o big endian hablamos del formato en el que una computadora almacena los datos en celdas de memoria. Justamente en la arquitectura x86 los datos se almacenan en el formato little endian, es decir, el byte menos significativo se almacena en una posición de memoria menor, y así hasta el byte más significativo.
Por lo tanto, en little endian el dato
0x12345678
se almacena en memoria:El dato
ABCD
, es decir\x41\x42\x43\x44
se almacena en memoria comoDCBA
:
Registro eip
El instruction pointer register apunta a la siguiente instrucción a ser ejecutada por el programa. Cada vez que una instrucción se procesa, el procesador actualiza automáticamente este registro para que apunte a la siguiente instrucción a ser ejecutada. Para ello su valor se incrementa de acuerdo al tamaño de la instrucción (por ejemplo, la instrucción add eax, 0x1
que se almacena en memoria como 83 c0 01
, ocupa 3 bytes).
Además de los registros para el almacenamiento de datos, en un programa se utilizan áreas de memoria que pueden ser accedidas con instrucciones de accesos a memoria del tipo load/store
o con las operaciones de la pila push/pop
.
Instrucción MOV
En la arquitectura x86 mov
es la instrucción encargada de los accesos a memoria.
mov reg, [addr]
: permite copiar valores desde una dirección de memoria a un registro.mov [addr], reg
: permite copiar valores desde un registro a una dirección de memoria.
De manera similar a la dereferencia de punteros en C, un operando de una instrucción puede ser dereferenciado como puntero si está rodeado de corchetes. El uso de corchetes en las direcciones como [0x08048510]
es similar a su uso en los arrays como array[2]
. En vez de un índice se usa una dirección de memoria para localizar un valor.
Con [ ]:
mov eax, [ebx]
Al segundo operando demov
se lo trata como un puntero, se sigue esa dirección y se copia el valor almacenado en ella eneax
.
Sin [ ]:
mov eax, ebx
Al no usar corchetes, el segundo operando consiste en el contenido deebx
(no entendido ya como puntero), por lo que se lo copia directamente eneax
.
LA PILA
PUSH & POP
Las operaciones push/pop
manipulan un área de la memoria de un proceso denominada pila o stack, que responde a una estructura de datos LIFO (last in, first out: último en entrar, primero en salir) donde los elementos se almacenan con push
y se desapilan con pop
.
La pila se utiliza para almacenar: valores de registros de manera temporal, variables locales, parámetros de funciones y direcciones de retorno.
Uno de los registros especiales vinculados a la pila es el puntero de pila.
Puntero de pila (registro esp).
El stack pointer register (o extended stack pointer) apunta al tope de la pila, es decir al último elemento almacenado en ella. Cuando se almacena un nuevo valor en la pila con push
, el valor del puntero se actualiza para siempre apuntar al tope de la pila.
Cuando se desapila un dato con pop
lo apuntado por esp
se almacena en el registro indicado en la instrucción. Y el valor de esp
se actualiza con el nuevo tope de pila.
Es interesante notar que con pop ebx
el valor 0x100
se almacena en ebx
quedando la dirección 0xbffff58c
disponible para su uso. Es posible ver cómo aún permanece 0x100
en 0xbffff58c
porque todavía no lo ha sobreescrito otra instrucción que utilice la pila.
Crecimiento de la pila
En el ejemplo anterior, contraintuitivamente, al almacenar un nuevo valor en la pila con push
, el tope de la pila que estaba en 0xbffff590
pasa a estar en 0xbffff58c
. Es decir que, al agregar un elemento, la pila creció hacia las direcciones numéricas menores.
Al desapilar un elemento el proceso fue inverso, la pila decreció desde 0xbffff58c
hasta 0xbffff590
, es decir decreció hacia direcciones mayores.
Esto se debe a que la pila crece desde direcciones numéricas mayores (que son usadas primero) hacia las direcciones de memoria menores. Es decir, crece desde 0xf...fff
hacia 0x0...000
. Como la pila crece desde su base -desde la dirección más alta- hacia direcciones menores de memoria, al apilar un nuevo elemento se debe decrementar el puntero de la pila y al desapilar un elemento se debe incrementar el puntero de la pila. Por eso con push
se resta esp = esp - 4
y con pop
se suma esp = esp + 4
.
Consideraciones: Es posible pensar a las instrucciones
push
ypop
como dos instrucciones concatenadas.
Instrucciones de salto
En assembler instrucciones del tipo jump
, branch
o call
modifican el valor del contador del programa. De esta manera instrucciones como jmp
, je
, jne
, call
provocan que el programa deje de ejecutarse de manera lineal modificando el flujo de ejecución.
La instrucción jmp
es un ejemplo de un salto incondicional, es decir, siempre va a ejecutarse.
En cambio jne
(jump if not equal
o saltar si los operandos son distintos) es un salto condicional que depende del valor del flag zero. Existe un registro especial llamado registro de estado o registro eflags (rflags en 64 bits) donde cada bit almacena información de control que se modifica con las operaciones aritmético lógicas. Se compone de flags (o banderas en español) de 1 bit, como el Z o zero flag
que se setea en 1 si la operación anterior resultó en 0, por ejemplo si el resultado de una resta como sub ebx, eax
dió 0. Otros flags son S o sign flag
si el resultado de la operación anterior da negativo y O u overflow flag
si se produce un overflow.
La información del registro de estado es utilizada luego por instrucciones de salto condicional. Si el resultado de una resta es 0, eso implica que ambos operandos son iguales y que la condición de jump if equal
debe considerarse como verdadera y por lo tanto el salto debe producirse.
Por ejemplo, considerando el siguiente programa:
Una instrucción de salto condicional como je 8048340
(jump equal
) evalúa que los operandos de la última instrucción aritmético lógica sean iguales, si lo son el salto debe producirse. Es posible conocer si son iguales a partir de una resta con sub
: si se antecede esa instrucción al jump if equal
y si el resultado de esa resta da 0, es que ambos operandos son iguales (tal como indica el ejemplo); si el resultado no da 0 es que no lo son.
En el ejemplo la operación sub ebx, eax
tiene como efecto setear Z=1, porque el resultado de la resta fue 0. La instrucción jump if equal
evalúa el valor de Z
, si es 1 significa que los operandos eran iguales, por ende eip
se modifica por el nuevo valor y el salto se produce.
De esta manera, es posible evaluar saltos condicionales corroborando el estado del flag zero
.
CALL y convención del llamado a funciones
En la arquitectura x86, en el llamado a funciones la pila juega un rol fundamental. En este espacio de memoria se almacenan las variables locales de la función llamada, sus argumentos y su dirección de retorno. Justamente se habla de frame
o marco de una función al sector de la pila donde ésta almacena sus argumentos y variables locales, entre otra información.
A medida que se llaman funciones y se retorna de ellas, en la pila se crean y destruyen frames
, permaneciendo siempre en el tope de la pila el marco de la función en ejecución.
Después del llamado a las tres funciones (funcion_a, funcion_b, funcion_c
) y cuando se están ejecutando instrucciones dentro de la funcion_c
(eip
apunta al cuerpo de esa función), el layout de la pila -en una versión simplificada- es:
Por convención los parámetros de una función se encuentran disponibles en la pila y se almacenan en orden inverso: desde el último al primero, de esta manera se encontrarán disponibles en el orden correcto. (Bajo otras convenciones los parámetros se almacenan en registros).
En el ejemplo anterior, el llamado a funcion_a(arg_1, arg_2)
haría que primero se apile arg_2
y después arg_1
.
Este gráfico es una versión simplificada del layout de la pila como se verá a continuación.
Frame pointer (registro ebp)
En tiempo de compilación no es posible conocer la dirección de memoria que tendrán los argumentos y variables locales de una función al ejecutar un programa. Por eso para acceder a ellos se usa el registro especial ebp
o frame pointer (también llamado base pointer register) que apunta a una ubicación fija dentro del marco de una función, para que la dirección de variables y argumentos pueda ser accedida como offsets utilizando este registro.
Entonces en el llamado a una función sus parámetros y variables locales son accedidos como un offset de ebp
, siempre negativo en el caso de las variables locales (ebp-0x4: var local #1
, ebp-0x8: var local #2
) y siempre positivo para los argumentos (ebp+0x8: param #1
, ebp+0xc: param #2
) ya que fueron apilados con anterioridad.
Considerando el mismo programa de ejemplo anterior, cuando la ejecución se encuentra en la funcion_a
sin haber llamado todavía la funcion_b
:
En la pila se observa el stack frame de la funcion_a
después de ser llamada, con los respectivos offsets de ebp
:
Este es el layout resultante de la pila y ya no una versión simplificada, lo cual puede ser comprobado fácilmente debuggeando un programa como el del ejemplo.
Prologo de una funcion
A las instrucciones iniciales que se pueden observar tanto en main()
como en funcion_a()
se las denomina prólogo:
El prólogo de una función son las instrucciones en assembler que modifican los registros para la creación del marco de la función llamada.
push ebp
: el valor del registroebp
se modifica en cada llamado a una función (es diferente para cada frame), por eso su valor se almacena en la pila. El valor del registroebp
almacenado permite acceder a las variables y parámetros de la función llamadora. Se lo almacena en la pila porque -al llamar a una función- se modifica el valor deebp
para acceder a las variables y parámetros de la función llamada.mov ebp, esp
: se apunta el registroebp
al puntero de la pila, de esta manera se establece una dirección de referencia para el nuevo marco de la función llamada. Como inmediatamente antes se habían apilado los parámetros, estos serán accedidos;[ebp+0x8] parámetro #1
y[ebp+0xc] parámetro #2
. Como a posteriori se apilan las variables locales, éstas serán accedidas:[ebp-0x4] variable local #1
y[ebp-0x8] variable local #2
.
Resultando en el layout ya mencionado:
Epilogo de una funcion
Se denomina epílogo a las instrucciones que, al finalizar la ejecución de la función llamada, vuelven la pila al estado inicial antes del llamado a la función:
La instrucción leave
puede pensarse como dos instrucciones:
mov esp, ebp
deja de lado las variables locales de la función llamada y reestablece el tope de la pilapop ebp
actualiza el registroebp
a la base del marco de la función llamadora anterior.ret
es un retorno desde una función llamada. Desapila una dirección del tope de la pila (apuntada poresp
) y la almacena en el registroeip
para ejecutar a continuación esa instrucción. (Al desapilarla, actualiza el valor deesp
al nuevo tope de la pila)
¿Qué es la dirección de retorno?
Al igual que con los saltos condicionales e incondicionales, el llamado a una función modifica el flujo de ejecución de un programa. No obstante, a diferencia de los saltos, cuando la función llamada termina de ejecutarse el control debe retornar a la función llamadora. El punto al que se debe retornar es la instrucción exactamente posterior al llamado a la función (dentro de la función llamadora).
Considerando una versión modificada del ejemplo anterior que imprime dos mensajes:
En main()
se llama a funcion_a(1,2)
. Estando al final de la funcion_a
una vez finalizada su ejecución (es decir, ya impreso el mensaje “Mensaje en funcion_a()”), el contador del programa debe retornar a main()
y continuar con la ejecución de printf("Mensaje en main()")
.
¿Cómo se sabe dónde retornar dentro de main? Para conocer en qué punto exacto de main()
debe continuar el flujo de ejecución se usa la dirección de retorno y la instrucción call
.
Viendo el pseudo assembler (código assembler levemente modificado para que resulte más fácil su lectura) es posible identificar el llamado de funcion_a(1,2)
dentro de main()
con el desensamblador OBJDUMP.
(El significado de los flags para la compilación del programa se detalla en la sección de CREANDO UN LABORATORIO SIN MITIGACIONES).
Consideraciones: como
printf("Mensaje..." )
tiene como argumento un string fijo sin parámetros por una optimización del compilador se llama a la funciónputs()
y no aprintf()
.
Al llamar a una función con la instrucción call
, después de haber apilado los parámetros en la pila en sentido inverso, se almacena la dirección de retorno (de la instrucción siguiente a call
para saber a dónde retornar) y el valor del registro ebp
usado en el marco actual. Si observamos el código en assembler de la funcion_a
:
Punto de entrada del binario _start
Cuando trabajamos en GNU/Linux el formato de los ejecutables es ELF (Executable and Linking Format). Con readelf -h
es posible ver los campos de la cabecera del archivo, que nos dan información relevante del binario:
Esta información es útil ya que nos indica que es un binario de la arquitectura Intel x86 y nos informa el punto de entrada del programa (Entry point adress: 0x80482d0
). Como podemos ver en el código desensamblado esa exacta dirección del punto de entrada corresponde a _start
y no a main
:
Efectivamente, la entrada al programa es _start
que se encarga de llamar a funciones de inicialización y hace un llamado a __libc_start_main
que -a su vez- se encarga de hacer el call a main
con los parámetros correspondientes.
Cuando se hable del frame anterior a main()
nos referiremos de manera simplificada al frame de _start
.
REFERENCIAS
[1]. Tanenbaum, Andrew S. (2005). Structured computer organization.
[2]. Intel Corporation. (Mayo de 2011). Intel 64 and IA-32 Architectures Software Developer’s Manual: Combined volumes. Disponible en: https://software.intel.com/sites/default/files/managed/39/c5/325462-sdm-vol-1-2abcd-3abcd.pdf
[3]. Younan, Y., Piessens, F., Joosen, W. (Sin fecha). Protecting global and static variables from buffer overflow attacks. Disponible en: http://fort-knox.org/files/globstat.pdf
[4]. Aleph One. (Noviembre de 1996). Smashing the Stack for Fun and Profit. Phrack, 7. Disponible en: http://phrack.org/issues/49/14.html
[5]. Liveoverflow.com. (2018). Disponible en: http://liveoverflow.com/
Última actualización