githubEditar

HackTheBox - Gavel

Writeup de la máquina Gavel de HackTheBox

  • Dificultad medium

  • Tiempo aprox. ~8.5h (+3h Decompilando)

  • Datos Iniciales: 10.129.9.253

Nmap Scan

Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 1f:de:9d:84:bf:a1:64:be:1f:36:4f:ac:3c:52:15:92 (ECDSA)
|_  256 70:a5:1a:53:df:d1:d0:73:3e:9d:90:ad:c1:aa:b4:19 (ED25519)
80/tcp open  http    Apache httpd 2.4.52
|_http-title: Did not follow redirect to http://gavel.htb/
|_http-server-header: Apache/2.4.52 (Ubuntu)
Service Info: Host: gavel.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel
#Nada en UDP (Solo DHCP)
  • 22/TCP (SSH) Versión vulnerable a RegreSSHion pero con difícil explotación, posiblemente no sea el vector.

  • 80/TCP (HTTP): Se nos redirige a gavel.htb, lo añadimos a /etc/hosts y buscamos subdominios.

HTTP, gavel.htb

Antes de nada, buscamos subdominios:

No hemos podido encontrar ningún subdominio nuevo, así que vamos directos a gavel.htb.

Al entrar, encontramos una página web para, al parecer, realizar pujas de ciertos objetos.

Desde esta página vemos 2 cosas relevantes:

  • En el título se indica "Gavel 2.0", pueden ser el servicio y versión reales?

    • Con el We don't talk about Gavel 1.0. Ever. (It ended in fire, lawsuits, and one mysteriously vanishing moon.) de abajo podemos deducir que simplemente se trata del contexto de la máquina más que de la versión técnica.

  • Se pueden crear usuarios, así que creamos uno username:password.

Mientras tanto, hacíamos un análisis de directorios:

Una vez con un usuario, hacemos una puja de 2000 por un objeto para ver qué pasa.

Vemos que se hace una solicitud a bid_handler.php:

Y una vez ha acabado el tiempo, entramos a nuestro inventario:

Probando XSS

Aquí podemos ver que arriba a la izquierda aparece nuestro username, podríamos probar a ver si la página es vulnerable a un Stored XSS. Pero cuando intentamos crear un usuario malicioso:

Además, desde BurpSuite vemos que la comprobación se realiza del lado del servidor, así que no hay mucho que podamos hacer.

Probando SQLi

Si hacemos una solicitud a inventory.php y la interceptamos con BurpSuite, podemos ver que es algo así:

Si se está devolviendo el inventario de nuestro usuario en función de user_id=2 y se ordena en función de sort, podemos tratar de hacer algunas inyecciones SQL:

Mandamos esto para ver si podemos ver todos los objetos de inventarios de los usuarios:

Y vemos que no se nos devuelve nada (inventario vacío), así que posiblemente user no sea inyectable. Por otro lado, si probamos con sort, vemos que si su valor no es quantity ni name no se devuelve nada, y tampoco es inyectable.

Más enumeración, Source Code Disclosure

Pasado un rato, pruebo a enumerar el directorio en que se encontraba bid_handler.php: includes/.

Pero, tras echar un vistazo, veo que no dan info nueva ni sirven como vectores de entrada. El único que hacía algo relevante era session.php porque daba cookies de sesión, pero tampoco podía hacerse nada con ellas.

Dedido volver a hacer un escaneo nmap por si nos habíamos dejado algo antes, y encuentro lo siguiente:

Y es aquí cuando aprendo que nmap activa unos u otros scripts (-sC) también en función de si trabaja sobre una IP o si lo hace sobre un nombre de dominio. Además, antes es muy probable que no hubiese encontrado el repositorio porque ni siquiera pudiese haber llegado a él, dado que cualquier solicitud resultaba en un redirect a gavel.htb que no daba ninguna información nueva.

Conclusión: Hacer futuros escaneos de nmap que vayan a servicios HTTP después de tener el nombre de dominio (si se nos redirige automáticamente), para que nmap pueda usar sus scripts completos.

Si miramos los archivos en el .git, encontramos http://gavel.htb/.git/config:

Gracias a que en el .git se guardan los cambios realizados (commits), estructuras de directorios y backups completos, podemos reconstruir el directorio al 100% si lo descargamos:

Y tenemos el código fuente. Haciendo algo más de enumeración:

  • En includes/config.php:

SQLi (de nuevo)

Aunque antes hayamos hecho una enumeración muy leve de SQLi, conviene buscar más ahora que tenemos el código fuente delante.

Normalmente en las conexiones PHP-DB se usan prepared statements con parámetros. Primero se envía al servidor SQL una plantilla del query y luego los valores del usuario se pasan por separado para que la DB sepa que los valores del usuario son datos sin más.

La vulnerabilidad aparece cuando el desarrollador concatena directamente el input del usuario dentro del string SQL antes de ejecutarlo, lo que hace que se pase un query SQL ya modificado por el usuario a la DB.

Esto está bien:

Esto es vulnerable:

Usar prepare() sin más no hace que la consulta sea segura, lo que determina la seguridad y la potencial vulnerabilidad a SQLi es el usar los parámetros dentro directamente o pasarlos junto con el query aparte.

Si buscamos los queries SQL realizados en todos los archivos del servidor web, y de entre ellos escogemos los que tienen el segundo formato de los anteriores:

Hemos encontrado un caso posiblemente vulnerable, aunque tiene una distinción clave con los explicados anteriormente, y es que el punto vulnerable no está tras un WHERE (user input son datos), sino tras un SELECT (user input es una tabla, columna o db) con $col, que se escapa de forma diferente y se trata de forma diferente.

Explicación: Null Byte SQLi in PDO

Basándonos en la info de algunas páginas (Principalmente SLCyberarrow-up-right) podemos explicar la vulnerabilidad y la posterior explotación.

Partimos de qué es PDO:

PDO (PHP Data Objects) es una extensión de PHP que proporciona una interfaz para acceder a bases de datos (de las extensiones para ello más usadas) desde aplicaciones PHP. Permite usar las mismas funciones para interactuar con diferentes bases de datos, como MySQL, PostgreSQL, SQLite, etc.

Para pasar el input del usuario y la consulta a la base de datos desde un servicio web, idealmente se haría lo siguiente en 2 viajes (Prepared Statement):

  1. Estructura: El servicio web manda la consulta a la DB, pero con un placeholder: SELECT * FROM users WHERE username = ?, y la DB se bloquea, esperando al dato que va en ?

  2. Datos: El servicio web manda a la DB el valor. Como la DB ya tiene el query, mande lo que mande el usuario se tomará como dato, independientemente de si es admin o user' OR 1=1 -- -, la DB lo tratará como texto. Como la estructura ya estaba creada y bloqueada en el primer paso, es imposible que el dato enviado en el segundo paso altere la lógica del query, de ahí que esto sea seguro.

El problema que hace que nuestro caso sea vulnerable es la siguiente línea de la página citada antes:

In fact, PDO emulates all prepared statements in MySQL by default. Unless you explicitly disable PDO::ATTR_EMULATE_PREPARES PDO will actually do all the escaping itself before your query even hits the database.

Esto significa que (por razones históricas), PDO en PHP no usa ese "modelo ideal", sino que emula el prepared statement. PDO actúa como intermediario que toma la consulta con los placeholders (?), la procesa y construye un string único que manda a la base de datos como un solo query. A ojos del desarrollador puede parecer un prepared statement, pero a ojos de la DB, no es un prepared statement, sino una única consulta SQL normal.

Cómo hace PDO esa emulación? Si PDO es el encargado de unir el query con los datos antes de enviarlo a la DB, tiene que buscar dónde están los placeholders (?) para reemplazarlos y luego construir el string con todo.

Podría parecer algo muy simple, pero si el desarrollador pone algo como:

Y se leen y reemplazan indiscriminadamente los ? según se encuentran, el input del usuario "The Who" iría al ? de "Who are you?" y no al de "author_id = ?", rompiendo la consulta. Por eso hace falta un criterio para saber dónde y dónde no sustituir.

Para solucionar eso, los creadores de PHP hicieron un parser dentro del código fuente de PDO que funciona de la siguiente manera:

  • Si se ve una comilla simple (') o un backtick (`), se considera un string literal y no se reemplaza ningún ? hasta que no se cierra el string.

El problema en esto es que el parser define que los caracteres válidos que puede haber dentro de un string literal (entre comillas) con cualquier cosa entre \001 y \377, es decir, que si pasamos un Null Byte (0x00) \0, el parser se lía porque \0 no está en la lista de permitidos, lo que hace que retroceda (backtrack).

Este backtrack provoca que el parser vuelva al inicio de string, pero con el backtick o la comilla original pasando a ser ignorados, lo que provoca que cuando se vea el signo de interrogación del usuario ? se tome como un parámetro válido y se sustituyan los datos en ese ?.

Explotación

Si ahora volvemos al contexto completo:

Vemos que se toma el input, se le quitan los backticks, y se guarda en col. Si nuestro input tenía backticks, el código irá al bloque else que nos llevará al siguiente query:

Aquí controlamos dos elementos:

  • col: Columna, input en el que se borran los backticks.

  • user_id: Parámetro que se pasa de forma segura a execute()

Podemos usar un payload como el mostrado aquíarrow-up-right:

En sort:

  • \ permite escapar la comilla simple que PDO pondrá cuando inyecte nuestro user_id en el ?, el nombre de la columna será literalmente \'x

  • ? es el falso parámetro, aquí irá user_id

  • ;-- - es un comentario de SQL, para que PDO deje de buscar parámetros después de este.

  • %00 es el Null Byte, el causante de la vulnerabilidad. Cuando PDO lo lea la primera vez, retrocederá al inicio del string y dejará de tomar el ? anterior como string.

En user_id:

  • x es un caracter de relleno, valdría cualquiera.

  • (`) sirve para cerrar el string que define el nombre de la columna

  • (...) es una subconsulta SQL

    • El AS dentro fuerza a que la subconsulta devuelva una columna llamada igual que la columna original ('x), dado que si el nombre no es igual la consulta dará un error.

  • ;-- - es un comentario que t

Mandamos el payload codificado para URL y:

Probamos ahora a usar SELECT username en lugar de password y conseguimos el usuario auctioneer:

De todas formas, este usuario estaba también hardcodeado en el código fuente de inventory.php:

Crackeando Hashes

Así que tenemos el usuario auctioneer y 2 hashes, pero uno de ellos pertenece al usuario username creado por nosotros, cuya contraseña es password, así que el otro es de auctioneer.

Los metemos a hashcat con -m 3200 (Blowfish) y sacamos:

Probamos a conectarnos por SSH:

Pero no parece ser para SSH, así que vamos a la web e iniciamos sesión como auctioneer.

Panel de Admin

Entramos al panel de admin y encontramos lo siguiente:

No parece que podamos hacer mucho más que lo que podíamos hacer antes, pero ahora tenemos permiso para modificar 2 cosas:

  • Mensaje de cada elemento que se puja: Potencial XSS? De todas formas, no serviría de mucho ni siquiera para robar cookies porque ya somos el user más privilegiado de la app web.

  • Regla a comprobar cuando un usuario puja: Según como se compruebe esto a nivel de servidor, podríamos conseguir RCE.

Como tenemos acceso al código fuente, podemos echar un ojo a bidding.php, bid_handler.php y a admin.php.

Cuando estamos en bidding.php e intentamos realizar una puja, mandamos un mensaje POST a bid_handler.php, que hace lo siguiente:

  • Comprueba que la puja no ha terminado (sigue activa)

  • Comprueba que nuestra puja es mayor que 0

  • Comprueba que nuestra puja es mayor que la actual

  • Comprueba que tenemos suficiente dinero

  • Comprueba que se cumple la regla custom

Esto último se hace en estas líneas:

  • Se comprueba si existe una regla, y si existe, se borra

  • Se crea una nueva regla en base a lo que hay en $rule

  • Se establece el parámetro $allowed en función a si se cumple o no la regla.

El motivo por el cual primero se borra una posible regla existente (runkit_function_remove) y luego se crea de nuevo (runkit_function_add) puede ser para actualizar la regla si el administrador la ha cambiado recientemente, porque si no se haría la comprobación sobre una regla obsoleta.

Por otro lado, no sé del todo cómo funciona runkit_function_add más allá de la generalización de que "crea una función", así que tendremos que mirar más a fondo. Tras mirar en un manual de PHP:

Así que en este caso:

Es decir, que podemos meter código arbitrario directamente, como:

Lo metemos a una puja, intentamos comprarla y:

Privesc 1: www-data -> auctioneer

Una vez hemos entrado como www-data y tras hacer un poco de enumeración, encontramos lo siguiente:

Puede ser que se trate de un cronjob que además se ejecuta como root, así que miramos en /etc:

Ahí vemos un php:

Pero resulta no ser nada relevante.

Aunque antes hayamos probado con las credenciales auctioneer:midnight1 y no funcionase, pruebo de nuevo a hacer su auctioneer:

El motivo por el que ahora nos ha dejado pero antes no nos dejaba entrar por ssh es el siguiente:

Hay una configuración explícita que hace que no podamos conectarnos por ssh aunque sepamos la contraseña.

Privesc 2: auctioneer -> root

Antes ya habíamos encontrado el archivo /invoice.txt, podemos intentar buscar de dónde sale porque posiblemente sea el vector de escalada de privilegios que buscamos. Dado que el archivo lo había creado root pero todo lo que tiene que ver con la web se ejecuta con privilegios menores (www-data), es posible que se trate de un script o binario en el sistema.

Tras una búsqueda, encontramos el binario /usr/local/bin/gavel-util, que hace lo siguiente:

Además encontramos un directorio /opt/gavel con varios archivos:

Si nos fijamos, el archivo gaveld es además un servicio en ejecución ejecutándose como root:

Si miramos exactamente qué syscalls hace gavel-util stats cuando lo ejecutamos:

Vemos que efectivamente intenta conectarse con /var/run/gaveld.sock (un socket de Unix), que, dado el nombre, podemos intuir que es /opt/gavel/gaveld.

Reverse Engineering

Es muy probable que simplemente hubiese que fijarse en cómo reaccionan gaveld y gavel-util ante ciertos inputs, y simplemente mandar uno malicioso una vez se supiese qué les podría hacer fallar, pero por curiosidad, decido copiar gavel-util a mi máquina Kali y descompilarlo con Ghidra.

Ahí encuentro varias funciones:

Tenemos varias funciones, pero las más relevantes son handle_conn() y php_safe_run().

Resumen funcionamiento gaveld

El proceso de vida del daemon y en específico de una regla que pasamos con submit sería el siguiente:

  1. INICIO DEL DAEMON y CONEXIÓN

  • main() crea el socket /var/run/gaveld.sock y lo pone en escucha. Luego espera conexiones entrantes.

  • Cuando llega una conexión, crea un proceso hijo y la redirige a él. El proceso hijo inicia handle_conn()

  • handle_conn() comprueba usuario y grupo de quien se conecta, si es correcto parsea el contenido recibido como un objeto JSON.

  • Del objeto JSON saca el campo "op", que debe ser "submit" o "stats", si no da error.

  1. PARSEO YAML, PASO A PHP_SAFE_RUN

  • Si el campo es "submit", inicia un parser de YAML que extrae los valores name, description, image, price, rule_msg y rule del cuerpo.

  • Si todos los campos existen y la longitud de rule es menor a 1KiB, se pasa la regla a php_safe_run()

  1. EJECUCIÓN DE LA REGLA

  • Busca una clave "env" y dentro una subclave "RULE_PATH".

  • Si NO la encuentra, usa el valor default /opt/gavel/.config/php/php.ini, si la encuentra, usa el archivo al que señale.

  • Toma el parámetro arg_formato pasado a la función y formatea una string usándolo en un placeholder:

Que en PHP haría

Posteriormente, hace:

Esto ejecuta en el shell el array args, que resulta ser esto:

Analizando gavel-util

Dado que sabemos que el php.ini por defecto bloquea funciones peligrosas y limita nuestro directorio operativo:

Es necesario pasar un parámetro en el json con la ruta a un php.ini arbitrario que no contenga estas limitaciones. El problema es que nosotros no especificamos los datos del json, sino que de eso se encarga el cliente gavel-util. Para ello, podemos mirar cómo funciona gavel-util por dentro. Tras descompilar parte de su código y relacionarlo con el de gaveld, su funcionamiento (al usar submit) es algo así:

  1. Comprueba que el archivo sea del tamaño (<10MB) y formato adecuado.

  2. Abre el archivo y deja todo su contenido en un buffer.

  3. Construye un json de la siguiente forma:

Y, tras mirar la función collect_env(), vemos que toma las variables de entorno y las guarda en un objeto json. Esto significa que ni siquiera hace falta que creemos un script que simule gavel-util, basta con ejecutarlo con la variable de entorno RULE_PATH puesta a cualquier valor que queramos.

Explotación

Creamos un ex.yaml:

Ponemos RULE_PATH a un archivo cualquiera que exista, no necesariamente con un formato de php.ini válido. gaveld simplemente buscaba el archivo, miraba que existiese y comprobaba que tuviese permisos, pero si lo encuentra, lo intenta leer, y no lo entiende, simplemente lo tomará como una configuración "vacía", es decir, sin restricciones. Ejecutamos gavel-util:

Pero si buscamos, no aparece nada.

Tras una búsqueda por internet y un rato de debugging, veo que puede ser por dos motivos a la vez:

  • Si el binario se ejecuta mediante systemd, es posible que tenga la directiva PrivateTmp=yes, que hace que tenga su propio directorio privado en /tmp y no pueda acceder a otros allí porque cree que su directorio privado es el /tmp real. No puede escribir a "nuestro" /tmp.

    • Solución: Usar otro directorio, p.ej /opt/gavel

  • El binario del daemon tenía unos límites muy estrictos de memoria, tiempo de cpu y, sobre todo, número de procesos hijos: Un máximo de 4. Si por algún motivo se ha llegado ya al límite, cualquier cosa con shell_exec() o exec() no funcionará porque implica crear un proceso hijo del shell.

    • Solución: Usar funciones de php directamente que no creen procesos hijos (como copy() o chmod()).

Así que modificamos el payload:

Probamos a mandarlo de nuevo:

Y tenemos root.

Última actualización