En este post se presentan los conceptos básicos del lenguaje de programación Assembly, y la utilización de un Debugger (y otras herramientas) para analizar el funcionamiento de los programas sobre el stack.

También, es la continuación del post Conceptos básicos de arquitectura de procesadores x86.

Assembly

Assembly es un lenguaje de programación de muy bajo nivel, el cual, consiste en un código mnemotécnico, también conocido como opcode (operation code).

Al igual que muchos lenguajes de programación, este debe ser convertido en código de máquina para ser ejecutado.

Para poder lograr esto se usa assembler.

Assembler

Este es un programa que traduce el lenguaje Assembly a código máquina.

Existen varios tipos de assembler y este dependerá del ISA del sistema, como por ejemplo:

  • MASM (Microsoft Macro Assembler): usado en arquitectura x86, el que utiliza sintaxis Intel para MS-DOS y Microsoft Windows.
  • GAS (GNU Assembler): usado en el proyecto GNU, y es el back-end por defecto de GCC.
  • NASM (Netwide Assembler): usado en arquitectura x86 para escribir programas de 16, 32 (IA-32) y 64 bits (x86-64).
  • FASM (Flat Assembler): usado en arquitectura x86, y soporta el lenguaje assembly en estilo Intel en IA-32 y x86-64.
NOTA: dentro de los más populares se encuentra NASM y MASM. En este post se mostrará el uso básico de NASM.

Cuando el archivo de código es ensamblado, el archivo que se crea es llamado archivo objeto (object file). Este es la representación binaria de un programa.

Ahora que tenemos el archivo object, se debe utilizar un linker para poder generar un archivo ejecutable. Para lograr esto, el linker utiliza uno o más archivos object (como kernel32.dll o user32.dll) para combinarlos y crear el ejecutable.

NOTA: los archivos kerne32.dll y user32.dll son requeridos en Windows para crear un ejecutable que necesita acceder a ciertas librerías.

El proceso para crear el archivo ejecutable es el siguiente:

NASM

Instalación

Para este caso, se instalará NASM-X en Windows.

El proyecto NASM-X es una colección de macros, includes y ejemplos que ayudan a desarrollar en NASM software de 32 y 64-bits para BSD, Linux, Windows y XBOX en una fracción inferior al tiempo normal.

Al momento de descargar NASM-X, extraemos el contenido y lo guardamos en el directorio C:\nasmx (para este ejemplo y los próximos, trabajaré con un Windows XP, pero puede ser replicado en versiones más actuales):

Luego, agregamos la ruta de los binarios de NASM-X a las variables de entorno:

Después de configurar las variables de entorno, validamos que quedó bien instalado. Para esto, abrimos un nuevo terminal, vamos a la carpeta de NASM-X, y corremos el archivo setpaths.bat, el cual, nos mostrará el mensaje "NASMX Development Toolkit" en caso de que lo anterior se encuentre bien configurado:

Ahora, para poder trabajar con las demos que este trae, debemos editar un componente del archivo windemos.inc que se encuentra en C:\nasmx\demos. En este debemos comentar la linea %include 'nasmx.inc' usando el símbolo ;, y agregar la linea %include 'C:\nasmx\inc\nasmx.inc':

Si queremos comprobar que todo se encuentra bien configurado, se puede ensamblar el siguiente demo:

  1. Ir a C:\nasmx\demos\win32\DEMO1
  2. Ensamblamos el archivo demo1.asm, el cual, abre una ventana con un mensaje. Para lograr esto, usamos el comando nasm -f win32 demo1.asm -o demo1.obj para obtener el archivo object:
    • -f especifica el formato del archivo, donde, en Windows puede ser obj, win32 y win64. En Unix se usan los siguientes formatos: aout, as86, coff, elf32, elf64, elfx32, ieee, macho32 y macho64
    • Esto es seguido del nombre del archivo
    • -o indica el nombre del archivo de salida
  1. Ahora que tenemos el archivo object, debemos utilizar el linker para poder generar el archivo ejecutable. El comando para esto es GoLink.exe /entry _main demo1.obj kernel32.dll user32.dll
    • /entry _main especifica que el programa iniciará en la etiqueta de código main:
    • kernel32.dll permite realizar una salida del proceso (ExitProcess)
    • user32.dll permite mostrar una ventana emergente con un mensaje (MessageBox)
  1. Si ejecutamos demo1.exe veremos que aparace una ventana con el mensaje Hello from the Procedure!:

ASM Básico

En esta sección no se enseñará a escribir programas en assembly, sólo se mostrarán instrucciones básicas para poder entender el funcionamiento de los programas al momento de verlos en un Debugger.

Dentro de las instrucciones que hablaremos se encuentran las siguientes, las que se dividen en clases:

  • Transferencia de datos:
    • MOV mueve (copia) los datos de una sección de memoria a otra
    • MOVSX mueve con extensión de signo
    • MOVZX mueve con extensión de cero
    • XCHG intercambia datos
    • PUSH agregar datos al stack
    • PUSHAD agrega todos los registros de 32 bits en el stack
    • POP sacar datos al stack
    • POPAD saca todos los registros de 32 bits en el stack
  • Aritméticos:
    • ADD permite realizar sumas
    • SUB corresponde a la resta
    • MUL realiza multiplicación sin signo
    • XOR operador lógico XOR
    • NOT operador lógico NOT
    • DEC resta 1
    • INC incrementa 1
  • Control del flujo:
    • CALL llamada a un procedimiento
    • RET retorno desde un procedimiento
    • LOOP control de loops
    • Jcc realiza un salto en caso de una condición
  • Otros:
    • STI configura un flag de interrupción
    • CLI elimina el flag de interrupción
    • IN entrada desde un puerto
    • OUT salida desde un puerto

Ejemplo de la suma de dos números:

MOV EAX,2    ; almacena 2 en EAX
MOV EBX,5    ; almacena 5 en EBX
ADD EAX,EBX  ; equivale a la siguiente operación: EAX = EAX + EBX

Sabemos que PUSH almacena datos en la parte superior del stack, ajustando el stack en -4-bytes (-32 bits o -0x04). Esto se puede representar de la siguiente forma en assembly (el siguiente ejemplo es lo mismo que usar la instrucción PUSH):

SUB ESP,4               ; ESP = ESP-4
MOV [ESP],0x12345678    ; almacene el valor 0x12345678 en la ubicación apuntada por ESP
                        ;Los corchetes indican la dirección señalada por el registro. 

Ahora si queremos hacer la instrucción inversa (POP), puede ser representada de la siguiente forma:

MOV EAX,[ESP]    ; almance el valor del puntero ESP en EAX
ADD ESP,4        ; agrega 4 a ESP, lo cual, ajusta el valor
                 ; de la parte superior del stack

Otras instrucciones que nos serán útiles de entender son CALL y RET.

Las subrutinas son implementadas usando las instrucciones CALL y RET. CALL empuja el puntero de instrucción actual (EIP) al stack y salta a la dirección de la función especificada. Mientras que, cada vez que la función ejecuta la instrucción RET, el último elemento se saca del stack y la CPU salta a la dirección.

Ejemplo de CALL:

MOV EAX,1       ; almacena 1 en EAX
MOV EBX,2       ; almacena 2 en EBX
CALL ADD_sub    ; llama la subrutina ADD_sub
INC EAX         ; incrementa el valor de EAX, quedando en 4
                ; esto se debe a que al llamar a ADD_sub, se hizo la suma
                ; EBX+EAX, quedando EAX en 3, y ahora con INC
                ; se incrementa en 1
JMP end_sample  ; salta hasta end_sample
ADD_sub:
ADD EAX,EBX     ; EAX = EAX + EBX
RETN            ; en este punto termina la función, por lo tanto, retorna
                ; a la función que lo llamo
end_sample:

Instrucciones de assembly que nos serán útiles (estas instrucciones son parte del curso de Exploiting Básico):

  • Acceso a la memoria []:
    • mov edi,[ecx] accesos a memoria por registro
    • mov edi,[ecx+eax] accesos a memoria por desplazamiento
    • mov edi,[ecx+esi+10h] accesos a memoria por índice y desplazamiento
  • Movimientos de datos MOV:
    • mov eax,16h valor inmediato a registro
    • mov eax,edx registro a registro
    • mov [04030201h],40h inmediato a memoria
    • mov [04030201h],edx registro a memoria y memoria a registro
    • movsx eax,ebx mueve el valor firmado a un registro y lo extiende con 1
    • movzx eax,ebx mueve un valor sin signo a un registro y lo extiende a cero
  • Instrucciones matemáticas:
    • dec eax resta 1 al valor de eax
    • inc eax suma 1 al valor de eax
    • inc dword ptr[04030201h] incrementa el contenido de posición de memoria
    • inc word ptr[04030201h] incrementa los últimos 2 bytes
    • inc byte ptr[04030201h] incrementa el último byte
    • add eax,ecx suma operandos y guarda el resultado en el primero
    • adc ebx,5 se suman ambos operandos y se suma el valor de Carry F
    • sub esi,3 resta el segundo operando al primero
    • sbb esi,2 se restan ambos operandos y se resta el valor de Carry F
  • Movimiento de datos LEA:
    • lea ecx,[ebp + 10h] deja en ecx, el valor de tenga ebp + 10h
    • lea ebx,[edx * 8h] deja en ebx, el valor que tenga edx * 8h
    • lea esi,[eax + ebx + 20h] deja en esi, la suma eax+ebx+20h
    • lea ecx,[0x04030201 + 2h] deja en ecx, el valor de 0x04030204
  • Movimiento de datos por tipo:
    • mov al,byte ptr[edx] mueve a al un byte al = 20h de 0x04020120
    • mov ax,word ptr[edx] mueve a al un byte ax = 0120h de 0x04020120
    • mov eax,dword ptr[edx] mueve a al un byte eax = 0x04020120
  • Obteniendo datos del stack:
    • push eax envía el registro al stack
    • pop edx registro edx toma el valor de eax desde el stack
    • pushad guarda el contenido de los registros y lo ordena en stack
    • popad toma los valores del stack y los envía a los registros

Intel vs AT&T

Al momento de trabajar con assembly, es necesario saber que existen dos arquitecturas de su sintaxis: Intel y AT&T.

Se pueden diferenciar porque Intel es la sintaxis por defecto de los entornos Windows. Mientras que AT&T es la sintaxis por defecto para entornos Linux:

  • Sintaxis Intel: <instrucción><destino><origen> (ejemplo: MOV EAX,8)
  • Sintaxis AT&T: <instrucción><origen><destino> (ejemplo: MOVL $8,%EAX)

Como se ve en el ejemplo anterior, en AT&T se utiliza el símbolo % antes del nombre de un registro, mientras que se usa el símbolo $ antes de un número.

Otra consideración al momento de usar AT&T, es que  se le agrega una letra a la instrucción dependiendo del tamaño del operando:

  • Q (Quad) para 64-bits
  • L (Long) para 32-bits
  • W (Word) para 16-bits
  • B (Byte) para 8-bits

Debugger

Los debugger son programas que corren otros programas, con la finalidad de poder tomar control sobre ellos y poder testearlos o encontrar fallos en estos.

En este caso, se utilizarán con la finalidad de ayudar en la escritura de exploits, analizar el programa, realizar ingeniería inversa a los binarios, entre otros.

Por lo tanto, este nos permitirá:

  • Detener el programa mientras se está ejecutando
  • Analizar el stack y sus datos
  • Inspeccionar los registros
  • Cambiar el programa o variables de este

Los debugger que podemos mencionar son:

Immunity Debugger

A continuación, se presenta la GUI de este debugger, el cual, se encuentra dividido en 4 paneles:

  1. Control de ejecución: permite reiniciar, cerrar, iniciar, pausar, ver cada instrucción, saltar entre instrucciones, volver a las instrucciones anteriores, ejecutar hasta un return, y permite que el disassembler sea navegado a una dirección de memoria en particular.
  2. Panel de disassembler
    • Columna 1: direcciones
    • Columna 2: código máquina
    • Columna 3: assembly
    • Columna 4: comentarios del debugger
  3. Panel de registros
    • Nombres de los registros
    • El contenido de estos
    • En caso de que estos apunten a una cadena ASCII, se mostrará dicho valor
  4. Dump de la memoria: muestra el contenido del espacio de memoria del proceso como el dump de un binario. Es útil para examinar regiones de memoria.
  5. Stack
    • Columna 1: corresponde a la dirección
    • Columna 2: es el valor en el stack en esa dirección
    • Columna 3: es una explicación del contenido (una dirección, un UNICODE, etc.)
    • Columna 4: comentarios del debugger
  6. Input de comandos: permite ejecutar plugins.
  7. Estado: muestra los mensajes de estado.
  8. Estado del proceso: muestra si el proceso se encuentra corriendo o si está pausado.

Ejemplo del uso del debugger para analizar un programa

Al momento de usar Immunity Debugger, tenemos 3 formas de cargar programas en este:

  • La primera es arrastrando el programa sobre el debugger
  • La segunda opción es cargarla desde la interfaz del debugger
  • La tercera forma es adjuntando el proceso del programa

Para utilizar la segunda opción, debemos ir al icono de directorio y luego seleccionar el programa a ejecutar:

Para adjuntar el proceso del programa, se debe ir a File > Attach:

Esto nos abrirá una ventana con los procesos corriendo en el sistema, donde, el programa que necesitemos ver en el debugger debe ser seleccionado y luego dar clic en Attach:

Para controlar el flujo del programa dentro del debugger, tenemos el panel de control de ejecución:

De este panel, por lo general se usan las primeras 6 instrucciones (de izquierda a derecha):

  • Restart: reinicia el programa.
  • Close: cierra el programa.
  • Run: inicia el programa.
  • Pause: pausa el programa.
  • Step into: recorre las instrucciones del programa, donde, si hay un CALL o un RETN, este va a dichas direcciones de memoria.
  • Step over: similar a Step into, pero en vez de ir a las direcciones de memoria de las instrucciones CALL y RETN, sigue de forma lineal las instrucciones del programa.

Las ventajas que nos entrega un debugger, es que podemos definir breakpoints; los que son usados para detener el programa en una dirección de memoria específica para realizar un análisis del comportamiento de este. Para configurar o eliminar un breakpoint, se debe seleccionar la dirección de memoria y presionar la tecla F2; esto dejará en color Cian dicha dirección:

Compilador

Varios lenguajes de programación de alto nivel (como es el caso de C y C++) necesitan ser compilados para poder crear un archivo de bajo nivel, y poder ser ejecutados.

Al igual que los debugger, existen múltiples opciones al momento de elegir un compilador:

Ejemplo de uso de GCC:

  • Código a compilar:
#include <stdio.h>

int main() {
	printf("Hola mundo");
	return 0;
}
  • Compilación:

Opciones usadas:

  • -m32 específica que es un entorno de 32-bits
  • -o define el nombre del archivo de salida

Decompilador

Los decompiladores son herramientas que nos permiten ver cómo funcionan los programas, con los cuales, podemos obtener el disassembler.

Para realizar esta tarea, utilizaremos el software objdump. Este se encuentra en la carpeta C:\Archivos de programa\Dev-Cpp\MinGW64\bin de Dev-C++.

Para obtener el disassembler del programa holamundo.exe, se utilizará el siguiente comando: objdump -d -Mintel holamundo.exe > holamundo.txt

  • -d indica que vamos a realizar un disassembler del archivo holamundo.exe
  • -Mintel define que el output del disassembler sea en sintaxis Intel
  • > nos permite reenviar la salida del comando al archivo holamundo.txt

Si vemos el archivo holamundo.txt, podemos observar que posee las instrucciones de assembly:

Disassembly del archivo holamundo.exe: