И снова про опасность eval()

в 13:06, , рубрики: Совершенный код

Сколько было сломано копий при обсуждении вопроса «Возможно ли сделать 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js