Сколько было сломано копий при обсуждении вопроса «Возможно ли сделать eval
безопасным?» — невозможно сосчитать. Всегда находится кто-то, кто утверждает, что нашёл способ оградиться от всех возможных последствий выполнения этой функции.
Когда мне понадобилось найти развёрнутый ответ на этот вопрос, я наткнулся на один пост. Меня приятно удивила глубина исследования, так что я решил, что это стоит перевести.
Коротко о проблеме
В Python есть встроенная функция eval()
, которая выполняет строку с кодом и возвращает результат выполнения:
assert eval("2 + 3 * len('hello')") == 17
Это очень мощная, но в то же время и очень опасная инструкция, особенно если строки, которые вы передаёте в eval
, получены не из доверенного источника. Что будет, если строкой, которую мы решим скормить eval
'у, окажется os.system('rm -rf /')
? Интерпретатор честно запустит процесс удаления всех данных с компьютера, и хорошо ещё, если он будет выполняться от имени наименее привилегированного пользователя (в последующих примерах я буду использовать clear
(cls
, если вы используете Windows) вместо rm -rf /
, чтобы никто из читателей случайно не выстрелил себе в ногу).
Какие есть решения?
Некоторые утверждают, что возможно сделать eval
безопасным, если запускать его без доступа к символам из globals. В качестве второго (опционального) аргумента eval()
принимает словарь, который будет использован вместо глобального пространства имён (все классы, методы, переменные и пр., объявленные на «верхнем» уровне, доступные из любой точки кода) кодом, который будет выполнен eval
'ом. Если eval
вызывается без этого аргумента, он использует текущее глобальное пространство имён, в которое мог быть импортирован модуль os
. Если же передать пустой словарь, глобальное пространство имён для eval
'а будет пустым. Вот такой код уже не сможет выполниться и возбудит исключение NameError: name 'os' is not defined
:
eval("os.system('clear')", {})
Однако мы всё ещё можем импортировать модули и обращаться к ним, используя встроенную функцию __import__
. Так, код ниже отработает без ошибок:
eval("__import__('os').system('clear')", {})
Следующей попыткой обычно становится решение запретить доступ к __builtins__
изнутри eval
'a, так как имена, подобные __import__, доступны нам потому, что они находятся в глобальной переменной __builtins__
. Если мы явно передадим вместо неё пустой словарь, код ниже уже не сможет быть выполнен:
eval("__import__('os').system('clear')", {'__builtins__':{}}) # NameError: name '__import__' is not defined
Ну а теперь-то мы в безопасности?
Некоторые говорят, что «да» и совершают ошибку. Для примера, вот этот небольшой кусок кода вызовет segfault
, если вы запустите его в CPython:
s = """
(lambda fc=(
lambda n: [
c for c in
().__class__.__bases__[0].__subclasses__()
if c.__name__ == n
][0]
):
fc("function")(
fc("code")(
0,0,0,0,"KABOOM",(),(),(),"","",0,""
),{}
)()
)()
"""
eval(s, {'__builtins__':{}})
Итак, давайте разберёмся, что же здесь происходит. Начнём с этого:
().__class__.__bases__[0]
Как многие могли догадаться, это просто один из способов обратиться к object
. Мы не можем просто написать object
, так как __builtins__
пусты, но мы можем создать пустой кортеж (тьюпл), первым базовым классом которого является object
и, пройдясь по его свойствам, получить доступ к классу object
.
Теперь мы получаем список всех классов, которые наследуют object
или, иными словами, список всех классов, объявленных в программе на данный момент:
().__class__.__bases__[0].__subclasses__()
Если заменить для удобочитаемости это выражение на ALL_CLASSES
, нетрудно будет заметить, что выражение ниже находит класс по его имени:
[c for c in ALL_CLASSES if c.__name__ == n][0]
Далее в коде нам надо будет дважды искать класс, так что создадим функцию:
lambda n: [c for c in ALL_CLASSES if c.__name__ == n][0]
Чтобы вызвать функцию, надо как-то её назвать, но, так как мы будем выполнять этот код внутри eval
'a, мы не можем ни объявить функцию (используя def
), ни использовать оператор присвоения, чтобы привязать нашу лямбду к какой-нибудь переменной.
Однако, есть и третий вариант: параметры по умолчанию. При объявлении лямбды, как и при объявлении любой обычной функции, мы можем задать параметры по умолчанию, так что если мы поместим весь код внутри ещё одной лямбды, и зададим ей нашу, как параметр по умолчанию, — мы добьёмся желаемого:
(lambda fc=(
lambda n: [
c for c in ALL_CLASSES if c.__name__ == n
][0]
):
# теперь мы можем обращаться к нашей лямбде через fc
)()
Итак, мы имеем функцию, которая умеет искать классы, и можем обращаться к ней по имени. Что дальше? Мы создадим объект класса code
(внутренний класс, его экземпляром, например, является свойство func_code
объекта функции):
fc("code")(0,0,0,0,"KABOOM",(),(),(),"","",0,"")
Из всех инициализующих параметров нас интересует только «KABOOM». Это и есть последовательность байт-кодов, которую будет использовать наш объект, и, как вы уже могли догадаться, эта последовательность не является «хорошей». На самом деле любого байт-кода из неё хватило бы, так как всё это — бинарные операторы, которые будут вызваны при пустом стеке, что приведёт к segfault
'у CPython. "KABOOM" просто выглядит забавнее, спасибо lvh за этот пример.
Итак, у нас есть объект класса code
, но напрямую выполнить его мы не можем. Тогда создадим функцию, кодом которой и будет наш объект:
fc("function")(CODE_OBJECT, {})
Ну и теперь, когда у нас есть функция, мы можем её выполнить. Конкретно эта функция попытается выполнить наш некорректно составленный байт-код и приведёт к краху интерпретатора.
Вот весь код ещё раз:
(lambda fc=(lambda n: [c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__ == n][0]):
fc("function")(fc("code")(0,0,0,0,"KABOOM",(),(),(),"","",0,""),{})()
)()
Заключение
Итак, надеюсь теперь ни у кого не осталось сомнений в том, что eval
НЕ БЕЗОПАСЕН, даже если убрать доступ к глобальным и встроенным переменным.
В примере выше мы использовали список всех подклассов класса object
, чтобы создать объекты классов code
и function
. Точно таким же образом можно получить (и инстанцировать) любой класс, существующий в программе на момент вызова eval()
.
Вот ещё один пример того, что можно сделать:
s = """
[
c for c in
().__class__.__bases__[0].__subclasses__()
if c.__name__ == "Quitter"
][0](0)()
"""
eval(s, {'__builtins__':{}})
Модуль lib/site.py содержит класс Quitter
, который вызывается интерпретатором, когда вы набираете quit()
.
Код выше находит этот класс, инстанциирует его и вызывает, чем завершает работу интерпретатора.
Сейчас мы запускали eval
в пустом окружении, исходя из того, что указанный в статье код — это весь код нашей программы.
В случае использования eval
'а в реальном приложении злоумышленник может получить доступ ко всем классам, которые вы используете, так что его возможности не будут ограничены практически ничем.
Проблема всех подобных попыток сделать eval
безопасным в том, что они все основаны на идее «чёрных списков», идее о том, что надо убрать доступ ко всем вещам, которые, как нам кажется, могут быть опасны при использовании в eval
'е. С такой стратегией практически нет шансов на победу, ведь если окажется незапрещённым хоть что-то, система будет уязвима.
Когда я проводил исследование этой темы, я наткнулся на защищенный режим выполнения eval
'а в Python, который является ещё одной попыткой побороть эту проблему:
>>> eval("(lambda:0).func_code", {'__builtins__':{}})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<string>", line 1, in <module>
RuntimeError: function attributes not accessible in restricted mode
Вкратце, он работает следующим образом: если __builtins__
внутри eval
отличаются от «официальных» — eval переходит в защищенный режим, в котором закрыт доступ к некоторым опасным свойствам, таким как func_code
у функций. Более подробное описание этого режима можно найти тут, но, как мы уже видели выше, он тоже не является «серебряной пулей».
И всё-таки, можно ли сделать eval
безопасным? Сложно сказать. Как мне кажется, злоумышленнику не удастся навредить без доступа к объектам с двумя нижними подчёркиваниями, обрамляющими имя, так что возможно, если исключить из обработки все строки с двумя нижними подчёркиваниями, то мы будем в безопасности. Возможно...
P.S.
В треде на Reddit я нашёл короткий сниппет, позволяющий нам в eval получить «оригинальные» __builtins__:
[
c for c in ().__class__.__base__.__subclasses__()
if c.__name__ == 'catch_warnings'
][0]()._module.__builtins__
Традиционное P.P.S. для хабра: прошу обо всех ошибках, неточностях и опечатках писать в личку :)
Автор: Utter_step