Escribiendo binarios en lenguaje Ensamblador
- Distribución: Debian 13
- Arquitectura: x86_64
- Sintaxis: Intel
- Herramientas/Comandos usados: nasm, ld, gcc, man, cat, auditd, xxd, file
Bienvenidos, mis hackers, a una nueva publicación en la que hablaré sobre cómo escribir nuestro primer programa en lenguaje Ensamblador. Tras un tiempo informándome acerca de este lenguaje de programación, viéndolo en CTFs, payloads, aplicaciones (mientras realizaba ingeniería inversa), y en volcados en formato hexadecimal, he pensado que sería una buena idea que todo hacker que se precie aprendiera a escribir en Ensamblador para poder hacer sus propios shellcodes, por ejemplo, para poder explotar vulnerabilidades como "Buffer Overflow."
Aprender lenguaje ensamblador no es una tarea complicada. Tan solo debes saber para qué sirven los registros, los mnemonics (instrucciones en lenguaje ensamblador como "mov" o "inc"), y las secciones. Hay más elementos importantes a la hora de programar en ensamblador, como por ejemplo las banderas (flags), es un concepto más avanzado que no cubriré en este post.
Segmentos / Secciones de memoria
Las secciones de memoria ya las expliqué en el post "Segmentación de memoria - Hacking: The Art Of Exploitation", por lo que lo explicaré muy por encima para que se entienda lo esencial sobre este tema para quienes no hayan leído el post anterior.
- .data: Contiene variables inicializadas.
- .bss: Contiene variables no inicializadas.
- .text: Contiene el código ensamblador que ejecutará la CPU.
Comentarios
En Ensamblador (y cualquier otro lenguaje de programación) es una buena práctica el insertar comentarios en el código que explique qué hace cada trozo del código escrito, para poder hacerlo más legible y entendible para el desarrollador. Los comantarios pueden usarse en ensamblador utilizando el símbolo ;. A continuación dejaré un ejemplo de esto mismo.
- El símbolo
;sirve para escribir un comentario de una sola línea.
; Increments the value of "rax" by 1
inc raxSi se quisieran escribir varias lineas de comentarios, podríamos usar varias veces ; en distintas líneas:
; Increments the value of "rax" by 1
; Then it decrements the value of "rax" by 1
inc rax
dec raxRegistros
Los registros son las unidades de almacenamiento más pequeñas y más rápidas que existen en la CPU. El uso que se les da en ensamblador, comúnmente, es para almacenar datos que se usará en el código ensamblador para hacer operaciones con estos. Vistos así, son similares a las variables en un lenguaje de programación de alto nivel, aunque estos registros son limitados, al contrario que las variables que se pueden crear tantas como se deseen en lenguajes como C o Python. Los registros los expliqué en el post "Apuntes de lenguaje ensamblador (Assembly)", aunque recomiendo la lectura de este post que acabo de mencionar, dejaré por aquí la misma breve explicación que usé en el post anterior para aquellos que no quieran leerlo.
rax -> Acumulador (aritmética general)
rbx -> Registro base (propósito general)
rcx -> Contador (Usado en bucles y shifts)
rdx -> Datos, para entrada/salida o multiplicaciones/divisiones
rsi -> Índice de fuente (copiar cadenas de caracteres/memoria, usado junto con "edi")
rdi -> Índice de destino (propósito general)
rsp -> Puntero de pila (límite superior de la pila)
rbp -> Puntero base (stack frame reference)
rip -> Puntero de instrucción (Dicta qué ejecuta la CPU, es decir, es un puntero a la siguiente instrucción que será ejecutada. Muy importante para hackear)
r8 -> Propósito general (Utilizado para argumentos de funciones)
r9 -> Propósito general (Utilizado para argumentos de funciones)
r10 -> Propósito general (Registro volátil)
r11 -> Propósito general (Regitro volátil)
r12 -> Propósito general (Registro no volátil que almacena datos entre funciones)
r13 -> Propósito general (Registro no volátil que almacena datos entre funciones)
r14 -> Propósito general (Registro no volátil que almacena datos entre funciones)
r15 -> Propósito general (Registro no volátil que almacena datos entre funciones)Sin embargo, esta definición no aplica cuando hablamos acerca de las syscalls (llamadas al sistema).
Registros aplicados a shellcodes
Los registros tienen distintos roles dependiendo del contexto en el que se empleen. En el caso de las syscalls, cada registro tiene un rol específico que se explica a continuación.
rax -> Función/Syscall a ejecutar (tras la ejecución contendrá el resultado)
rdi -> Primer argumento
rsi -> Segundo argumento
rdx -> Tercer argumento
r10 -> Cuarto argumento
r8 -> Quinto argumento
r9 -> Sexto argumentoCuando se quiere llamar a una syscall, se debe especificar en cada registro cual y con qué argumentos se va a ejecutar. Esto se explica a continuación.
Syscalls
Las syscalls o "llamadas al sistema" se tratan de interfaces entre la aplicación y el kernel que permite a la aplicación realizar ciertas acciones en el sistema que de otra manera, no podría. Podemos pensar en esto de una manera similar a ejecutar funciones en una aplicación como C. Las funciones (a veces) requieren argumentos para poder ejecutarse, y las syscalls también requieren de estos mismos argumentos. Por ejemplo, si quisiera guardar información en un archivo, tendría que llamar a la función, y especificar en los Uargumentos, por ejemplo, qué información quiero guardar y en qué archivo. Para llamar a una función en C requeriríamos una línea como esta:
execve("/bin/sh", NULL, NULL)En este caso, "execve" sería la función a ejecutar mientras que "/bin/sh", "NULL" y "NULL" son los argumentos que se pasan a la función. Podemos ver más información sobre esta función usando el comando "man execve".
NAME
execve - execute program
LIBRARY
Standard C library (libc, -lc)
SYNOPSIS
#include <unistd.h>
int execve(const char *pathname, char *const _Nullable argv[],
char *const _Nullable envp[]);
DESCRIPTION
...Es posible hallar más información sobre las syscalls en GNU/Linux usando el comando man syscalls
Mientras que en "C" para poder llamar a una función necesitas especificar su nombre, en "Ensamblador" es necesario especificar su número. Cada syscall posee un número identificativo para referirse a ella, de modo que en ensamblador no se llama a las syscalls a través de números que las identifican. Podemos ver cual número hace referencia a cada syscall usando el comando ausyscall (que se puede usar instalando el paquete auditd) del siguiente modo.
❱❱ ausyscall --dump
Using x86_64 syscall table:
0 read
1 write
2 open
3 close
4 stat
5 fstat
6 lstat
...O también consultando el fichero /usr/include/x86_64-linux-gnu/asm/unistd_64.h.
❱❱ cat /usr/include/x86_64-linux-gnu/asm/unistd_64.h
#ifndef _ASM_UNISTD_64_H
#define _ASM_UNISTD_64_H
#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3
#define __NR_stat 4
...
#define __NR_execve 59
#define __NR_exit 60
...Como podemos ver, el número al que hace referencia "execve" es el 59, por lo que para poder ejecutar la syscall, tenemos que hacer que el registro rax contenga el número 59. Por si solo, si especificamos que queremos ejecutar la función "execve" con una syscall, vamos a provocar un error, ya que es obligatorio que especifiquemos qué información contendrán sus otros tres parámetros.
Parámetros en syscalls
En el caso de querer replicar la línea que vimos anteriormente llamando a la función (execve("/bin/sh", NULL, NULL)), deberíamos especificar en el primer argumento la cadena de caracteres "/bin/sh", y en el segundo y tercer argumento una cadena de caracteres que tan sólo posea un caracter nulo.
Pero, ¿qué es un caracter nulo? Un Null Character o Null Pointer (según el contexto, es lo mismo), sería el equivalente en hexadecimal a 0x0, que al ser un valor "vacío" (es decir, que no contiene información), se considera nulo. Podemos limpiar los valores que contengan los registros de diferentes maneras. Una de las formas de poner un caracter nulo en un registro sería con la instrucción:
mov rax, 0Sin embargo, una manera un poco más correcta sería realizar una operación xor sobre el registro tal y como se describe a continuación.
xor rax, raxLas operaciones XOR convierten en 0 los valores iguales, por lo que si especificamos que queremos hacer una operación xor siendo las dos entradas el mismo registro, todo el registro se elimina y se queda en 0.
Y ahora, ¿cómo podemos especificar que queremos que la cadena en el primer argumento sea "/bin/sh"? Sencillo, tan solo tenemos que convertir esta cadena de caracteres en hexadecimal. Para ello, podemos usar los comandos xxd y printf del siguiente modo.
❱❱ printf "/bin/sh" | xxd -ps
2f62696e2f7368¿Por qué usamos printf? Pues por que este comando es similar a echo, pero no añade el byte New-Line (Línea nueva), de modo que no añade bytes extra. A esto me refiero:
❱❱ printf "/bin/sh" | xxd
00000000: 2f62 696e 2f73 68 /bin/sh
❱❱ echo "/bin/sh" | xxd
00000000: 2f62 696e 2f73 680a /bin/sh.Usando echo, podemos ver que se le añade el byte 0x0a, que es el que hace que el salto de línea. Como tan solo queremos la cadena de caracteres /bin/sh, usaremos printf para este cometido. Ahora que ya tenemos la cadena de caracteres que queremos transformada en hexadecimal, falta añadirle un 0x delante de la misma para indicarle a nuestro programa escrito en Ensamblador que vamos a utilizar un conjunto de bytes para representar la cadena. Quedaría del siguiente modo.
0x2f62696e2f7368Una vez tenemos la cadena de caracteres y los valores que queremos establecer en los otros dos argumentos (en este caso son 0, por que no queremos especificar ningún valor como argumento, es decir, queremos que sean nulos), deberemos comenzar a programar nuestro binario.
Estructura de la sintaxis de "nasm"
Dependiendo del ensamblador que elijamos, deberemos usar un tipo de sintaxis a la hora de escribir nuestro código u otro. Los ensambladores se encargan de transformar un fichero lleno de código ensamblador en un fichero binario con código ensamblador a nivel binario, es decir, tal y como el nombre indica, "ensambla" el código que hemos escrito para crear un programa funcional. En mi caso, elegí "nasm" como ensamblador por la facilidad de su sintaxis, su capacidad que tiene para ensamblar binarios a nivel multi-plataforma, y sobre todo, la cantidad tan exagerada de documentación que ofrece.
Para poder crear binarios en ensamblador, no basta solo con poner el código. Necesitamos especificar las secciones y las funciones que tendrá el binario. Esto se puede hacer con las palabras clave section y global.
section: Especifica qué sección se va a crear y qué información guardará dicha secciónglobal: Directiva que permite que la función declarada con esta misma directiva sea accesible desde otros archivos (es decir, es una directiva que permite exportar la función de un fichero.asma otros ficheros.asm).
Cuando queremos declarar una función, debemos incluírla en la sección .text, así como las variables inicializadas irían en la sección .data y las variables sin inicializar irían en la sección .bss. A continuación dejo un ejemplo sobre cómo crear una función en nasm.
section .text
global _start
_start:
<código_ensamblador>La línea clave section .text indica que lo que viene a continuación se guardará en la sección .text. En este caso, lo que se guardará será el código en ensamblador de la función _start. La directiva global no es necesaria ponerla en el contexto de esta guía, pero yo la considero una buena práctica acostumbrarse a usar directivas para cuando se desee crear un código más amplio involucrando varios ficheros. Para el propósito de este post, podríamos almacenar la cadena que vimos anteriormente (/bin/sh) en una variable para evitar tener que traducirla a hexadecimal y dejar que nasm haga el trabajo duro. Esto podríamos hacerlo añadiendo las siguientes líneas al código:
section .data
string db "/bin/sh"Que lo que haría sería crear una variable llamada string que contiene /bin/sh. Esto tiene muchos beneficios, pero como en un futuro quiero enseñar a escribir shellcode en ensamblador (y al crear shellcode no podemos usar variables), prefiero omitir este punto.
Programando en ensamblador
Finalmente, podemos mancharnos las manos escribiendo un código muy sencillo en ensamblador. Lo que haré será crear un programa sencillo llamado create_shell que lo único que hace es crear una shell. Para ello, tendremos que usar dos syscalls, la de execve para poder ejecutar la shell, y exit para detener la ejecución del programa de manera grácil. Para ejecutar la syscall de execve, necesitaremos las siguientes instrucciones.
mov rbx, 0x68732f6e69622f
push rbx
push rsp
pop rdi
xor rsi, rsi
xor rdx, rdx
mov al, 59
syscallA continuación dejo las operaciones que realizan las siguientes líneas.
mov rbx, 0x68732f6e69622f: Insertamos en el registrorbxla cadena/bin/sh.push rbx: Insertamos la cadena en el stack para usarla en las próximas instrucciones.push rsp: Insertamos en el stack la dirección de memoria que contiene la cadena/bin/sh.pop rdi: Sacamos del stack la dirección de memoria que contiene la cadena deseada y guardamos dicha dirección en el registrordipara usarlo como primer argumento en la syscall.xor rsi, rsi: Convertimos el valor (cualquiera que pueda tener) del registrorsia 0. Este será el segundo argumento.xor rdx, rdx: Convertimos el valor (cualquiera que pueda tener) del registrordia 0. Este será el tercer argumento.mov al, 59: Pasamos al primer byte del registroraxel número59. Este es el código de la syscallexecveque hará que cuando ejecutemos la instrucciónsyscall, se ejecutará la shell.syscall: Se ejecuta la syscall y aparece la shell.
Una vez ya tenemos la shell, ya hemos cumplido el cometido del programa. Ahora, queremos salir de este. Para ello, deberemos de ejecutar la syscall exit usando las siguientes instrucciones.
mov al, 60
xor rdi, rdi
syscallmov al, 60: Hacemos que el código de la syscall que vayamos a ejecutar sea el60, que pertenece a la syscallexit.xor rdi, rdi: El seguno parámetro especifica el código de salida del programa. Como quiero que sea0, elimino el valor que tenía anteriormente el registrordi(que era/bin/sh) y lo dejo vacío (es decir, con el valor0).syscall: Se ejecuta la syscall y el programa termina con un código de salida de0.
El código completo del programa sería el siguiente.
section .text
global _start
_start:
mov rbx, 0x68732f6e69622f
push rbx
push rsp
pop rdi
xor rsi, rsi
xor rdx, rdx
mov al, 59
syscall
mov al, 60
xor rdi, rdi
syscallCompilando el binario
Para compilar el binario, usaremos nasm (el ensamblador del que hablamos antes), y ld, que se trata de un enlazador, concretamente el enlazador de GNU. Un enlazador tiene como propósito enlazar las referencias de símbolos y relocalizar los datos que contiene el fichero objeto generado/s (puedenn ser uno o varios según la complejidad y necesidades del binario) por nasm para crear un binario ejecutable. Es decir, es el encargado de enlazar (por ejemplo) funciones de uno o varios ficheros .asm convertidos en ficheros objeto (.o) para poder generar el archivo ejecutable (el fichero ELF final).
Para poder ensamblar el archivo .asm en un fichero objeto de formato ELF, debemos usar el siguiente comando.
nasm -f elf64 <fichero.asm> -o <fichero.o>Teniendo en cuenta que el fichero que contiene el código ensamblador se llama create_shell.asm, el comando sería el siguiente.
❱❱ nasm -f elf64 create_shell.asm -o create_shell.oY una vez tenemos el fichero objeto (.o), debemos enlazarlo para obtener el binario ejecutable del siguiente modo.
ld <fichero_objeto.o> -o <ejecutable>Y finalmente, este sería el resultado.
❱❱ nasm -f elf64 create_shell.o -o create_shellEjecutando el binario
Una vez tenemos el binario, podemos comprobar que efectivamente es un fichero de tipo ELF 64 usando el comando file.
❱❱ file ./create_shell
./create_shell: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not strippedY finalmente, podemos probar a ejecutar el binario para comprobar que, efectivamente, crea una shell interactiva gracias a la syscall execve.
❱❱ ./create_shell
$ whoami
usuario
$ Referencias






