Data Movement
Comencemos con las instrucciones de movimiento de datos, las cuales están entre las instrucciones más fundamentales en cualquier programa ensamblador. Usaremos frecuentemente instrucciones de movimiento de datos para mover datos entre direcciones, entre registros y direcciones de memoria, y para cargar datos inmediatos en registros o direcciones de memoria. Las principales instrucciones de Data Movement
son:
Instruction | Description | Example |
---|---|---|
mov |
Mover datos o cargar datos inmediatos | mov rax, 1 -> rax = 1 |
lea |
Cargar una dirección apuntando al valor | lea rax, [rsp+5] -> rax = rsp+5 |
xchg |
Intercambiar datos entre dos registros o direcciones | xchg rax, rbx -> rax = rbx, rbx = rax |
Moving Data
Usemos la instrucción mov
como las primeras instrucciones en nuestro proyecto del módulo fibonacci
. Necesitaremos cargar los valores iniciales (F0=0
y F1=1
) en rax
y rbx
, de modo que rax = 0
y rbx = 1
. Copiemos el siguiente código en un archivo fib.s
:
global _start
section .text
_start:
mov rax, 0
mov rbx, 1
Ahora, armemos este código y ejecutémoslo con gdb
para ver cómo funciona en acción la instrucción mov
:
$ ./assembler.sh fib.s -g
gef➤ b _start
Breakpoint 1 at 0x401000
gef➤ r
─────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
→ 0x401000 <_start+0> mov eax, 0x0
0x401005 <_start+5> mov ebx, 0x1
───────────────────────────────────────────────────────────────────────────────────── registers ────
$rax : 0x0
$rbx : 0x0
...SNIP...
─────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
0x401000 <_start+0> mov eax, 0x0
→ 0x401005 <_start+5> mov ebx, 0x1
───────────────────────────────────────────────────────────────────────────────────── registers ────
$rax : 0x0
$rbx : 0x1
Así, hemos cargado los valores iniciales en nuestros registros para realizar más adelante otras operaciones e instrucciones sobre ellos.
Nota: En ensamblador, mover datos no afecta al operando fuente. Por lo tanto, podemos considerar mov
como una función de copy
, en lugar de un movimiento real.
Loading Data
Podemos cargar datos inmediatos usando la instrucción mov
. Por ejemplo, podemos cargar el valor de 1
en el registro rax
usando la instrucción mov rax, 1
. Debemos recordar aquí que el tamaño de los datos cargados depende del tamaño del registro de destino
. Por ejemplo, en la instrucción anterior mov rax, 1
, dado que usamos el registro de 64 bits rax
, estará moviendo una representación de 64 bits del número 1
(es decir, 0x00000001
), lo cual no es muy eficiente.
Por esta razón, es más eficiente usar un tamaño de registro que coincida con el tamaño de nuestros datos
. Por ejemplo, obtendremos el mismo resultado que en el ejemplo anterior si usamos mov al, 1
, ya que estamos moviendo 1 byte (0x01
) en un registro de 1 byte (al
), lo cual es mucho más eficiente. Esto es evidente cuando observamos el desensamblado de ambas instrucciones en objdump
.
Tomemos el siguiente código básico en ensamblador para comparar el desensamblado de ambas instrucciones:
global _start
section .text
_start:
mov rax, 0
mov rbx, 1
mov bl, 1
Ahora ensamblémoslo y veamos su shellcode con objdump
:
nasm -f elf64 fib.s && objdump -M intel -d fib.o
...SNIP...
0000000000000000 <_start>:
0: b8 00 00 00 00 mov eax,0x0
5: bb 01 00 00 00 mov ebx,0x1
a: b3 01 mov bl,0x1
Podemos ver que el shellcode de la primera instrucción es más del doble del tamaño de la última instrucción.
Este entendimiento será muy útil al escribir shellcodes.
Modifiquemos nuestro código para usar subregistros y hacerlo más eficiente:
global _start
section .text
_start:
mov al, 0
mov bl, 1
La instrucción xchg
intercambiará los datos entre los dos registros. Intenta agregar xchg rax, rbx
al final del código, ensamblarlo y luego ejecutarlo a través de gdb
para ver cómo funciona.
Address Pointers
Otro concepto crítico para entender es el uso de punteros. En muchos casos, veremos que el registro o la dirección que estamos utilizando no contiene inmediatamente el valor final, sino que contiene otra dirección que apunta al valor final. Esto es siempre el caso con registros pointer, como rsp
, rbp
y rip
, pero también se utiliza con cualquier otro registro o dirección de memoria.
Por ejemplo, ensamblamos y ejecutamos gdb
en nuestro binario ensamblado fib
, y verificamos los registros rsp
y rip
:
gdb -q ./fib
gef➤ b _start
Breakpoint 1 at 0x401000
gef➤ r
...SNIP...
$rsp : 0x00007fffffffe490 → 0x0000000000000001
$rip : 0x0000000000401000 → <_start+0> mov eax, 0x0
Vemos que ambos registros contienen direcciones de puntero a otras ubicaciones. GEF
hace un excelente trabajo mostrándonos el valor de destino final.
Moving Pointer Values
Podemos ver que el registro rsp
contiene el valor final de 0x1
, y su valor inmediato es una dirección de puntero a 0x1
. Entonces, si usamos mov rax, rsp
, no estaremos moviendo el valor 0x1
a rax
, sino que moveremos la dirección de puntero 0x00007fffffffe490
a rax
.
Para mover el valor real, tendremos que usar corchetes []
, lo cual en ensamblador x86_64
y sintaxis Intel
significa cargar valor en la dirección
. Así que, en el mismo ejemplo anterior, si queremos mover el valor final al que apunta rsp
, podemos envolver rsp
en corchetes, como mov rax, [rsp]
, y esta instrucción mov
moverá el valor final en lugar del valor inmediato (que es una dirección al valor final).
Podemos usar corchetes para calcular un desplazamiento de dirección relativo a un registro u otra dirección. Por ejemplo, podemos hacer mov rax, [rsp+10]
para mover el valor almacenado a 10 direcciones de distancia desde rsp
.
Para demostrar esto adecuadamente, tomemos el siguiente código de ejemplo:
global _start
section .text
_start:
mov rax, rsp
mov rax, [rsp]
Este es solo un programa simple para demostrar este punto y ver la diferencia entre las dos instrucciones.
Ahora, ensamblamos el código y ejecutamos el programa con gdb:
$ ./assembler.sh rsp.s -g
gef➤ b _start
Breakpoint 1 at 0x401000
gef➤ r
...SNIP...
─────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
→ 0x401000 <_start+0> mov rax, rsp
───────────────────────────────────────────────────────────────────────────────────── registers ────
$rax : 0x00007fffffffe490 → 0x0000000000000001
$rsp : 0x00007fffffffe490 → 0x0000000000000001
Como podemos ver, el mov rax, rsp
movió el valor inmediato almacenado en rsp
(que es una dirección de puntero a rsp
) al registro rax
. Ahora presionemos si
y verifiquemos cómo se verá rax
después de la segunda instrucción:
$ ./assembler.sh rsp.s -g
gef➤ b _start
Breakpoint 1 at 0x401000
gef➤ r
...SNIP...
─────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
→ 0x401003 <_start+3> mov rax, QWORD PTR [rsp]
───────────────────────────────────────────────────────────────────────────────────── registers ────
$rax : 0x1
$rsp : 0x00007fffffffe490 → 0x0000000000000001
Podemos ver que esta vez, el valor final de 0x1
fue movido al registro rax
.
Nota: Al usar []
, puede que necesitemos establecer el tamaño de los datos antes de los corchetes, como byte
o qword
. Sin embargo, en la mayoría de los casos, nasm
lo hará automáticamente por nosotros. Podemos ver arriba que la instrucción final es en realidad mov rax, QWORD PTR [rsp]
. También vemos que nasm
agregó PTR
para especificar el movimiento de un valor desde un puntero.
Loading Value Pointers
Finalmente, necesitamos entender cómo cargar la dirección de un puntero a un valor, usando la instrucción lea
(o Load Effective Address
), la cual carga un puntero al valor especificado, como en lea rax, [rsp]
. Esto es lo opuesto a lo que aprendimos anteriormente (es decir, cargar un puntero a un valor versus mover un valor desde un puntero).
En algunos casos, necesitamos cargar la dirección de un valor en un registro en lugar de cargar directamente el valor en ese registro. Esto suele hacerse cuando los datos son grandes y no caben en un solo registro, por lo que los datos se colocan en el stack o en el heap, y se almacena un puntero a su ubicación en el registro.
Por ejemplo, la syscall write
que usamos en nuestro programa HelloWorld
requiere un puntero al texto que se imprimirá, en lugar de proporcionar directamente el texto, ya que este podría no caber por completo en el registro, dado que el registro tiene solo 64 bits o 8 bytes.
Primero, si quisiéramos cargar un puntero directo a una variable o una etiqueta, todavía podemos usar instrucciones mov
. Dado que el nombre de la variable es un puntero a donde se encuentra ubicada en la memoria, mov
almacenará este puntero en la dirección de destino. Por ejemplo, tanto mov rax, rsp
como lea rax, [rsp]
harán lo mismo al almacenar el puntero a message
en rax
.
Sin embargo, si quisiéramos cargar un puntero con un offset (es decir, a unas pocas direcciones de una variable o dirección), debemos usar lea
. Por esta razón, con lea
el operando fuente suele ser una variable, una etiqueta o una dirección envuelta en corchetes, como en lea rax, [rsp+10]
. Esto permite el uso de offsets (es decir, [rsp+10]
).
Nota que si usamos mov rax, [rsp+10]
, en realidad moverá el valor en [rsp+10]
a rax
, como se discutió anteriormente. No podemos mover un puntero con un offset usando mov
.
Veamos el siguiente ejemplo para demostrar cómo funciona lea
y cómo puede diferir de mov
:
global _start
section .text
_start:
lea rax, [rsp+10]
mov rax, [rsp+10]
Ahora ensamblémoslo y ejecutémoslo con gdb
:
$ ./assembler.sh lea.s -g
gef➤ b _start
Breakpoint 1 at 0x401000
gef➤ r
...SNIP...
─────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
→ 0x401003 <_start+0> lea rax, [rsp+0xa]
───────────────────────────────────────────────────────────────────────────────────── registers ────
$rax : 0x00007fffffffe49a → 0x000000007fffffff
$rsp : 0x00007fffffffe490 → 0x0000000000000001
Observamos que lea rax, [rsp+10]
cargó la dirección que está a 10 direcciones de rsp
(en otras palabras, 10 direcciones desde la cima del stack). Ahora usemos si
para ver qué haría mov rax, [rsp+10]
:
─────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
→ 0x401008 <_start+8> mov rax, QWORD PTR [rsp+0xa]
───────────────────────────────────────────────────────────────────────────────────── registers ────
$rax : 0x7fffffff
$rsp : 0x00007fffffffe490 → 0x0000000000000001
Como se esperaba, vemos que mov rax, [rsp+10]
movió el valor almacenado allí a rax
.