Обзор SSTI-уязвимостей для приложений, разработанных на Flask/Jinja2

Обзор SSTI-уязвимостей для приложений, разработанных на Flask/Jinja2

Если вы ни разу не слышали об инжектирования в шаблоны на стороне сервера (Server-Side Template Injection, SSTI) или не уверены, что хорошо знаете эту технологию, рекомендую предварительно ознакомиться со статьей Джеймса Кеттла (James Kettle).

Автор: Tim Tomes

Если вы ни разу не слышали об инжектирования в шаблоны на стороне сервера (Server-Side Template Injection, SSTI) или не уверены, что хорошо знаете эту технологию, рекомендую предварительно ознакомиться со статьей Джеймса Кеттла (James Kettle).

Как профессионалы в сфере безопасности мы помогаем организациям, которые принимают решения, несущие определенные риски. Если рассматривать риск в разрезе влияния и вероятности возникновения той или иной ситуации, без истинного знания о влиянии уязвимости мы не сможем правильно рассчитать риск. Недавно Джеймс надоумил меня провести исследование, суть которого сводится к оценке влияния SSTI-уязвимостей на приложения, разработанные на фреймворке Flask и шаблонизаторе Jinja2. Эта статья – результат данного исследования. Если вы хотите чуть больше погрузиться в данную тему, рекомендую ознакомиться со статьей Райана Рейда (Ryan Reid), где подробно рассказывается о том, что представляет собой SSTI в приложениях, написанных на Flask/Jinja2.

Тестовое приложение

Для того чтобы оценить степень влияния SSTI, мы будем использовать тестовое приложение, содержащее следующее представление.

@app.errorhandler(404)
def page_not_found(e):
    template = '''{%% extends "layout.html" %%}
{%% block body %%}
    <div class="center-content error">
        <h1>Oops! That page doesn't exist.</h1>
        <h3>%s</h3>
    </div>
{%% endblock %%}
''' % (request.url)
    return render_template_string(template), 404

Суть работы данного приложения заключается в следующем. Разработчик решил отказаться от отдельного шаблона под 404 страницу, а создал строку для шаблона внутри функции представления, отвечающего за отображение 404 страницы. При возникновении ошибки пользователю возвращается URL с ошибкой, но вместо передачи URL в контекст шаблона через функцию render_template_string, используется строковое форматирование для динамически добавляемого URL к строке шаблона. Вполне резонно, не правда ли? Я видел ситуации хуже.

При выполнении кода выше, получаем ожидаемый результат:

Рисунок 1: Страница с ошибкой 404

Многие читатели тут же подумали об XSS и были правы. Если в конец URL добавить <script>alert(42)</script>, получим XSS-уязвимость.

Рисунок 2: Эксплуатация XSS-уязвимости

Первоначальный код уязвим к межсайтовому скриптингу (XSS). Если вы читали статью Джеймса, приведенную выше, то знаете, что присутствие XSS-уязвимости является индикатором возможности применения технологии SSTI. Наш код является хорошим примером. Но если мы копнем чуть глубже и добавим в конец URL выражение {{ 7+7 }}, то увидим, что движок шаблона вычислил математическое выражение, и на выходе мы получим 14 внутри шаблона.

Рисунок 3: Результат добавления выражения {{ 7+7 }} в конец URL

Теперь рассмотрим подробнее, как использовать технологию SSTI на примере нашего приложения.

Анализ кода

После получения первого рабочего эксплоита, мы будем более глубоко исследовать шаблон и искать SSTI-уязвимость. Модифицированная уязвимая функция представления тестового приложения будет выглядеть так.

@app.errorhandler(404)
def page_not_found(e):
    template = '''{%% extends "layout.html" %%}
{%% block body %%}
    <div class="center-content error">
        <h1>Oops! That page doesn't exist.</h1>
        <h3>%s</h3>
    </div>
{%% endblock %%}
''' % (request.url)
    return render_template_string(template,
        dir=dir,
        help=help,
        locals=locals,
    ), 404

Теперь мы вызываем render_template_string функцию с параметрами dir, help и locals, которые добавляются в контекст шаблона. Таким образом, эти параметры мы можем использовать для анализа уязвимости и выяснения дополнительных возможностей, доступных в шаблоне.

Но вначале сделаем небольшую паузу и поговорим о том, что говорится в документации относительно такого понятия как «контекст шаблона». Существует несколько источников, откуда объекты попадают в контекст шаблона.

  1.  Глобальные функции шаблонизатора Jinja.

  2.  Глобальные переменные flask-шаблона.

  3. Объекты, добавленные разработчиком.

Нас больше всего интересуют пункты 1 и 2, поскольку эти объекты доступны во всех приложениях, где используется Flask/Jinja2. Объекты из пункта 3 уже будут варьировать в зависимости от конкретного приложения, к которым можно получить доступ различными способами. На сайте StackOverflow есть несколько примеров. Мы не будем глубоко погружаться в исследование нестандартных объектов (пункт 3), поскольку здесь требуется статический анализ кода.

Методология анализа и поиска уязвимостей на базе пунктов 1 и 2 будет выглядеть примерно так.

  1. Читаем документацию!

  2. Исследуем объект locals при помощи dir на предмет того, что доступно в контексте шаблона.

  3. Исследуем все найденные объекты, используя dir и help.

  4. Анализируем исходный код на предмет присутствия чего-нибудь интересного.

Результаты анализа

Первые интересные открытия будут найдены в объекте request, который является глобальным в фрейворке Flask (flask.request). Данный объект содержит ту же самую информацию, что и объект request, доступный через представление. Внутри объекта request находится объект environ. Объект request.environ представляет собой словарь объектов, имеющих отношение к серверной части. Один из элементов этого словаря – метод shutdown_server, который связан с ключом werkzeug.server.shutdown. Догадались, что произойдет с сервером, если в шаблон инжектировать выражение {{ request.environ['werkzeug.server.shutdown']() }}. Вы правильно поняли. Потратив несколько минут, мы можем спровоцировать DOS-атаку. Однако этот метод не доступен, если приложение запущено при помощи HTTP-сервера gunicorn. Так что уязвимость может присутствовать лишь на сервере, который используется в целях разработки.

Второе интересное открытие находим после анализа объекта config, который, так же как и объект request, является глобальным в фреймворке Flask (flask.config). Данный объект представляет собой словарь со всеми переменными, связанными с конфигурацией приложения, в том числе строками для подключения к базе данных, учетными записями к сторонним сервисам, SECRET_KEY и т. д. Просмотр этих переменных осуществляется при помощи выражения {{ config.items() }} и не сложнее, чем инжектирование полезной нагрузки.

Рисунок 4: Результат выполнения команды config.items()

Не спасает и хранение этих данных в переменных среды окружения, поскольку объект config содержит все переменные, связанные с конфигурацией, ПОСЛЕ обработки фреймворком.

Наиболее интересное открытие появляется также после изучения объекта config. Помимо того что config представляет собой словарь, этот объект также является подклассом, содержащим несколько методов: from_envvar, from_object, from_pyfile и root_path. Здесь мы приступаем к изучению исходного текста. Ниже представлен код метода from_object, принадлежащего классу Config (flask/config.py).

def from_object(self, obj):
        """Предназначен для обновления значений выбранного объекта.  
           Объект может быть двух типов:
        -   строка: в этом случае объект с этим именем будет импортирован
        -   ссылка на объект: в этом случае объект используется напрямую
 
        Объекты обычно представляют собой модули или классы.
 
        Переменные в верхнем регистре объекта хранятся внутри класса config.
        Пример использования:
 
            app.config.from_object('yourapplication.default_config')
            from yourapplication import default_config
            app.config.from_object(default_config)
 
Рекомендуется использовать функцию для загрузки не текущей конфигурации,
а конфигурации по умолчанию. Текущую конфигурацию следует загружать при 
помощи метода `from_pyfile` и в идеале не из пакета, поскольку пакет 
может быть общесистемным. 
 
        :param obj: имя для импорта или объект
        """
        if isinstance(obj, string_types):
            obj = import_string(obj)
        for key in dir(obj):
            if key.isupper():
                self[key] = getattr(obj, key)
 
    def __repr__(self):
        return '<%s %s>' % (self.__class__.__name__, dict.__repr__(self))

Если мы передадим строку в метод from_object, далее эта строка будет передана в метод import_string, находящийся в модуле werkzeug/utils.py, который попытается импортировать объект с подходящим именем.

def import_string(import_name, silent=False):
    """Поиск и импорт объектов на основе переданной строки. 
Эта возможность полезна при наличии пути к импортируемому объекту.
 Путь может быть задан в двух вариантах: 
(``xml.sax.saxutils.escape``) или (``xml.sax.saxutils:escape``).  
 
    Если параметр `silent` равен True, в случае неудачного импорта 
метод возвратит значение `None`.
 
    :param import_name: имя (путь) импортируемого объекта.
    :param silent: если в этом параметре установлено `True` ошибки 
    при импорте будут игнорироваться,
    и будет возвращаться значение `None`.
    :return: метод возвращает импортируемый объект
    """
    # происходит принудительная конвертация имени в строку
    # __import__ не может обрабатывать Unicode-строки в исходном списке
    # (fromlist), если модуль является пакетом
   
    import_name = str(import_name).replace(':', '.')
    try:
        try:
            __import__(import_name)
        except ImportError:
            if '.' not in import_name:
                raise
        else:
            return sys.modules[import_name]
 
        module_name, obj_name = import_name.rsplit('.', 1)
        try:
            module = __import__(module_name, None, None, [obj_name])
        except ImportError:
# поддерживаться импорт модулей, которые еще не установлены 
            # родительским модулем (или пакетом)
            module = import_string(module_name)
 
        try:
            return getattr(module, obj_name)
        except AttributeError as e:
            raise ImportError(e)
 
    except ImportError as e:
        if not silent:
            reraise(
                ImportStringError,
                ImportStringError(import_name, e),
                sys.exc_info()[2])

 

Метод from_object затем добавляет все атрибуты только что загруженного модуля, чьи имена переменных полностью находятся в верхнем регистре, в объект config. Интересный факт заключается в том, что атрибуты, добавленные в объект config, поддерживают собственные типы. То есть функции, добавленные в объект config, могут быть вызваны в контексте шаблона через объект config. Для демонстрации вышесказанного инжектируем выражение {{ config.items() }} внутрь SSTI-уязвимости и посмотрим содержимое объекта config.

Рисунок 5: Содержимое объекта config

После инжектирования выражения {{ config.from_object('os') }} в объект config добавятся все атрибуты библиотеки os, чьи имена переменных полностью находятся в верхнем регистре. Вновь инжектируем {{ config.items() }} и замечаем появление новых элементов. Также обратите внимание на типы новых элементов.

Рисунок 6: Содержимое объекта config после импортирования новых элементов

Как было сказано ранее, любой объект, добавленный в config, можно вызвать через SSTI-уязвимость (конечно, при условии, что сам элемент вообще способен быть вызванным). Следующий шаг: поиск нужного функционала внутри доступных к импорту модулей, который поможет выйти за пределы песочницы шаблона.

Скрипт ниже повторяет алгоритм методов from_object и import_string и анализирует Python Standard Library на предмет объектов, доступных для импорта.

 #!/usr/bin/env python
 
from stdlib_list import stdlib_list
import argparse
import sys
 
def import_string(import_name, silent=True):
    import_name = str(import_name).replace(':', '.')
    try:
        try:
            __import__(import_name)
        except ImportError:
            if '.' not in import_name:
                raise
        else:
            return sys.modules[import_name]
 
        module_name, obj_name = import_name.rsplit('.', 1)
        try:
            module = __import__(module_name, None, None, [obj_name])
        except ImportError:
            # support importing modules not yet set up by the parent module
            # (or package for that matter)
            module = import_string(module_name)
 
        try:
            return getattr(module, obj_name)
        except AttributeError as e:
            raise ImportError(e)
 
    except ImportError as e:
        if not silent:
            raise
 
class ScanManager(object):
 
    def __init__(self, version='2.6'):
        self.libs = stdlib_list(version)
 
    def from_object(self, obj):
        obj = import_string(obj)
        config = {}
        for key in dir(obj):
            if key.isupper():
                config[key] = getattr(obj, key)
        return config
 
    def scan_source(self):
        for lib in self.libs:
            config = self.from_object(lib)
            if config:
                conflen = len(max(config.keys(), key=len))
                for key in sorted(config.keys()):
                    print('[{0}] {1} => {2}'.
format(lib, key.ljust(conflen), repr(config[key])))
 
def main():
    # parse arguments
    ap = argparse.ArgumentParser()
    ap.add_argument('version')
    args = ap.parse_args()
    # creat a scanner instance
    sm = ScanManager(args.version)
    print('\n[{module}] {config key} => {config value}\n')
    sm.scan_source()
 
# start of main code
if __name__ == '__main__':
    main()

 

Ниже показаны некоторые наиболее интересные результаты работы скрипта, который был запущен в среде с Python 2.7, включая наиболее интересные объекты, доступные для импорта.

(venv)macbook-pro:search lanmaster$ ./search.py 2.7
 
[{module}] {config key} => {config value}
 
...
[ctypes] CFUNCTYPE               => <function CFUNCTYPE at 0x10c4dfb90>
...
[ctypes] PYFUNCTYPE              => <function PYFUNCTYPE at 0x10c4dff50>
...
[distutils.archive_util] ARCHIVE_FORMATS => {'gztar': (<function make_tarball at 0x10c5f9d70>, 
[('compress', 'gzip')], "gzip'ed tar-file"), 'ztar': (<function make_tarball at 0x10c5f9d70>, 
[('compress', 'compress')], 'compressed tar file'), 'bztar': (<function make_tarball at 0x10c5f9d70>, 
[('compress', 'bzip2')], "bzip2'ed tar-file"), 'zip': (<function make_zipfile at 0x10c5f9de8>,
 [], 'ZIP file'), 'tar': (<function make_tarball at 0x10c5f9d70>,
 [('compress', None)], 'uncompressed tar file')}
...
[ftplib] FTP                     => <class ftplib.FTP at 0x10cba7598>
[ftplib] FTP_TLS                 => <class ftplib.FTP_TLS at 0x10cba7600>
...
[httplib] HTTP                            => <class httplib.HTTP at 0x10b3e96d0>
[httplib] HTTPS                           => <class httplib.HTTPS at 0x10b3e97a0>
...
[ic] IC => <class ic.IC at 0x10cbf9390>
...
[shutil] _ARCHIVE_FORMATS => {'gztar': (<function _make_tarball at 0x10a860410>,
 [('compress', 'gzip')], "gzip'ed tar-file"), 'bztar': 
(<function _make_tarball at 0x10a860410>, [('compress', 'bzip2')], 
"bzip2'ed tar-file"), 'zip': (<function _make_zipfile at 0x10a860500>, 
[], 'ZIP file'), 'tar': (<function _make_tarball at 0x10a860410>, 
[('compress', None)], 'uncompressed tar file')}
...
[xml.dom.pulldom] SAX2DOM                => <class xml.dom.pulldom.SAX2DOM at 0x10d1028d8>
...
[xml.etree.ElementTree] XML        => <function XML at 0x10d138de8>
[xml.etree.ElementTree] XMLID      => <function XMLID at 0x10d13e050>

Далее мы используем нашу методологию к наиболее интересным элементам в надежде найти нечто, что поможет нам выйти за границы песочницы.

Спойлер: среди найденных результатов я не смог найти что-то, что помогло бы мне «покинуть» песочницу. Однако в целях обнародования результатов моего исследования ниже представлена дополнительная информация касательно найденных объектов, которую вы, при необходимости, можете использовать в будущих собственных исследованиях.

ftplib

Мы могли бы использовать объект ftplib.FTP для обратного подключения к серверу, находящемуся под нашим контролем, и загрузки файлов с сервера жертвы. Мы также можем загружать и выполнять файлы на сервере жертвы при помощи метода config.from_pyfile. Анализ документации и исходного текста ftplib показывает, что для решения нашей задачи ftplib требует открытых файловых обработчиков, но поскольку функцию встроенная функция open не доступна внутри песочницы шаблона, кажется, не существует способа создать файловые обработчики.

Httplib

Мы могли бы использовать httplib.HTTP для загрузки URL’ов файлов на локальную файловую систему при помощи обработчика файлового протокола file://. К сожалению, httplib не поддерживает обработчик файлового протокола.

xml.etree.ElementTree

Мы могли бы использовать объект xml.etree.ElementTree.XML для загрузки файлов из файловой системы при помощи пользовательских сущностей (user defined entities). Однако здесь говорится о том, что etree не поддерживает пользовательские сущности.

xml.dom.pulldom

Несмотря на то, что модуль xml.etree.ElementTree не поддерживает пользовательские сущности, модуль pulldom поддерживает. Однако мы ограничены классом xml.dom.pulldom.SAX2DOM, который не содержит метода для загрузки XML через интерфейс объекта.

Заключение

Даже несмотря на том, что метод «покинуть» песочницу шаблона пока не найден, мы исследовали влияние SSTI-уязвимостей на приложения, разработанные на Flask/Jinja2. Я планирую  покопаться в этой теме поглубже, и цель данной статьи - сподвигнуть и вас к подобным исследованиям.  

Ваша приватность умирает красиво, но мы можем спасти её.

Присоединяйтесь к нам!