Saltar a contenido

Registers, Addresses, and Data Types

Ahora que entendemos la arquitectura general de computadoras y procesadores, necesitamos comprender algunos elementos de Assembly antes de comenzar a aprender Assembly: Registers, Memory Addresses, Address Endianness y Data Types. Cada uno de estos elementos es importante, y comprenderlos correctamente nos ayudará a evitar problemas y horas de solución de errores mientras escribimos y depuramos código en Assembly.


Registers

Como se mencionó anteriormente, cada núcleo de CPU tiene un conjunto de registros. Los registros son los componentes más rápidos en cualquier computadora, ya que están construidos dentro del núcleo de la CPU. Sin embargo, los registros son muy limitados en tamaño y solo pueden contener unos pocos bytes de datos a la vez. Hay muchos registros en la arquitectura x86, pero solo nos enfocaremos en los necesarios para aprender Assembly básico y los esenciales para futuras explotaciones binarias.

Hay dos tipos principales de registros en los que nos enfocaremos: Data Registers y Pointer Registers.

Data Registers Pointer Registers
rax rbp
rbx rsp
rcx rip
rdx
r8
r9
r10
  • Data Registers - se utilizan generalmente para almacenar argumentos de instrucciones/syscalls. Los registros de datos principales son: rax, rbx, rcx y rdx. Los registros rdi y rsi también existen y generalmente se utilizan para los operandos de destination y source en las instrucciones. Luego, tenemos registros de datos secundarios que pueden usarse cuando todos los registros anteriores están en uso, los cuales son r8, r9 y r10.

  • Pointer Registers - se utilizan para almacenar punteros de direcciones importantes. Los registros principales de punteros son el Base Stack Pointer rbp, que apunta al inicio del Stack, el Current Stack Pointer rsp, que apunta a la ubicación actual dentro del Stack (parte superior del Stack), y el Instruction Pointer rip, que contiene la dirección de la siguiente instrucción.


Sub-Registers

Cada registro de 64-bit puede dividirse en sub-registros más pequeños que contienen los bits inferiores, con un tamaño de un byte 8-bits, 2 bytes 16-bits y 4 bytes 32-bits. Cada sub-registro puede utilizarse y accederse de forma independiente, por lo que no tenemos que consumir los 64-bits completos si tenemos una cantidad menor de datos.

register parts

Los sub-registros pueden accederse como:

Size in bits Size in bytes Name Example
16-bit 2 bytes el nombre base ax
8-bit 1 bytes nombre base y/o termina en l al
32-bit 4 bytes nombre base + comienza con el prefijo e eax
64-bit 8 bytes nombre base + comienza con el prefijo r rax

Por ejemplo, para el registro de datos bx, el registro de 16-bit es bx, por lo que el de 8-bit es bl, el de 32-bit sería ebx, y el de 64-bit sería rbx. Lo mismo se aplica a los registros de punteros. Si tomamos el Base Stack Pointer bp, su sub-registro de 16-bit es bp, el de 8-bit es bpl, el de 32-bit es ebp, y el de 64-bit es rbp.

Los siguientes son los nombres de los sub-registros para todos los registros esenciales en una arquitectura x86_64:

Description 64-bit Register 32-bit Register 16-bit Register 8-bit Register
Data/Arguments Registers
Syscall Number/Return value rax eax ax al
Callee Saved rbx ebx bx bl
1st arg - Destination operand rdi edi di dil
2nd arg - Source operand rsi esi si sil
3rd arg rdx edx dx dl
4th arg - Loop counter rcx ecx cx cl
5th arg r8 r8d r8w r8b
6th arg r9 r9d r9w r9b
Pointer Registers
Base Stack Pointer rbp ebp bp bpl
Current/Top Stack Pointer rsp esp sp spl
Instruction Pointer 'call only' rip eip ip ipl

A medida que avancemos en el módulo, discutiremos cómo usar cada uno de estos registros.

Existen otros registros varios, pero no los cubriremos en este módulo, ya que no se necesitan para el uso básico de Assembly. Por ejemplo, está el registro RFLAGS, que se utiliza para mantener diversas flags usadas por la CPU, como el zero flag ZF, que se utiliza para instrucciones condicionales.


Memory Addresses

Como se mencionó anteriormente, los procesadores x86 de 64 bits tienen direcciones de 64 bits de ancho que van desde 0x0 hasta 0xffffffffffffffff, por lo que esperamos que las direcciones estén dentro de este rango. Sin embargo, la RAM está segmentada en varias regiones, como el Stack, el heap y otras regiones específicas del programa y del kernel. Cada región de memoria tiene permisos específicos de read, write, execute que especifican si podemos leer de ella, escribir en ella o llamar a una dirección en ella.

Siempre que una instrucción pasa por el Instruction Cycle para ser ejecutada, el primer paso es obtener la instrucción desde la dirección en la que está ubicada, como se mencionó anteriormente. Hay varios tipos de obtención de direcciones (es decir, addressing modes) en la arquitectura x86:

Addressing Mode Description Example
Immediate El valor se proporciona dentro de la instrucción add 2
Register El nombre del registro que contiene el valor se proporciona en la instrucción add rax
Direct La dirección completa directa se proporciona en la instrucción call 0xffffffffaa8a25ff
Indirect Se proporciona un puntero de referencia en la instrucción call 0x44d000 o call [rax]
Stack La dirección está en la parte superior del stack add rsp

En la tabla anterior, mientras más abajo esté, más lento será. Cuanto menos inmediato sea el valor, más lento será obtenerlo.

Aunque la velocidad no es nuestra principal preocupación al aprender Assembly básico, debemos comprender dónde y cómo se ubica cada dirección. Tener este conocimiento nos ayudará en futuras explotaciones binarias, como Buffer Overflow. Esta comprensión tendrá implicaciones aún más significativas con explotaciones binarias avanzadas, como ROP o Heap exploitation.


Address Endianness

El endianness de una dirección es el orden de sus bytes en el que se almacenan o recuperan de la memoria. Hay dos tipos de endianness: Little-Endian y Big-Endian. Con los procesadores Little-Endian, el byte menos significativo de la dirección se llena/recupera primero (derecha-a-izquierda), mientras que con los procesadores Big-Endian, el byte más significativo se llena/recupera primero (izquierda-a-derecha).

Por ejemplo, si tenemos la dirección 0x0011223344556677 para almacenar en memoria, los procesadores Little-Endian almacenarían el byte 0x00 en los bytes más a la derecha, luego el byte 0x11 después de él, quedando 0x1100, y luego el byte 0x22, quedando 0x221100, y así sucesivamente. Una vez que todos los bytes están en su lugar, se verán como 0x7766554433221100, que es la inversión del valor original. Por supuesto, al recuperar el valor, el procesador también utilizará la recuperación Little-Endian, por lo que el valor recuperado será el mismo que el original.

Otro ejemplo que muestra cómo esto puede afectar los valores almacenados es binario. Por ejemplo, si tuviéramos el entero de 2 bytes 426, su representación binaria es 00000001 10101010. El orden en el que se almacenan estos dos bytes cambiaría su valor. Por ejemplo, si se almacenara en orden inverso como 10101010 00000001, su valor se convertiría en 43521.

Los procesadores Big-Endian almacenarían estos bytes como 00000001 10101010 (izquierda-a-derecha), mientras que los procesadores Little-Endian los almacenarían como 10101010 00000001 (derecha-a-izquierda). Al recuperar el valor, el procesador debe usar el mismo endianness utilizado al almacenarlos, o se obtendrá un valor incorrecto. Esto indica que el orden en el que se almacenan/recuperan los bytes marca una gran diferencia.

La siguiente tabla demuestra cómo funciona el endianness:

Address 0 1 2 3 4 5 6 7 Address Value
Little Endian 77 66 55 44 33 22 11 00 0x
Big Endian 00 11 22 33 44 55 66 77 0x

Address: 0x0011223344556677

Puedes hacer clic en el botón "Load Address" para visualizar cómo cada endianness carga datos/direcciones en la memoria.

Como podemos ver, esto significa que una dirección escrita en Little-Endian o en Big-Endian se referiría a diferentes ubicaciones en la memoria, ya que sería leída de manera diferente por cada tipo de procesador.

Este módulo siempre usará el orden de bytes Little-Endian, ya que se utiliza con Intel/AMD x86 en la mayoría de los sistemas operativos modernos, por lo que el shellcode siempre se representa de derecha a izquierda.

Lo importante que debemos aprender aquí es saber que nuestros bytes se almacenan en memoria de derecha a izquierda. Por lo tanto, si quisiéramos almacenar una dirección o una cadena con Assembly, tendríamos que almacenarla en orden inverso. Por ejemplo, si queremos almacenar la palabra Hello, empujaríamos sus bytes en orden inverso: o, l, l, e y finalmente H.

Esto puede parecer un poco contraintuitivo, ya que la mayoría de las personas están acostumbradas a leer de izquierda a derecha. Sin embargo, esto tiene múltiples ventajas al procesar datos, como poder recuperar un subregistro sin tener que recorrer todo el registro o poder realizar operaciones aritméticas en el orden correcto de derecha a izquierda.


Data Types

Finalmente, la arquitectura x86 admite muchos tipos de tamaños de datos, que pueden utilizarse con varias instrucciones. Los siguientes son los tipos de datos más comunes que utilizaremos con las instrucciones:

Component Length Example
byte 8 bits 0xab
word 16 bits - 2 bytes 0xabcd
double word (dword) 32 bits - 4 bytes 0xabcdef12
quad word (qword) 64 bits - 8 bytes 0xabcdef1234567890

Siempre que usemos una variable con un cierto tipo de dato o usemos un tipo de dato con una instrucción, ambos operandos deben ser del mismo tamaño.

Por ejemplo, no podemos usar una variable definida como byte con rax, ya que rax tiene un tamaño de 8 bytes. En este caso, tendríamos que usar al, que tiene el mismo tamaño de 1 byte. La siguiente tabla muestra el tipo de dato apropiado para cada subregistro:

Sub-register Data Type
al byte
ax word
eax dword
rax qword

Discutiremos esto más a fondo en las próximas secciones. Con todos los fundamentos de Assembly cubiertos, podemos comenzar a aprender sobre las instrucciones x86 y escribir código Assembly básico.