0,0
рейтинг
8 мая 2014 в 17:06

Разработка → И снова про опасность 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 "<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. для хабра: прошу обо всех ошибках, неточностях и опечатках писать в личку :)
Перевод: Ned Batchelder
Владислав Степанов @Utter_step
карма
74,5
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (25)

  • +14
    Вот хоть убей не знаю, зачем вообще кому-то может потребоваться eval, тем более — в Питоне. Только по дурости и/или лености.
    • +18
      Во славу сатаны, конечно же. Даже название намекает.
    • +5
      Насколько я успел заметить, eval часто применяется в задачах из серии кодогенерации. Тот же cog в конечном счёте eval-ит код: bitbucket.org/ned/cog/src/2fbe1f31bd50ba9ef76e3d9fb2d18165a3daeca5/cogapp/cogapp.py

      P.S. я сейчас отвечаю на вопрос, а не оправдываю его применение, если что.)
    • 0
      да и не только в питоне, но и в PHP
    • +16
      Почти во всех скриптовых языках есть eval. Это миллионная статья про то почему eval опасен. Но он не так страшен как его малюют и у него есть хорошее применение.
      В eval нельзя никогда исполнять то что получено из внешнего источника.

      Eval хорош в единственной вещи: метапрограммирование + оптимизация. Самый быстрый mysql драйвер под node.js написан с применением eval. Eval позволяет скомпилировать один раз функцию под конкретную окружающую среду. В случае драйвера mysql генерируется функция для прямого мэппинга отдачи объектов.
      function(data){
        return {id: data[0], name: data[1], date: new Date(data[2])};
      }
      

      В случае написания высоконагруженных приложений рендереры могут стать узким местом, потому почти все библиотеки форматирования даты, заточенные на скорость используют eval. coolDateFormater('Y-m-d') -> вернёт функцию, которая примет на вход дату, а внутри не будет разбирать формат, а сразу возьмёт из даты год, месяц, день и отдаст: Год +'-'+ месяц +'-'+ день. Рендереры не всегда про визуальную часть, в моделях зачастую данные перегоняются в свой формат и на 1к таких операций это уже может дать эффект, а на 10к стать заметным невооруженным глазом.
      • +3
        Лично у меня есть паттерн observable оптимизированный через eval. Вместо обхода списка всех подписчиков — на каждое подписывание перегенерируется функция, которая вызывает всех подписчиков в нужном скоупе с переданными параметрами. Дало ускорение ~30%. На богатых на эвенты моделях такой подход сказался положительно. (Хотя кого я обманываю, я люблю оптимизировать и эта задача была сделана just for fun. Так как скорость решения оказалась выше обычной реализации — стал использовать эту).
        • +2
          А это ускорение было заметно простым смертным?
          • +1
            Там на самом деле надо было заходить к оптимизации с другой стороны, что бы когда добавляешь пачку объектов — выключать рассылку эвентов. Эта оптимизация была сделана потом. Но до этого момента было видно что ворочается чуть менее безнадёжно).
    • 0
      Например, в стандартной библиотеке collections.named_tuple реализован именно через eval (формалньно — через exec, но невелика разница). hg.python.org/cpython/file/ab5e2b0fba15/Lib/collections/__init__.py#l239
      Наверное, более чистым способом было бы аккуратно собрать AST-дерево и скомпилировать его, благо возможности для того есть. Но это сложнее, и, возможно, даже медленнее.
    • –5
      зачем вообще кому-то может потребоваться eval,

      Например (схематично):

      sub create_handler
      {
      my ( $type ) = @_;
      eval «use Engine::$type;»;
      return err_log( «USE Engine::$type error: $@» ) if $@;
      my $obj;
      eval "\$obj = Engine::$type->new();";
      return err_log( «Engine::$type->new() error: $@» ) if $@;
      $obj;
      }
      • 0
        • 0
          Из решений без eval там упомянут только Module::Load. Который всё равно нужно оборачивать в eval, потому что он умирает (die) при ошибках :)
        • 0
          P.S. Да и то, если заглянуть в потроха Module::Load, то обнаружится, что фактически этот модуль — обёртка для того же eval { require $file } :-)
          • 0
            Совершенно не того же. eval с блоком в Perl есть и используется вместо try/catch и абсолютно безопасен. Опасен только eval со строкой. В вашем примере, что бы не находилось в переменной $file будет вызван require с одним аргументом и больше ничего.
    • 0
      eval — очень удобный способ фильтрации чего угодно по сложным пользовательским выражениям (найти все ноутбуки в продаже, от 10 000 до 20 000, но не acer, либо можно acer, но если до 12 000 и выпущен в этом году и памяти больше 8G, либо любой ноут с USB 3.0 если меньше 10 000, и при этом не учитывать предложения фирм, которые в моем черном списке).

      Обычный интерфейс «с галочками» как на яндекс-маркете, во-первых очень сложно реализуется (особенно, если нам его надо для многих видов товара сделать), во-вторых — гораздо менее гибкий (попробуйте-ка вышеописанное выражение выразить в виде «галочек» на форме фильтра в маркете)

      ниже дал ссылку на свой пост с обсуждением этой проблемы и безопасным решением
  • 0
    Есть такое:

    code.activestate.com/recipes/496746-restricted-safe-eval/

    Проблемы с os. запросами решается через setuid setgid seteuid setegid chroot

    Но конечно лучше eval не использовать в оригинале.
  • +1
    В TCL есть замечательная штука — safe interp. Которая как раз решает подобные проблемы. Почему другие языки не возьмут на вооружение эту штуку?
    • 0
      Пробовали, не получается. Есть у меня подозрение, что с TCL оно работает только в силу малараспространённости TCL'я. Причём NSA, скорее всего, умеет вскрывать сервера, использующие safe interp, а остальным оно просто не нужно.
      • 0
        Вы неправы. В TCL оно сделано строго по принципу «запрещено всё, что не разрешено», т.е. по белым спискам (в отличие от того, что например описано в статье выше — там чёрные списки). То есть, взломать конечно можно, если разрешили неправильно (скажем, сделали алиас небезопасной функции в интерп, а проверку при вызове алиаса выполнили недостаточно тщательно), но это не проблема языка или интерпретатора, а явно ошибка в программе, некорректное использование или некорректный (слишком обширный) белый список.
        Сообщений же на тему «escape safe interp» в случае с корректным белым списком не обнаруживается, несмотря на то, что в силу структуры самого языка возможностей «сбежать» там должно быть хоть отбавляй.
  • +1
    docs.python.org/dev/library/ast.html#ast.literal_eval безопасный. Правда, именно поэтому умеет совсем немного.
    • 0
      Да, его проблема в том, что он умеет только загрузить примитивные конструкции. Как json.loads и подобные.
  • 0
    Интересный материал: us.pycon.org/2014/schedule/presentation/208/ — выступление на пайкон 2014 о создании питонячей песочницы.
    слайды
  • 0
    Некропостну со ссылкой на свой пост по этой теме — evalidate: безопасная обработка пользовательских выражений. Тоже очень долго мучался с безопасностью eval'а (при том что от всей его мощности мне нужно всего-то полпроцента), кончилось написанием своего модуля для обработки только безопасных пользовательских выражений.
  • 0
    Как реализовать REPL без eval?
    • +3

      REPL как правило доверенный — а потому проблема безопасного eval в этом случае не стоит

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.