Journal Chaînes de formatage et sécurité en python (solution au "Petit Défi Python")

Posté par  (site web personnel) . Licence CC By‑SA.
46
22
jan.
2020

Sommaire

La semaine dernière, je vous proposais un défi de cybersécurité en python. Si vous ne l'avez pas encore vu, allez tenter votre chance sur Github avant de lire la suite de ce journal, ce sera plus intéressant.

La vulnérabilité

La première étape du défi était de trouver où était la faille de sécurité. L'application étant toute simple, ce n'était pas très difficile. Le script python contient les deux lignes suivantes:

to_format = f"Printing a {self.width}-character wide box: [Age: {{self.age:{self.width}}} ]"
print(to_format.format(self=self))

Les variables self.width et self.age sont toutes deux de type str.
Après l'exécution de la première ligne, la variable to_format contient une chaîne de caractères dont le contenu est en partie contrôlé par l'utilisateur. Ensuite, str.format est appelé sur cette chaîne. Le problème est donc l'utilisation successive de deux formatages de chaîne de caractères, laissant un attaquant fournir une chaîne de formatage malicieuse qui lui permettra d'extraire des informations qui sont dans des variables auxquelles il n'aurait sinon pas accès.

L'exploitation

Simplification

Allons-y progressivement et commençons par ignorer que l'on ne contrôle pas la totalité de la variable to_format. Considérons l'exemple simplifié suivant:

SECRET = 'super secret'

class Sandbox:
    def run(self):
        s = input()
        print(s.format(self=self))

Ici, nous contrôlons totalement ce qui est passé à str.format. Par exemple, si on rentre {self}, on voit s'afficher <__main__.Sandbox object at 0x10ccde3c8>. Très bien, on peut commencer à extraire des informations…

Extraire une variable globale

Mais comment accéder à la variable SECRET, qui est définie dans l'espace de noms global et non pas dans Sandbox ?

Pour cela, il faut comprendre un peu comment fonctionne l'interpréteur python. Cet interpréteur doit être capable d'exécuter une fonction en prenant simplement la fonction et ses paramètres. Comme on peut définir une fonction qui fait référence à des données qui lui sont extérieures, il doit garder à l'intérieur de la fonction un objet qui contient les variables de l'espace de noms dans lequel la fonction est définie. Cet objet est accessible depuis le code python lui-même, et est documenté dans la documentation officielle de python :

__globals__ : A reference to the dictionary that holds the function’s global variables — the global namespace of the module in which the function was defined. Read-only

Très bien, essayons d'accéder à cet objet pour la méthode run de notre objet. Nous rentrons dans le script {self.run.__globals__}, et nous voyons s'afficher:

{'__name__': '__main__', '__doc__': None, '__package__': None, 
'__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x1075aef60>, 
'__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, 
'__file__': 'test.py', '__cached__': None, 
'SECRET': 'super secret', 
'Sandbox': <class '__main__.Sandbox'>}

Hourra ! Nous avons extrait le secret.

Utiliser une chaîne de formatage

Le problème

Bon, ne nous emballons pas trop vite. Le défi original avait une petite subtilité qui n'est pas dans notre exemple de la partie précédente: nous ne contrôlons pas la totalité de la chaîne de caractères à formater. La chaîne vulnérable est définie comme:

to_format = f"Printing a {self.width}-character wide box: [Age: {{self.age:{self.width}}} ]"

ce que l'on peut réécrire de manière plus intéressante pour notre analyse comme :

to_format = "blabla " + self.width + "blabla {self.age:" + self.width + "} blabla "

L'injection que l'on peut faire aura lieu après {self.age:. Il faut donc que ce soit un spécificateur de format valide en python. Les spécificateurs de format sont à première vue plutôt limités. On peut par exemple mettre .3f pour afficher un nombre avec 3 chiffres après la virgule. Ça ne nous avance pas beaucoup…

Les spécificateurs de formatage emboîtés

En lisant la documentation attentivement, on tombe sur ce passage intéressant:

A format_spec field can also include nested replacement fields within it. These nested replacement fields may contain a field name, conversion flag and format specification, but deeper nesting is not allowed. The replacement fields within the format_spec are substituted before the format_spec string is interpreted. This allows the formatting of a value to be dynamically specified.

On peut donc utiliser une chaîne de formatage à l'intérieur d'une autre chaîne de formatage.
À l'origine, cette fonctionnalité sert à faire des choses comme:

'{x:.{y}f}'.format(x=1, y=3) # Retourne '1.000'

Pas super utile dans la vie réelle, mais génial pour nous ! Nous allons pouvoir rentrer une valeur entre accolades, et elle sera interprétée par python. Mais nous allons devoir faire attention à ce que toute la partie entre {self.age: et } reste une chaîne de formatage valide.

L'alignement

Il nous reste donc à trouver comment former une chaîne de formatage valide avec la valeur que l'on va extraire, quelle qu'elle soit. On va pour cela utiliser les fonctions d'alignement.

Python permet d'utiliser le signe > pour aligner la valeur que l'on veut afficher à droite, en utilisant la caractère que l'on veut pour compléter l'alignement:

'{x: >3}'.format(x=1) # '  1'
'{x:a>3}'.format(x=1) # 'aa1'

Formidable ! c'était le chaînon manquant. On va donc utiliser cette fonctionnalité d'alignement, et utiliser comme caractère de remplissage un caractère extrait grâce à notre système de spécificateurs de formatage emboîtés. On pourra ainsi extraire un par un tous les caractères du secret.

Solution

En combinant toutes les étapes précedentes, on obtient donc :

{self.ask_secret.__globals__[SECRET][0]}>9

Il ne nous reste plus qu'à faire un petit script pour extraire les caractères un par un, et le tour est joué !

Conclusion

J'espère que ce petit défi vous a intéressé et appris de nouvelles choses sur python. Si vous voulez revoir d'autres défis du même type, n'hésitez pas à plussoyer ce journal et laisser des commentaires.

  • # Impressionnant

    Posté par  (site web personnel) . Évalué à 10.

    Je connais bien le langage Python et j'ai fini par trouver la solution mais j'aurai jamais réussi sans les trucs filés dans les commentaires. Ça me confirme que ma carrière de hacker est pas prêt de décoller :-)

    Sinon, outre l'erreur de laisser l'utilisateur contrôler la chaîne de formattage, le script traîne aussi l'erreur de laisser la largeur en l'état de string. Puisqu'on attend un entier, autant la convertir tout de suite en entier:

    self.width = int(input("How wide do you want the nice box to be ? "))
    Et là, plus de faille de sécurité.

    Bon, sinon, on constate plus il y a de magie dans le langage, plus il s'expose à des failles soit de surface d'attaque, soit de mauvaise compréhension des utilisateurs. Et dans Python, il y a beaucoup de magie…

  • # Chouette !

    Posté par  (site web personnel) . Évalué à 8.

    J'espère que ce petit défi vous a intéressé et appris de nouvelles choses sur python. Si vous voulez revoir d'autres défis du même type, n'hésitez pas à plussoyer ce journal et laisser des commentaires.

    Très ludique, ça permet d'explorer, de découvrir des choses, j'<3

  • # Très intéressant

    Posté par  . Évalué à 4.

    Merci !

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.