Всем привет! Меня зовут Сергей Бобров aka BlackFan. Я работаю в Лаборатории Касперского, занимаюсь анализом защищенности мобильных и веб-приложений, участвую в пентестах и редтимах. В свободное время я также разрабатываю собственные инструменты, занимаюсь исследованиями в области безопасности и ищу баги на платформе Standoff Bug Bounty. Сегодня мы разберем на практике мисконфигурации, связанные с объектным хранилищем S3, и углубимся в проблемы, возникающие при его интеграции в веб-приложения через прокси (например, nginx). В основе статьи — 10 лабораторных стендов, каждый из которых поясняет различные уязвимости интеграции: от классической хранимой XSS и подмены бакетов до обхода правила rewrite и отравления кэша. 

Все сказанное ниже можно посмотреть и послушать в видео (также в VK видео). 

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

S3: что нужно знать багхантеру 

S3 (Simple Storage Service) — это сервис, разработанный компанией Amazon для предоставления доступа к объектному хранилищу в облаке AWS. Поскольку Amazon был первопроходцем в сфере облачных сервисов, его решения стали отраслевым стандартом. Сегодня под «S3» подразумевается не просто конкретный продукт, а совместимое API и подход к хранению данных. 

Базовая функциональность S3 описывается всего четырьмя HTTP-запросами:

  • Создание или редактирование объекта в бакете (контейнере) — PUT;

  • Получение объекта — GET;

  • Удаление объекта — DELETE;

  • Листинг (список объектов) бакета — GET на корень бакета.

На практике разработчики часто воспринимают S3 как «просто папку в облаке», и именно в этом упрощении кроется огромное пространство для мисконфигураций и уязвимостей при интеграции S3 в работу веб-приложений.

Авторизация и подписи запросов

Для выполнения авторизованных запросов каждый пользователь получает пару: Access Key и Secret Key. Подпись формируется следующим образом:

  • все компоненты запроса (метод, Content-Type, дата и др.) приводятся к каноническому виду — строке, разделенной символами переноса;

  • вычисляется HMAC-SHA1 от этой строки;

  • результат кодируется в Base64 и передается в заголовке Authorization или в query-параметрах. 

Существует два основных формата подписи: v2 (устаревшая, но еще используется во многих совместимых решениях) и v4 (более сложная). В своих примерах я использую v2 из-за ее простоты и наглядности. Для временного доступа к объектам применяются подписанные URL с параметром Expires.

Два способа интеграции S3 в веб-приложения

Через S3-клиент в коде приложения

Разработчики создают клиент, указывают endpoint, пару значений Access Key и Secret Key, а затем вызывают соответствующие методы S3. В этом случае атакующий ограничен лишь элементами, передаваемыми в метод клиента: содержимым файла, Content-Type, именем файла. 

Через прокси (nginx)

Часто S3 подключается как хранилище статики через nginx с помощью директивы proxy_pass. При такой конфигурации все возможности S3-протокола становятся доступны злоумышленнику, потому что nginx не фильтрует методы и заголовки (кроме префикса пути). Это самый интересный для нас случай, поскольку здесь возникает большинство уязвимостей.

Контроль доступа: ACL и Bucket Policy

Управление доступом в S3 реализовано двумя способами:

  • S3 Bucket ACL (Access Control List) — список правил, который определяет разрешения для бакета или отдельных объектов. Самая опасная ошибка здесь в том, что разработчики часто неверно трактуют группу AuthenticatedUsers. Это не пользователи одного аккаунта, а все авторизованные пользователи S3 (в рамках данного облака) во всем мире. Такая ошибка приводила к масштабным утечкам, а иногда и к шифрованию бакетов с требованием выкупа. Например, если вы пользуетесь облаком Яндекса, то любой другой пользователь при наличии такой мисконфигурации может получить доступ к вашему бакету.

  • S3 Bucket Policy — JSON-документ, позволяющий задавать правила на уровне бакета: разрешения по префиксам, условиям, конкретным пользователям. В отличие от ACL, политика применяется ко всему бакету, что упрощает администрирование, но при ошибках также открывает широкие возможности для атак.

Идентификация S3-систем и разведка

Перед атакой важно понять, с какой именно S3-системой мы имеем дело. Я составил таблицу S3 Fingerprint, где собраны характерные заголовки и страницы ошибок для разных реализаций. Она позволяет по ответу на запрос идентифицировать S3. Второй репозиторий — это специальное расширение для Burp Suite и Caido. 

Рекомендации:

  • Сделайте запрос к существующему и несуществующему объекту — сравните ответы. Это позволяет понять, как устроено проксирование, выявить, какие заголовки добавляет фронтенд, раскрывается ли служебная информация S3.

  • Обратите внимание на заголовок X-Amz-Request-Id — его формат (длина, регистр, префиксы) часто указывает на конкретную S3-совместимую систему (MinIO, Ceph RGW, Yandex Cloud, AWS). 

  • Используйте расширение для Burp Suite (например, Hackvertor) для быстрой генерации подписанных запросов с некорректной сигнатурой. Это может раскрыть каноническое представление запроса, включая имя бакета.

Практические стенды

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

Ошибки в правилах проксирования

Прокси не знает семантики S3 и работает с декодированным и нормализованным значением пути. Ошибка в одном символе правила разделяет то, что видит nginx, и то, что уходит в хранилище, и именно на этом расхождении строятся атаки ниже.

Подмена бакета через отсутствие конечного слеша

В этом стенде ошибка в том, что разработчик забыл указать завершающий слеш в правиле /static. Если обратиться к /staticxxx, то при проксировании префикс static заменится на имя бакета, а все дополнительные данные склеятся с ним. В результате запрос уйдет к несуществующему бакету.

Таким образом, мы можем создать бакет с оригинальным префиксом, добавить к нему какие-то данные, залить любые файлы и обратиться к этим файлам через сайт, с которым мы взаимодействуем. Например, создав в облаке атакующего такой бакет, мы загрузили в него файл xss.html, содержащий типичный эксплойт. При обращении через уязвимый сайт у пользователей срабатывал XSS.

32:17–36:18 (демонстрация решения)

https://youtu.be/SwEpvGKsidc?t=1937

HTTP Request Splitting через $uri и кража cookies

В этом стенде правило прокси использует переменную $uri, которая содержит декодированный путь запроса. Это позволяет злоумышленнику внедрять в путь служебные символы (например, пробелы и переводы строк) и использовать HTTP Request Splitting для модификации запроса. Уязвимость детектируется так: добавление пробела и буквы H вызывает ошибку 400 (воспринимается как версия протокола). Используя эту технику, атакующий подменяет заголовок Host и перенаправляет запрос на произвольный бакет, загружая XSS-эксплойт. Более того, сформировав PUT-запрос с той же уязвимостью и отправив его через XSS, можно записать в бакет атакующего часть HTTP-запроса пользователя вместе с его сессионными cookies (включая HttpOnly). Для подписи таких запросов создается сервисный аккаунт с ролью storage.uploader.

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

36:19–50:11 (демонстрация решения)

https://youtu.be/SwEpvGKsidc?t=2178

Обход rewrite через %0A

В этой лабораторной используется Ceph Rados Gateway, но здесь имя бакета заменяется с помощью регулярного выражения — правила rewrite. В такой конфигурации возникает следующая проблема: зачастую в этих правилах используются символы начала строки (^) и конца строки ($) в регулярном выражении. Но nginx взаимодействует с декодированным и нормализованным значением пути, и, если после папки static указать символ переноса строки (%0A), регулярное выражение не сработает. Корректное имя бакета не добавится в HTTP-запрос.

В результате папка static остается в запросе, но proxy_pass с префиксом static заменяется на одинарный слеш, и все, что идет после static, становится именем бакета. Добавив %0A в середину названия объекта, получаем ошибку NoSuchBucket. Значит, правило подстановки не сработало, и мы можем обратиться к произвольному бакету (например, test) с нужным нам постфиксом.

Перебрав имена бакетов в Intruder, находим существующий бакет public и точно так же формируем PUT-запрос на создание файла. В результате мы опять получаем возможность выполнять XSS через проксирование на S3. 

55:47–59:40 (демонстрация решения)

https://youtu.be/SwEpvGKsidc?t=3347

Отравление кэша через параметры запроса

В этом стенде нет основного приложения — весь трафик проксируется в S3 и кэшируется nginx на одну минуту. Ключ кэша формируется только по пути, без учета query-параметров. Здесь ошибка в том, что разработчики воспринимают S3 как статичную папку и не учитывают параметры, меняющие ответ.

Мы отправляем запрос к index.html с параметром ?response-content-type=image/png. S3 возвращает 200 OK с измененным Content-Type. nginx закэшировал этот ответ, и все последующие пользователи получали index.html с типом image/png. Таким образом, мы можем временно блокировать функциональность сайта.

А вот попытка использовать параметр ?acl, который часто доступен для неавторизованных пользователей, не удалась: у нашего бакета корректно заданы привилегии, поэтому возвращается ошибка 403 — такие ответы не попадают в кэш. 

1:16:04–1:20:00 (демонстрация решения)

https://youtu.be/SwEpvGKsidc?t=4563

Особенности S3-совместимых систем

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

Обход пути через .. в Ceph RGW

На этот раз используем Ceph Rados Gateway. Это более продвинутое приложение по сравнению с MinIO. 

Здесь правила проксирования выглядят вполне адекватно, но проблема заключается в том, что у proxy_pass в nginx есть два вида проксирования: с указанием пути в правиле или без него. Для nginx оба запроса выглядят одинаково, но, если путь не задан, запрос улетает на upstream-сервер именно так, как его указал пользователь.

Это приводит к тому, что для S3-систем, которые не нормализуют путь (в отличие от файловой системы), символы ../ становятся корректной частью имени объекта. В результате мы можем обращаться к произвольным бакетам, но с неизменяемым постфиксом.

Наша цель — обратиться к внутреннему бакету public, который не предназначен для внешнего взаимодействия. При обращении к существующему файлу мы видим новый формат заголовка x-amz-request-id (содержится длинное значение с префиксом tx...), что соответствует Ceph RGW. 

Сформировав ненормализованный путь с .., пытаемся обратиться к другому бакету, и получаем в ответ NoSuchBucket. Для перебора существующих внутренних бакетов используем Intruder. Однако у ошибок NoSuchBucket одинаковый код и размер ответа, поэтому нужно добавить правило извлечения текста из контента, и по регулярному выражению или разделителям извлекаем код ответа. В одном из значений мы находим ошибку NoSuchKey: это означает, что бакет public существует.

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

50:10 – 55:41 (демонстрация решения)

https://youtu.be/SwEpvGKsidc?t=3010

Поиск бакетов через нотацию имен

Мы выяснили, что имя бакета передается в Host-заголовке (virtual-host-style). При запросе с некорректной подписью нам возвращается подробная ошибка с кодом 403, внутри которой раскрывается каноничное представление запроса и содержится имя бакета.

Так как речь идет о публичном объектном хранилище, разработчики стараются использовать единую нотацию именования бакетов. Зная корпоративную нотацию, мы можем перебирать возможные имена бакетов через Burp Intruder (тип атаки — Cluster bomb attack). Ожидаем три типа ответов:

  • 404 — бакет не существует;

  • 403 — существует, но листинг запрещен;

  • 200 — существует и некорректно настроен.

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

24:41–32:17 (демонстрация решения)

https://youtu.be/SwEpvGKsidc?t=1361

Злоупотребление штатными возможностями протокола

Прокси настроено корректно, но сам протокол S3 остается доступен снаружи: листинг, подписанные ссылки, подмена Content-Type, все это штатные возможности, которые при неосторожной интеграции превращаются в вектор атаки.

Базовый листинг и загрузка файлов (XSS)

В этом стенде бакет был доступен только через прокси с префиксом /static. Мы выяснили, что:

  • Листинг бакета включен по умолчанию, и мы можем перебирать объекты.

  • Доступна загрузка (PUT) для неавторизованных пользователей.

Загрузив test.html с Content-Type text/html, мы получили возможность выполнять произвольный HTML/JS в контексте сайта. Это классическая хранимая XSS.

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

12:04-21:10 (демонстрация решения)

https://youtu.be/SwEpvGKsidc?t=725

Подписанные ссылки и листинг бакета

В этом стенде была реализована функциональность скачивания через подписанные URL. В этом случае на эндпойнт /download передается путь к файлу, который мы скачиваем. И если мы вместо пути к объекту укажем, например, слеш (/), то сервер нам вернет подписанную ссылку, указывающую на корень бакета. В случае с S3-системой такая ссылка предоставляет список всех объектов в бакете, для которых мы затем можем сгенерировать подписанные ссылки и получить доступ к их содержимому.

21:12 - 24:03 (демонстрация решения)

https://youtu.be/SwEpvGKsidc?t=1272

Service Worker и подмена контента

В этом сценарии приложение использовало два отдельных хоста: один взаимодействует с S3-бакетом, а второй — с основным приложением (flask). Такое разделение часто используется для того, чтобы контент пользователя изолировать от основного приложения. Наша цель — заставить легитимного пользователя при попытке скачать свой файл получить файл атакующего.

Создав аккаунт атакующего в отдельном браузере, чтобы эксплуатация была корректной, загружаем туда, например, XSS. В данном приложении обязательное использование постфикса .webp. И если мы попытаемся загрузить HTML-файл с расширением .webp, то при скачивании получим некорректное изображение, потому что приложение автоматически подставляет соответствующий Content-Type

Чтобы изменить Content-Type на text/html и сделать загруженный нами HTML-файл исполняемым, мы используем параметр response-content-type в GET-запросе. Однако из-за изоляции поддомена это не дает доступа к cookie основного приложения.
Здесь нам поможет Service Worker: загружаем на поддомен JavaScript-файл с правильным Content-Type и с помощью отдельного HTML-файла регистрируем его как Worker. Теперь он перехватывает все fetch-запросы пользователя на этом поддомене, отправляет информацию (пути к файлам) на наш коллаборатор и подменяет ответы на ссылку, ведущую на наш вредоносный файл (например, злую картинку). 

1:06:20–1:16:03 (демонстрация решения)

https://youtu.be/SwEpvGKsidc?t=3980

Когда классы уязвимостей сшиваются в цепочку

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

Раскрытие ключей через debug-режим Django

В этой лабораторной приложение основано на Django, которое работает в debug-режиме. Наша цель здесь — обратиться к бакету secret и к его файлу secret.txt, который не должен быть доступен извне.

При искусственном вызове ошибки нам вывелась полная трассировка со значениями переменных окружения. Здесь есть автоматическое маскирование, однако у нас учетная запись S3 задана в виде коротких обозначений S3_SK и S3_AK, и таким образом мы смогли раскрыть учетную запись, которая используется для взаимодействия с S3 на этом сайте. Имея этот ключ, мы можем подписывать любые запросы к S3.

Несмотря на то, что прокси было настроено корректно, мы использовали специальный заголовок X-Amz-Copy-Source для копирования объекта из внутреннего бакета secret в публично доступный. Таким образом, мы обошли ограничения прокси и вытащили данные, к которым не имели прямого доступа.

59:41–1:06:14

https://youtu.be/SwEpvGKsidc?t=3580 (демонстрация решения)

Вывод

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