Introduccion
Esta publicación describirá la creación de un simple paquete de archivos ejecutables para linux x86_64. Se supone que el lector está familiarizado con el lenguaje de programación C, el lenguaje ensamblador para la arquitectura x86_64 y con los archivos ELF del dispositivo. Para garantizar la claridad, el manejo de errores se eliminó del código en el artículo y no se mostraron las implementaciones de algunas funciones, el código completo se puede encontrar haciendo clic en los enlaces a github (
cargador ,
empacador ).
La idea es esta: transferimos el archivo ELF al empaquetador y obtenemos uno nuevo con la siguiente estructura en la salida:
Para la compresión, se decidió utilizar el algoritmo Huffman, para el cifrado: AES-CTR con una clave de 256 bits, es decir, la implementación de kokke
tiny-AES-c . Se utilizan 256 bytes de datos aleatorios para inicializar la clave AES y el vector de inicialización utilizando un generador de números pseudoaleatorios, como se muestra a continuación:
for(int i = 0; i < 32; i++) { seed = (1103515245*seed + 12345) % 256; key[i] = buf[seed]; }
Esta decisión fue causada por el deseo de complicar la ingeniería inversa. Hasta la fecha, me di cuenta de que la complicación es insignificante, pero no comencé a eliminarla, ya que no quería gastar tiempo y energía en ella.
Cargador de arranque
Primero, se considerará el trabajo del gestor de arranque. El cargador no debe tener ninguna dependencia, por lo que todas las funciones necesarias de la biblioteca C estándar deberán escribirse de forma independiente (la implementación de estas funciones está disponible por
referencia ). También debe ser posicionalmente independiente.
_Función de inicio
El gestor de arranque comienza desde la función _start, que simplemente pasa argc y argv a main:
.extern main .globl _start .text _start: movq (%rsp), %rdi movq %rsp, %rsi addq $8, %rsi call main
Función principal
El archivo main.c comienza definiendo varias variables externas:
extern void* loader_end;
Todos ellos se declaran como externos para encontrar la posición de los caracteres correspondientes a las variables (Elf64_Sym) en el empaquetador y cambiar sus valores.
La función principal en sí es bastante simple. El primer paso es inicializar punteros en un archivo ELF empaquetado, un búfer de 256 bytes y en la parte superior de la pila. Luego, el archivo ELF se descifra y se expande, luego se coloca en el lugar correcto de la memoria utilizando la función load_elf, y finalmente, el valor del registro rsp vuelve a su estado original y se produce un salto al punto de entrada del programa:
#define SET_STACK(sp) __asm__ __volatile__ ("movq %0, %%rsp"::"r"(sp)) #define JMP(addr) __asm__ __volatile__ ("jmp *%0"::"r"(addr)) int main(int argc, char **argv) { uint8_t *payload = (uint8_t*)&loader_end;
El restablecimiento del estado de AES y el archivo ELF descomprimido se realiza por motivos de seguridad, de modo que la clave y los datos descifrados se almacenan en la memoria solo durante el tiempo de uso.
A continuación se considerará la implementación de algunas funciones.
cargar
Tomé esta función del usuario de github con el apodo bediger de su repositorio
userlandexec y la finalicé, ya que la función original se bloqueó en archivos como ET_DYN. La falla se produjo debido al hecho de que el valor del primer argumento de la llamada al sistema mmap se estableció en NULL, y la dirección se devolvió bastante cerca del programa principal, durante las llamadas posteriores a mmap y se copiaron segmentos a las direcciones devueltas por ellos, se sobrescribió el código del programa principal y se produjo un defecto. Por lo tanto, se decidió agregar la dirección inicial como parámetro a la función load_elf. La función en sí pasa por todos los encabezados del programa, asigna memoria (su número debe ser un múltiplo del tamaño de página) para los segmentos PT_LOAD del archivo ELF, copia su contenido a las áreas de memoria asignadas y establece los derechos de lectura, escritura y ejecución correspondientes a estas áreas:
elf_load_addr
Esta función para archivos ET_EXEC ELF devuelve NULL, ya que los archivos de este tipo deben ubicarse en las direcciones especificadas en ellos. Para los archivos ET_DYN, primero se calcula la dirección igual a la diferencia entre la dirección base del programa principal (es decir, el gestor de arranque), la cantidad de memoria requerida para colocar el ELF en la memoria y 4096, 4096: el espacio necesario para no colocar el archivo ELF justo al lado del programa principal. Después de calcular esta dirección, se verifica si el área de memoria se cruza, desde la dirección dada a la dirección base del programa principal, con el área desde el comienzo del archivo ELF desempaquetado hasta su final. En caso de intersección, la dirección se devuelve igual a la diferencia entre la dirección de inicio del ELF desempaquetado y la cantidad de memoria requerida para colocarlo, de lo contrario se devuelve la dirección calculada previamente.
La dirección base del programa se encuentra extrayendo la dirección de los encabezados del programa del vector auxiliar (vector auxiliar ELF), que se encuentra después de los punteros a las variables de entorno en la pila, y restando el tamaño del encabezado ELF:
--------------------------------------------------------------------------- -> [ argc ] 8 [ argv[0] ] 8 [ argv[1] ] 8 [ argv[..] ] 8 * x [ argv[n – 1] ] 8 [ argv[n] ] 8 (= NULL) [ envp[0] ] 8 [ envp[1] ] 8 [ envp[..] ] 8 [ envp[term] ] 8 (= NULL) [ auxv[0] (Elf64_auxv_t) ] 16 [ auxv[1] (Elf64_auxv_t) ] 16 [ auxv[..] (Elf64_auxv_t) ] 16 [ auxv[term] (Elf64_auxv_t) ] 16 (= AT_NULL) [ ] 0 - 16 [ ] >= 0 [ ] >= 0 [ ] 8 (= NULL) < > 0 ---------------------------------------------------------------------------
La estructura por la cual se describe cada elemento del vector auxiliar tiene la forma:
typedef struct { uint64_t a_type;
Uno de los valores válidos de a_type es AT_PHDR, a_val apuntará a los encabezados del programa. El siguiente es el código para la función elf_load_addr:
void* elf_base_addr(void *rsp) { void *base_addr = NULL; unsigned long argc = *(unsigned long*)rsp; char **envp = rsp + (argc+2)*sizeof(unsigned long);
Descripción del script de vinculador
Es necesario definir los caracteres para las variables externas descritas anteriormente, y también asegurarse de que el código y los datos del cargador después de la compilación estén en la misma sección .text. Esto es necesario para extraer convenientemente el código de máquina del cargador simplemente cortando el contenido de esta sección del archivo. Para lograr estos objetivos, se escribió el siguiente script de enlazador:
ENTRY(_start) SECTIONS { . = 0; .text :{ *(.text) *(.text.startup) *(.data) *(.rodata) payload_size = .; QUAD(0) key_seed = .; QUAD(0) iv_seed = .; QUAD(0) loader_end = .; } }
Vale la pena explicar que QUAD (0) coloca 8 bytes de ceros, en lugar de los cuales el empaquetador sustituirá valores específicos. Para recortar el código de la máquina, se escribió una pequeña utilidad que también escribe al comienzo del código de la máquina el desplazamiento del punto de entrada al gestor de arranque desde el inicio del gestor de arranque, el desplazamiento de los valores de los caracteres payload_size, key_seed y iv_seed desde el inicio del gestor de arranque. El código para esta utilidad está disponible
aquí . Esto finaliza la descripción del gestor de arranque.
Empacador directo
Considere la función principal del empacador. Utiliza dos argumentos de línea de comando: el nombre del archivo de entrada es argv [1] y el nombre del archivo de salida es argv [2]. Primero, el archivo de entrada se muestra en la memoria y se verifica la compatibilidad con el empaquetador. El empaquetador funciona con solo dos tipos de archivos ELF: ET_EXEC y ET_DYN, y solo con archivos compilados estáticamente. La razón para introducir esta restricción fue el hecho de que diferentes sistemas Linux tienen diferentes versiones de bibliotecas compartidas, es decir. La probabilidad de que un programa compilado dinámicamente no se inicie en un sistema que no sea el sistema principal es bastante alta. El código correspondiente en la función principal:
size_t mapped_size; void *mapped = map_file(argv[1], &mapped_size); if(check_elf(mapped) < 0) return 1;
Después de eso, si el archivo de entrada pasa la verificación de compatibilidad, se comprime:
size_t comp_size; uint8_t *comp_buf = huffman_encode(mapped, &comp_size);
A continuación, se genera el estado AES y el archivo ELF comprimido se cifra. El estado de AES está determinado por la siguiente estructura:
#define AES_ENTROPY_BUFSIZE 256 typedef struct { uint8_t entropy_buf[AES_ENTROPY_BUFSIZE];
Código correspondiente en main:
AES_state_t aes_st; for(int i = 0; i < AES_ENTROPY_BUFSIZE; i++) state.entropy_buf[i] = rand() % 256; state.key_seed = rand(); state.iv_seed = rand(); AES_init_ctx_iv(&state.ctx, state.entropy_buf, state.key_seed, state.iv_seed); AES_CTR_xcrypt_buffer(&aes_st.ctx, comp_buf, comp_size);
Después de eso, la estructura que almacena información sobre el gestor de arranque se inicializa, los valores de payload_size, key_seed y iv_seed en el gestor de arranque se cambian a los generados en el paso anterior, después de lo cual se restablece el estado AES. La información sobre el gestor de arranque se almacena en la siguiente estructura:
typedef struct { char *loader_begin;
Código correspondiente en main:
loader_t loader; init_loader(&loader); *loader.payload_size_patch_offset = comp_size; *loader.key_seed_pacth_offset = aes_st.key_seed; *loader.iv_seed_patch_offset = aes_st.iv_seed; memset(&aes_st.ctx, 0, sizeof(aes_st.ctx));
En la parte final, creamos un archivo de salida, escribimos un encabezado ELF, un encabezado de programa, un código de cargador, un archivo ELF comprimido y encriptado, y un búfer de 256 bytes en él:
int out_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0755);
El código principal del empaquetador termina aquí, luego se considerarán las siguientes funciones: la función de inicializar información sobre el cargador de arranque, la función de escribir el encabezado ELF y la función de escribir el encabezado del programa.
Inicializando la información del gestor de arranque
El código de máquina del cargador está incrustado en el ejecutable del empaquetador utilizando el código simple a continuación:
.data .globl _loader_begin .globl _loader_end _loader_begin: .incbin "loader" _loader_end:
Para determinar su dirección en la memoria, se declaran las siguientes variables en el archivo main.c:
extern void* _loader_begin; extern void* _loader_end;
A continuación, considere la función init_loader. Primero, los siguientes valores se leen secuencialmente en él: el desplazamiento del punto de entrada desde el inicio del cargador de arranque (entry_offset), el cambio del tamaño del archivo ELF empaquetado desde el inicio del cargador de arranque (payload_size_patch_offset), el cambio del valor inicial del generador para la clave desde el inicio del cargador de arranque (key_seed_inicial_patch_ el generador del valor del vector del parámetro inicialización desde el inicio del gestor de arranque (iv_seed_patch_offset). Luego, la dirección del cargador se agrega a los últimos tres valores, por lo que al desreferenciar los punteros y asignarles valores, reemplazaremos los ceros asignados en la etapa de diseño (QUAD (0)) con los valores que necesitamos.
void init_loader(loader_t *l) { void *loader_begin = (void*)&_loader_begin; l->entry_offset = *(size_t*)loader_begin; loader_begin += sizeof(size_t); l->payload_size_patch_offset = *(void**)loader_begin; loader_begin += sizeof(void*); l->key_seed_pacth_offset = *(void**)loader_begin; loader_begin += sizeof(void*); l->iv_seed_patch_offset = *(void**)loader_begin; loader_begin += sizeof(void*); l->payload_size_patch_offset = (size_t)l->payload_size_patch_offset + loader_begin; l->key_seed_pacth_offset = (size_t)l->key_seed_pacth_offset + loader_begin; l->iv_seed_patch_offset = (size_t)l->iv_seed_patch_offset + loader_begin; l->loader_begin = loader_begin; l->loader_size = (void*)&_loader_end - loader_begin; }
write_elf_ehdr
void write_elf_ehdr(int fd, loader_t *loader) {
Aquí se produce la inicialización estándar del encabezado ELF y su posterior escritura en un archivo, lo único a lo que hay que prestar atención es al hecho de que en los archivos ET_DYN ELF el segmento descrito por el primer encabezado del programa incluye no solo el código ejecutable, sino también el encabezado ELF y todos los encabezados programas Por lo tanto, su desplazamiento desde el principio debe ser igual a cero, el tamaño debe ser la suma del tamaño del encabezado ELF, todos los encabezados del programa y el código ejecutable, y el punto de entrada se determina como la suma del tamaño del encabezado ELF, el tamaño de todos los encabezados del programa y el desplazamiento desde el comienzo del código ejecutable.
write_elf_phdr
void write_elf_phdr(int fd, loader_t *loader, size_t payload_size) {
Aquí, el encabezado del programa se inicializa y luego se escribe en un archivo. Debe prestar atención al desplazamiento relativo al comienzo del archivo y al tamaño del segmento descrito por el encabezado del programa. Como se describe en el párrafo anterior, el segmento descrito por este encabezado incluye no solo el código ejecutable, sino también el encabezado ELF y el encabezado del programa. También hacemos que el segmento con código ejecutable sea accesible para la escritura, esto se debe al hecho de que la implementación de AES utilizada en el gestor de arranque cifra y descifra los datos "en su lugar".
Algunos hechos sobre el trabajo del empacador
Durante las pruebas, se notó que los programas compilados estáticamente con glibc pasan a segfault cuando se inician, en esta instrucción:
movq% fs: 0x28,% rax
No pude averiguar por qué sucede esto, me alegrará si comparte información sobre este tema. En lugar de glibc, puede usar musl-libc, todo funciona sin fallas. Además, el empacador se probó con programas de golang compilados estáticamente, por ejemplo, un servidor http. Para bloqueos estáticos completos de los programas de golang, se deben utilizar los siguientes indicadores:
CGO_ENABLED = 0 vaya a construir -a -ldflags '-extldflags "-static"'.
Lo último con lo que se probó el empaquetador fue con los archivos ET_DYN ELF sin un vinculador dinámico. Es cierto que al trabajar con estos archivos, la función elf_load_addr puede fallar. En la práctica, se puede cortar del gestor de arranque y usar una dirección fija, por ejemplo 0x10000.
Conclusión
Este empaquetador, obviamente, no tiene sentido usarlo para el propósito previsto, ya que los archivos protegidos por él se descifran con bastante facilidad. El objetivo de este proyecto era dominar mejor el trabajo con archivos ELF, la práctica de generarlos, así como la preparación para la creación de un empaquetador más completo.