Enviado por magnus el Mar, 08/10/2019 - 10:22
hacker programming

Estimados hacklabbers, hoy quiero hablarles de una de las técnicas más viejas del hacking pero que, no por antigua es obsoleta. Estoy hablando de hacer un overflow de memoria en tiempo de ejecución para hacerse con el control de un programa, la cual puede ser de distintos tipos, como desborde de pila (stack overflow) y de buffer (buffer overflow), por ejemplo. En este artículo hablaremos de un ejemplo práctico de un buffer overflow. 

En mi artículo anterior conté cómo una vulnerabilidad en una asignación de memoria de un programa incluido en el sistema operativo, le permitía a un gusano ejecutar códigos remotamente y copiarse a otras computadoras. En este caso, quiero contarles de un curioso programa que encontré en un servidor que estaba quitando de producción. 

En dicho servidor, los usuarios guardaban carpetas de trabajo y las mismas hoy han sido migradas a una plataforma cloud, por lo que el mismo quedó obsoleto. Revisando este servidor para ver si no olvidaba migrar ningún dato, encontré un archivo que capturó mi atención: 

En una carpeta con varios programas ejecutables, había uno resaltado en rojo, el cual, además, estaba en castellano a diferencia de los demás y tenía una fecha de creación bastante antigua. Al ejecutarlo vi el siguiente menú: 

Al poner un comando de los listados, mostraba lo siguiente: 

Al poner un nombre cualquiera, noté que creó una carpeta con propietario root. Tras chequear los permisos del archivo noté que, efectivamente, el programa se ejecutaba con permisos de root. En breve, este es un script que le permitía a los usuarios crear, editar y borrar archivos de texto como root y crear y borrar carpetas como root.

Intenté ejecutarlo fuera de la carpeta de trabajo asignada para los usuarios y el programa arrojó un error de “ubicación inválida”, y al querer usar un comando que no fuera uno de los listados, arrojó un error de “comando inválido”.

Parecería que, a pesar de correr como root, es un programa que siendo usado con mala intención no permitiría hacer más daño que borrar las carpetas de una ubicación que se backupeaba a diario. Entonces se me ocurrió descompilarlo y ver cómo funcionaba, vi algo que me llamó la atención: 

Cerca del final del archivo encontré la siguiente secuencia de comandos: 

.L8:

    leaq    .LC7(%rip), %rdi
    movl    $0, %eax
    call    printf@PLT
    leaq    -18(%rbp), %rax
    movq    %rax, %rsi
    leaq    .LC8(%rip), %rdi
    movl    $0, %eax
    call    __isoc99_scanf@PLT
    movl    $10, %edi
    call    putchar@PLT
    leaq    -18(%rbp), %rax
    movq    %rax, %rdi
    call    valid
    testb    %al, %al
    je    .L9
    leaq    -18(%rbp), %rax
    leaq    .LC5(%rip), %rsi
    movq    %rax, %rdi
    call    strcmp@PLT
    testl    %eax, %eax
    jne    .L10
    leaq    .LC9(%rip), %rdi
    call    puts@PLT
    movl    $0, %eax
    jmp    .L12

Vemos que lo primero que hace es llamar a lo contenido en el label LC7 y LC8:


.LC7:

    .string    "Comandos disponibles: \n -mkdir\n -rmdir\n -rm\n -touch\n -nano\n -salir\n\n Escriba el comando que desea ejecutar: "

.LC8:

    .string    "%s"


Luego hace un scanf@PLT y llama a una función a la que decidí llamar “valid” y le envía lo que el usuario haya tipeado. Veamos qué hace esta función:

valid:

.LFB3:

    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    subq    $16, %rsp
    movq    %rdi, -8(%rbp)
    movq    -8(%rbp), %rax
    leaq    .LC0(%rip), %rsi
    movq    %rax, %rdi
    call    strcmp@PLT
    testl    %eax, %eax
    je    .L4
    movq    -8(%rbp), %rax
    leaq    .LC1(%rip), %rsi
    movq    %rax, %rdi
    call    strcmp@PLT
    testl    %eax, %eax
    je    .L4
    movq    -8(%rbp), %rax
    leaq    .LC2(%rip), %rsi
    movq    %rax, %rdi
    call    strcmp@PLT
    testl    %eax, %eax
    je    .L4
    movq    -8(%rbp), %rax
    leaq    .LC3(%rip), %rsi
    movq    %rax, %rdi
    call    strcmp@PLT
    testl    %eax, %eax
    je    .L4
    movq    -8(%rbp), %rax
    leaq    .LC4(%rip), %rsi
    movq    %rax, %rdi
    call    strcmp@PLT
    testl    %eax, %eax
    je    .L4
    movq    -8(%rbp), %rax
    leaq    .LC5(%rip), %rsi
    movq    %rax, %rdi
    call    strcmp@PLT
    testl    %eax, %eax
    jne    .L5

Prestando un poco de atención vemos que esta función en apariencia bastante extensa, en realidad es muchas veces la misma función:

Mueve a los registros rax y rsi el contenido de rbp y rip, para luego compararlos con strcmp y si el resultado es igual, ejecuta el label L4:

.L4:

    movl    $1, %eax
    jmp    .L6

Y si no lo son, el L5:

.L5:

    movl    $0, %eax

 

En otras palabras, compara lo escrito por el usuario con una serie de palabras guardadas en memoria y devuelve 1 si son iguales y 0 si no lo son. Por este proceso booleano de validación es que decidí bautizarla “valid”.

Vemos que si esta función devuelve una comparación fallida, salta a L9, que lo que hace es volver al inicio del programa. En caso contrario, prosigue con L10:

.L10:

    leaq    .LC10(%rip), %rdi
    movl    $0, %eax
    call    printf@PLT
    leaq    -48(%rbp), %rax
    movq    %rax, %rsi
    leaq    .LC8(%rip), %rdi
    movl    $0, %eax
    call    __isoc99_scanf@PLT
    leaq    -18(%rbp), %rax
    movq    %rax, %rsi
    leaq    .LC11(%rip), %rdi
    call    concat
    movq    %rax, -8(%rbp)
    leaq    -48(%rbp), %rdx
    leaq    -18(%rbp), %rsi
    movq    -8(%rbp), %rax
    movl    $0, %ecx
    movq    %rax, %rdi
    movl    $0, %eax
    call    execl@PLT
    jmp    .L12

Este parece ser el cuerpo principal del programa, ya que lo primero que vemos que hace es llamar al Label LC10 y al LC8, los cuales dicen:

.LC10:
    .string    "Escriba el nombre de archivo o carpeta: "

.LC8:
    .string    "%s"

Y a continuación hace un scanf@PLT para registrar lo que el usuario escribe, tras lo cual invoca una función que decidí llamar “concat”, LFB2:

concat:

.LFB2:

    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    pushq    %rbx
    subq    $40, %rsp
    .cfi_offset 3, -24
    movq    %rdi, -40(%rbp)
    movq    %rsi, -48(%rbp)
    movq    -40(%rbp), %rax
    movq    %rax, %rdi
    call    strlen@PLT
    movq    %rax, %rbx
    movq    -48(%rbp), %rax
    movq    %rax, %rdi
    call    strlen@PLT
    addq    %rbx, %rax
    addq    $1, %rax
    movq    %rax, %rdi
    call    malloc@PLT
    movq    %rax, -24(%rbp)
    movq    -40(%rbp), %rdx
    movq    -24(%rbp), %rax
    movq    %rdx, %rsi
    movq    %rax, %rdi
    call    strcpy@PLT
    movq    -48(%rbp), %rdx
    movq    -24(%rbp), %rax
    movq    %rdx, %rsi
    movq    %rax, %rdi
    call    strcat@PLT
    movq    -24(%rbp), %rax
    addq    $40, %rsp
    popq    %rbx
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc

.LFE2:

    .size    concat, .-concat
    .section    .rodata

Esto que parece algún idioma extraterrestre, tras releerlo mucho, noté que todas esas funciones “movq”, que significan “mover un segmento de 64 bytes” lo que estaban haciendo es tomar dos parámetros ingresados en la función concat y colocarlos uno junto al otro en espacios de memoria consecutivos. Por eso decidí llamarla “concat”, apócope de “concatenar”.

Lo que imaginé es que esta función lo que hace es tomar el comando solicitado por el programa y, tras su validación con la función valid, lo concatena con el parámetro de nombre de carpeta o archivo para ejecutarlos como una única línea de comando, sin embargo, lo que vi es que toma lo ubicado en el label LC11:

.LC11:

    .string    "/bin/"

Y eso es lo que concatena con lo tipeado por el usuario antes de la ejecución de la función "valid", mientras que lo tipeado tras LC10 es enviado como segundo parámetro a:

call    execl@PLT

Lo cual quiere decir que este programa accede directamente a todos los comandos ubicados en la carpeta /bin y allí ejecuta lo tipeado por el usuario.

Ahora bien, ¿por qué puede ser esto inseguro?

La respuesta es: Por la manera en que las variables están colocadas en la memoria. En ningún momento este programa verifica que una variable termine donde la otra comienza, ni valida los datos antes de ejecutarlos, sino únicamente al ingresarlos.

Veamos a qué me refiero:

 

Vemos que al colocar un nombre de archivo o carpeta ridículamente largo, obtenemos un SIGSEGV, es decir un error de segmentación.

Lo que está ocurriendo es que las variables donde se almacenan el texto para el comando y el texto para el nombre de archivo son contiguas en memoria, entonces al poner un nombre de carpeta más largo de lo esperado, esta variable “pisa” a la otra, y el programa, al pasarle ambas variables concatenadas al sistema, acaba pidiéndole que ejecute “000000000…..”, lo cual no tiene ningún sentido para este y el programa se estrella, al haber sido incluso pisada la dirección de retorno.

Ya sé que estarán diciendo “pues eso es un programa inestable, pero no le veo el peligro a eso”. Tras jugar un poco con la longitud del nombre de archivo, encontré que el tamaño de la variable era exactamente 20 bytes o caracteres. Y, al parecer, el buffer para la variable que almacena el comando es de apenas 10 bytes, por lo que cualquier caracter por encima de esa longitud total de 30 bytes acababa causando un error de segmentación.

Veamos con un editor de memoria qué está ocurriendo con las variables:

En primer lugar, cargamos el binario con gdb, y lo primero que hacemos es poner un breakpoint en la dirección de memoria 0x0, para que al correrlo, el programa se cargue en memoria pero no se ejecute ningún comando. Una vez hecho esto, borramos ese breakpoint, que ya no cumple ninguna función y le solicitamos al gdb que nos muestre como se cargó en memoria el programa decompilado:

 

Con esta información, cargamos nuevos breakpoints antes y después de que el usuario ingrese datos por teclado y uno al final, justo antes de que el programa ejecute el comando y luego le damos continuar:

 

En este punto vemos lo siguiente:

 

Al comando scanf que vemos en rosa como la orden siguiente por ejecutar, se le pasa el contenido de RSI como el destino al cual debe guardar lo que reciba. Es decir, en RSI está la dirección de la variable que el programa almacenará cuando escribamos algo en el teclado.

Le indicamos a gdb que queremos examinar la memoria desde un poco antes de esta posición (0x7ffcda46ca6e):

 

[x significa eXaminar, 16 significa 16 iteraciones, y w es por “words”, el tamaño de los  grupos de memoria que mostrará].

Al analizarlo en un editor hexadecimal,  vemos que el contenido de la variable, por ahora es cero y está rodeado de basura o cosas sin sentido:

 

 

Entonces, continuamos con la ejecución y ponemos, por ejemplo, el comando mkdir y volvemos a analizar la memoria:

 

Analizamos esto:

 

 

Y esto que tampoco parece tener sentido, cobra mucho sentido si pensamos que debemos leer las direcciones de memoria de izquierda a derecha y dentro de ellas, los bytes en pares de derecha a izquierda. Siguiendo esa regla, encontramos la cadena de texto “mkdir”, expresado como “dkm…..ri”.

Continuamos con la ejecución y justo antes de poner el nombre de carpeta inspeccionamos el espacio de memoria que va a ocupar la variable:

 

 

Vemos que el espacio que ocupará la variable son puros ceros, hasta donde comienza la otra variable, con 56d4d5… Ponemos entonces un nombre de carpeta más largo que toda esta secuencia de ceros y que el comando que escribimos antes, teniendo en cuenta que cada par de números representa un caracter, y luego analizamos la memoria de nuevo:

 

Vemos que se repiten los caracteres 0x31, 0x32… etc., hasta 0x38. Estos, desde luego, son los códigos hexadecimales de los caracteres ASCII del 1 al 8.

Si avanzamos un poco más, podemos ver qué es lo que nuestro programa intenta ejecutar:

 

Lo vemos en el paso 0040: “/bin/7777788888”. Desde luego, ese programa no existe en /bin y el programa finaliza con error.

Lo que vemos es que las variables, al haber sido declaradas juntas, ocupan espacios contiguos de memoria, entonces, cuando la longitud del nombre de archivo supera el máximo establecido por el buffer, comienza a “pisar” a la variable del nombre de comando (la cual ya había sido validada por el programa).

Lo que hice a continuación fue crear una copia del programa en mi máquina, al cual le dí exactamente los mismos permisos que tenía en la computadora en la cual lo encontré, y cree un archivo de exactamente 30 bytes de longitud en su nombre y que dentro contenía simplemente la palabra “bash”, y llamé a este archivo “AAAAAAAABBBBBBBBCCCCCCCCDDDDDDbash”.

Luego hice correr el programa, y puse a ejecutar un comando cualquiera de los aprobados por este, pero como nombre de archivo a operar, elegí uno que ya existía, el que había creado anteriormente:  AAAAAAAABBBBBBBBCCCCCCCCDDDDDDbash.

Normalmente, el programa diría “Error: El archivo ya existe”, pero esta vez el resultado fue otro. Exactamente el que esperaba:

 

Exploit

 

Como vemos, ejecuté el programa como usuario común ($) y el programa sale dándome shell de root (#). A partir de aquí, si fuera una persona malintencionada, tendría el sistema a mi merced para hacer lo que me plazca.

Ahora bien, ¿cómo podría haber evitado esto la persona que hizo este script? Hay varias opciones:

  1. Simplemente declarando las variables en otro orden, este procedimiento no hubiera funcionado. Sin embargo, seguramente alguna otra variante del mismo sí.

  2. Chequeando los comandos antes de ejecutar, cuando finalizó la intervención del usuario y ya no tiene posibilidades de alterar el flujo de funcionamiento del programa.

  3. Chequeando la cantidad de caracteres ingresados por el usuario para que no superen el buffer.

  4. Poniendo un menú de selección, de manera que nada de lo que pueda poner el usuario pueda llegar jamás a un comando “exec” con permisos de root.

Y, desde luego, están invitados a sugerir otras medidas en los comentarios.

Hay algo que sí estuvo bien hecho en este programa, y es que se compiló con PIC (Position Independent Code - Código independiente de la posición), lo que lo hace prácticamente inmune a ataques más sofisticados que veremos más adelante. Pero, seguramente, esta era la opción por defecto del compilador y el desarrollador de este programa tuvo poco que ver con ello.

Finalmente, como siempre, los invito a comentar sus opiniones aquí o en nuestras redes sociales, Facebook y Twitter, o pueden unirse a la charla en Discord y el foro.

Agradecimientos: Quiero agradecer especialmente a Physics de Pastafrola CTF Team, a quien conocí en la EkoParty de este año y cuyos conocimientos sobre explotación de binarios me inspiraron a terminar este artículo que venía postergando hace mucho tiempo. Su ayuda fue indispensable para encontrar cómo pasar de un programa inestable a un shell de root, lo cual no es poca cosa. Sin él, este artículo no hubiera sido posible.

Acerca del autor

Administrador de sistemas Linux.
Administrador de redes.
Programador en los ratos libres.
Técnico electrónico.

...me gusta desarmar cosas...

Comentarios