Format-string injection

Script simple

Prenons le programme suivant :


mega_secret = "this_is_a_mega_secret"

class User():
    def __init__(self, first_name: str, last_name: str):
        self.first_name = first_name
        self.last_name = last_name
        self.secret = "this_is_a_secret"

    def get_greetings(self):
        greetings = "Bonjour {self.first_name}".format(self=self)
        print("DEBUG -- "+greetings)#pour le debug
        greetings = (greetings+" {self.last_name}").format(self=self)
        return greetings

first_name = input("Indiquez votre prénom : ")
last_name = input("Indiquez votre nom de famille : ")
user = User(first_name, last_name)
print(user.get_greetings())

La fonction get_greetings formate une première chaine, puis formate le résultat du formatage une deuxième fois (après y avoir concaténé {self.last_name}). Remarquez deux secrets : un dans l’objet user, qui est user.secret, et un autre global qui est mega_secret.

Dans une utilisation normale, le flux d’exécution serait le suivant :

Indiquez votre prénom : Forrest
Indiquez votre nom de famille : Gump
DEBUG -- Bonjour Forrest
Bonjour Forrest Gump

La première format-string est « Bonjour {self.fist_name} » et la seconde est « Bonjour Forrest {self.last_name} ».

Testons quelque chose d’un peu original :

Indiquez votre prénom : {self.last_name}
Indiquez votre nom de famille : lol
DEBUG -- Bonjour {self.last_name}
Bonjour lol lol

Vous voyez ? Mon prénom est littéralement « {self.last_name} ». La première format-string est « Bonjour {self.fist_name} » et la seconde est donc « Bonjour {self.last_name} {self.last_name} ». Python interprète donc mon prénom « {self.last_name} » comme un placeholder, et le substitue donc par la variable self.last_name donnée en paramètre. Cela peut paraître innocent, mais testons quelque chose de plus malin :

Indiquez votre prénom : {self.secret}
Indiquez votre nom de famille : lol
DEBUG -- Bonjour {self.secret}
Bonjour this_is_a_secret lol

Oups ! Puisque self est donné en paramètre de « format », j’accède à ce que je veux à l’intérieur de user, en particulier self.secret. Ok, mais au moins le méga secret est protégé, non ?

Indiquez votre prénom : {self.__init__.__globals__[mega_secret]}
Indiquez votre nom de famille : lol
DEBUG -- Bonjour {self.__init__.__globals__[mega_secret]}
Bonjour this_is_a_mega_secret lol

Et zut…

Détaillons un peu l’exploit. self.__init__ est le constructeur de User, c’est donc une fonction. Comme n’importe quelle fonction python, c’est un objet comme un autre. Cet objet contient un champ « __globals__ » qui est un dictionnaire qui comporte tous les objets accessibles depuis l’intérieur de cette fonction. En particulier, puisque « mega_secret » est une variable globale, elle est accessible de l’intérieur de self.__init__, d’où sa présence dans le dictionnaire self.__init__.__globals__.

Voici un petit exemple pour mieux comprendre :


secret = "un_secret"

class MyObject():
    def __init__(self):
        pass

if __name__ == "__main__":
    print(MyObject().__init__.__globals__["secret"])

Donne :

un_secret

Et :


from test_1 import MyObject

class MyObject2():
    def __init__(self):
        pass

if __name__ == "__main__":
    print(MyObject().__init__.__globals__["secret"])
    print(MyObject2().__init__.__globals__["secret"])

Donne :

un_secret
Traceback (most recent call last):
File "./test_2.py", line 10, in <module>
    print(MyObject2().__init__.__globals__["secret"])
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
KeyError: 'secret'

Revenons à nos moutons : le programme « greetings » pourrait simplement être corrigé en formatant la string en un coup et non en deux coups. La fonction get_greetings deviendrait :

def get_greetings(self):
    greetings = "Bonjour {self.first_name} {self.last_name}".format(self=self)
    return greetings

On pourrait aussi complètement abandonner le .format pour passer aux f-strings, qui, par design, ne permettent pas de laisser glisser des inputs utilisateurs dans la « string to be formatted ». La fonction get_greetings ressemblerait à ça :

def get_greetings(self):
    greetings = f"Bonjour {self.first_name} {self.last_name}"
    return greetings

Dans notre cas très simple, les f-strings conviennent très bien. Mais, dans d’autres cas, les f-strings sont limitées. Les f-string n’existent pas au format « string to be formatted », ce qui peut être parfois un problème. Elles sont directement formatées dès leur création. Prenez le programme suivant :

import os

a = 1 
with open(os.path.join("data", "test_save_f_string.txt"), "w") as f:
    f.write(f"Test {a}")

Le fichier enregistré contient :

Test 1

Et non :

Test {a}

CMS

Prenons un exemple réaliste où l’usage du f-string est impossible.

Nota Bene : l’exemple possède aussi d’autres problèmes de sécurité, mais nous n’avons pas souhaité le complexifier inutilement

from fastapi import FastAPI, status, Depends, UploadFile
from fastapi.responses import HTMLResponse
from fastapi.exceptions import HTTPException
import os
import uuid
from pydantic import BaseModel

PAGE_DIR = os.path.join(os.path.dirname(__file__), "data")
def get_page_path_from_id(page_id: str) -> str:
    return os.path.join(PAGE_DIR, page_id+".html")

app = FastAPI()

@app.post("/page", response_model=str, tags=["CMS"])
async def create_new_page(html_file: UploadFile):
    """
    On enregistre une nouvelle page html sur le CMS
    """
    page_id = str(uuid.uuid4())
    with open(get_page_path_from_id(page_id), "wb") as f:
        f.write(await html_file.read())
    return page_id

class User(BaseModel):
    first_name: str
    last_name: str

    def get_complete_name(self) -> str:
        return f"{self.first_name} {self.last_name}"

@app.get("/page", response_class=HTMLResponse, tags=["CMS"])
def read_page(
    page_id: str, 
    first_name: str,
    last_name: str,
):
    user = User(first_name=first_name, last_name=last_name)
    print(TOKEN)
    print(user.get_complete_name.__globals__)
    try:
        with open(get_page_path_from_id(page_id), "r") as f:
            return HTMLResponse(f.read().format(user=user))
    except FileNotFoundError:
        raise HTTPException(status.HTTP_404_NOT_FOUND, detail="La page n'existe pas")
    
TOKEN = "mon_token_secret"

@app.post("/admin/pages", response_model=list[str], tags=["ADMIN"])
async def list_page(token :str):
    """
    Liste tous les fichiers qui ont été enregistrés par les utilisateurs
    """
    if token != TOKEN:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Vous ne pouvez pas")
    return os.listdir(PAGE_DIR)

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("app:app", port=8000)

Il s’agit d’un CMS partagé qui permet aux utilisateurs (potentiellement plusieurs) d’uploader leurs propres pages html et de les lire ensuite. Il y a une route pour rajouter une nouvelle page (POST /page), et une route pour lire une page (GET /page). La route GET /page permet de personnaliser la page en fonction de l’utilisateur qui la lit. Il y a également une route admin qui liste tous les fichiers html déposés par les utilisateurs. Cette route ne peut être appelée que par ceux qui connaissent le token secret.

Nota Bene : l’usage d’un uuid4 assure deux choses :

  • il empêche que différents utilisateurs s’embêtent en choisissant le même nom de fichier html (qui est toujours unique dans notre cas grâce à uuid4),

  • et il empêche un utilisateur de lire les pages d’un autre utilisateur (car pour cela il doit deviner un uuid4 existant, ce qui est quasi impossible).

C’est un CMS simple, mais ça fait le job.

Voici un flux d’exécution normal. On commence par créer une nouvelle page :

curl -X 'POST' \
    'http://127.0.0.1:8000/page' \
    -H 'accept: application/json' \
    -H 'Content-Type: multipart/form-data' \
    -F 'html_file=@test.html;type=text/html'

Avec test.html :

<html>
    <head></head>
    <body>
        <div>
            Bonjour {user.get_complete_name()} !
        </div>
    </body>
</html>

Disons que la route renvoie « 12346 » comme page_id.

On va ensuite lire la page :

curl -X 'GET' \
    'http://127.0.0.1:8000/page?page_id=12346&first_name=Forrest&last_name=Gump' \
    -H 'accept: text/html'

Et l’on obtient le html :

<html>
    <head></head>
    <body>
        <div>
            Bonjour Forrest Gump !
        </div>
    </body>
</html>

Très bien, c’est ce que l’on attendait. Testons maintenant nos exploits précédents. On upload une page malveillante :

<html>
    <head></head>
    <body>
        <div>
            Le token est : {user.get_complete_name.__globals__[TOKEN]}
        </div>
    </body>
</html>

On la lit avec la route GET /page, et on obtient :

<html>
    <head></head>
    <body>
        <div>
            Le token est : mon_token_secret
        </div>
    </body>
</html>

Vous aurez remarqué que l’exploit n’utilise plus __init__ mais get_complete_name : c’est parce que __init__ est écrit dans la classe BaseModel dont hérite User. Cette classe est écrite dans le module pydantic, et donc user.__init__.__globals__ donne accès au contexte du module pydantic et non pas le contexte de notre fichier .py où est défini la variable TOKEN.

Si l’on ne peut pas utiliser de f-string, comment pouvons-nous corriger cette faille ?

Tentatives de correction

La première chose à faire serait de ne pas passer l’objet user en entier à .format, car ce serait donner au formatage un contexte contenant beaucoup trop d’informations. Au lieu d’avoir :

return HTMLResponse(f.read().format(user=user))

il faudrait :

return HTMLResponse(f.read().format(first_name=user.first_name, last_name=user.last_name))

Les attributs user.first_name et user.last_name sont de type str qui sont des built-in. Autant que je sache, il n’est pas possible de faire grand-chose avec un str. Il n’est même pas possible de faire first_name.__init__.__globals__ car les built-in ne semblent pas avoir de __globals__. Si un lecteur trouve un moyen d’exploiter le code corrigé, je serais extrêmement curieux de savoir comment (mon mail est en bas de la page).

Pour éviter la tentation d’introduire des objets trop complexes dans le contexte du formatage (comme l’objet user par exemple), il est conseillé d’utiliser Template du module string, plutôt que le .format (https://docs.python.org/3/library/string.html#template-strings) :

from string import Template

return HTMLResponse(Template(f.read()).safe_substitute(first_name=user.first_name, last_name=user.last_name))

Cela peut faire guise de garde-fou. Prenez le code suivant, dans lequel on utilise un Template, mais où l’on a malencontreusement donné en contexte l’objet user :

from string import Template
from pydantic import BaseModel

mega_secret = "this_is_a_mega_secret"

class User(BaseModel):
    first_name: str
    last_name: str
    secret: str

user = User(first_name="Forrest", last_name="Gump", secret="I love Jenny")

print(Template("Bonjour ${first_name}").safe_substitute(first_name=user.first_name))
print(Template("Bonjour ${user.secret}").safe_substitute(user=user))
print(Template("Bonjour ${user.__init__.__globals__[mega_secret]}").safe_substitute(user=user))

Nous obtenons en sortie :

Bonjour Forrest
Bonjour ${user.secret}
Bonjour ${user.__init__.__globals__[mega_secret]}

Ouf ! les secrets sont bien gardés ! Vraiment ? Prenez l’exemple suivant, légèrement modifié :

from string import Template
from pydantic import BaseModel

class User(BaseModel):
    first_name: str
    last_name: str
    secret: str

user = User(first_name="Forrest", last_name="Gump", secret="I love Jenny")

print(Template("Bonjour ${first_name}").safe_substitute(first_name=user.first_name))
print(Template("Test ${user}").safe_substitute(user=user))

On obtient :

Bonjour Forrest
Test first_name='Forrest' last_name='Gump' secret='I love Jenny'

Aïe ! La classe User implémente une méthode __str__ (implémentée dans BaseModel dont User hérite). Cette méthode renvoie une représentation de l’objet, et celle-ci contient tous les attributs de l’objet, donc le secret. Le garde-fou n’est donc pas parfait…

Ok, ainsi, passer en argument les attributs qui composent user au lieu de donner user est un début de solution, à condition de faire très attention au fait que ces attributs doivent être des types built-in. Mais, comment faire si vous avez réellement besoin que l’utilisateur utilise l’objet user (pour accéder à la méthode user.get_complete_name par exemple) ? Spoiler : vous ne pouvez pas.

Vous pourriez vous dire qu’il faut isoler au maximum la définition de la classe User du reste du code, en la définissant dans un autre fichier. Et vous auriez à moitié raison.

Par exemple :

import pydantic
import os

class User(pydantic.BaseModel):
    first_name: str
    last_name: str

    def get_complete_name(self) -> str:
        return f"{self.first_name} {self.last_name}"
from User import User

secret = "this_is_a_secret"

if __name__ == "__main__":
    print(User.get_complete_name.__globals__["secret"])

Donne :

Traceback (most recent call last):
File "./test_1.py", line 6, in <module>
    print(User.get_complete_name.__globals__["secret"])
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
KeyError: 'secret'

Mais, vous n’avez pas pensé aux modules que vous chargez dans le fichier User. Par exemple, si le fichier User.py contient un

import os

comme dans notre exemple, alors vous pouvez y accéder grâce à __globals__. Et là, c’est la porte ouverte à toutes les dérives :

from User import User

secret = "this_is_a_secret"

if __name__ == "__main__":
    print(User.get_complete_name.__globals__['os'].system("cat ./User.py"))

qui renvoie bien le contenu du fichier, donc le secret. Les plus perspicaces auront remarqué que nous ne sommes pas limités à la lecture de User.py, et que l’on aurait aussi pu faire un :

print(User.get_complete_name.__globals__['os'].system("cat /etc/shadow"))

Mais, même si vous faites attention à ce que vous importez dans le fichier User.py, vous ignorez ce qu’importent les modules que vous importez. Par exemple, notre fichier User.py importe pydantic, et le module pydantic.dataclasses contient un

import sys

On peut donc faire :

from User import User

secret = "this_is_a_secret"

if __name__ == "__main__":
    print(User.get_complete_name.__globals__['pydantic'].dataclasses.sys.modules["os"].system("cat User.py"))

Bon, vous l’aurez compris : ne faites pas de *.format* sur un input utilisateur, il y aura toujours moyen de faire n’importe quoi. Toutes les tentatives de résolution n’ont pas permis de corriger correctement la faille.

De façon générale : NE JAMAIS LAISSER LA POSSIBILITÉ A L’UTILISATEUR D’ÉCRIRE DANS UNE CHAINE QUI SERA EXÉCUTÉE ! Que le code en question soit du SQL, du LDAP, ou comme dans notre cas, du Python, même si le contexte d’exécution est réduit.

Dans notre exemple de CMS, le mieux serait de supprimer la fonctionnalité qui permet de personnaliser la page en fonction de l’utilisateur, et laisser le javascript de la page html personnaliser le contenu en fonction des paramètres de l’url.