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.
| |
Reconnaissance
Nmap
| |
| |
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/hostsbefore 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:
| |
Each vhost is a distinct backend behind the nginx proxy:
| Host | Purpose |
|---|---|
craft.htb | Static marketing site |
api.craft.htb | REST API (Swagger UI, token-protected endpoints) |
gogs.craft.htb | Self-hosted Gogs Git service |
vault.craft.htb | HashiCorp 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:
| |
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, notX-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/loginuses HTTP Basic Auth to hand out a JWT. Without credentials it just returnsAuthentication 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:
| |
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:
| |
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:
| |
Initial Access
Authenticating to the API
With dinesh’s credentials we can mint a JWT via Basic Auth:
| |
| |
Verifying the token works:
| |
| |
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:
| |
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:
| |
When the payload fires, the API request blocks (the shell holds the connection open), so nginx eventually returns:
| |
That 504 is the success signal, not a failure - meanwhile the listener catches the shell:
| |
| |
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:
| |
/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:
| |
netstat confirms the container’s place in the Docker network and shows the MySQL
backend it talks to:
| |
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:
| |
| |
A brief dead end here: dropping a standalone
showTables.pyintocraft_api/and running it failed withModuleNotFoundError: 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/appsidesteps the packaging issue entirely.
The user table is the prize:
| |
| |
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:
- A
.sshdirectory with anid_rsa/id_rsa.pubkeypair belonging to gilfoyle. - A
vault/secrets.shscript 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.
| |
| |
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:
| |
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:
| |
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:
| |
| |
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 clearingVAULT_TOKEN- broke everything withconnection refused. Vault is not listening on the host’s localhost; it runs in thevaultcontainer reachable via theVAULT_ADDRthat the shell was already configured with (vault.craft.htb:8200), authenticated by the.vault-tokenthat 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_ADDRand let the CLI pick up~/.vault-tokenon its own, after which the OTP request succeeded immediately.
With the OTP entered, we get a root shell on the host:
| |
Detection and Mitigation
| Stage | Root cause | Mitigation |
|---|---|---|
| Credential leak in Git history | A 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 input | Unsanitized abv interpolated into eval('%s > 1' % ...) | Parse and range-check numeric input with float()/Decimal, never evaluate it |
| Cleartext password storage | user table stored plaintext passwords | Hash passwords with a slow, salted algorithm (bcrypt/argon2) |
| Credential reuse | One DB password unlocked Gogs and an SSH key passphrase | Unique credentials per system; no password reuse across service/DB/SSH |
| Over-privileged Vault token | A root-policy .vault-token sat readable in a user’s home directory | Scope 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
mkfiforeverse shell blocking the HTTP request). - Vault audit log entries for
ssh/creds/root_otprequests followed immediately by an SSH login asrootfrom the same source.
Lessons Learned
- Gitignored is not gone.
settings.pywas 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. Theabv“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-tokenin a user’s home directory hands that user full root SSH via theroot_otprole.
Command Reference
| |