[Перевод] И снова про опасность eval()
Сколько было сломано копий при обсуждении вопроса «Возможно ли сделать 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 »
P.S. В треде на Reddit я нашёл короткий сниппет, позволяющий нам в eval получить «оригинальные» __builtins__: [ c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings' ][0]()._module.__builtins__ Традиционное P.P. S. для хабра: прошу обо всех ошибках, неточностях и опечатках писать в личку :)