Saltar a contenido

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.