Назад к блогу

10 неочевидных уязвимостей S3-интеграций в веб-приложениях

10 неочевидных уязвимостей S3-интеграций в веб-приложениях

Статья разбирает десять неочевидных способов, которыми небрежно настроенная S3-интеграция превращается в дыру в безопасности веб-приложения — от подмены Content-Type через подписанные ссылки до перезаписи JavaScript и опасных ACL-грантов. Автор показывает конкретную механику атак и объясняет, почему привычные защиты вроде проверки расширения файла или проксирования через nginx не спасают. Материал будет полезен разработчикам и администраторам, которые считают S3 всего лишь «файлопомойкой» и не подозревают, сколько возможностей протокола остаётся открытым наружу.

S3-совместимое хранилище обычно подключают к веб-приложению как «просто файлопомойку»: статика, загрузки пользователей, бэкапы. Но протокол S3 сам по себе — это полноценный API с листингом, подписанными ссылками, управлением Content-Type и ACL. Когда интеграция сделана небрежно, эти штатные возможности превращаются в вектор атаки. Ниже — механика того, как это происходит.

Подписанные URL: почему расширения .webp недостаточно

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

Обход строится на том, что подписанный URL может нести параметры, влияющие на отдачу. Один из них — response-content-type в GET-запросе: это параметр, которым клиент указывает серверу, какой Content-Type вернуть в ответе, и сервер применяет его вместо типа, определённого по расширению. Так Content-Type ответа меняется на text/html, и загруженный HTML-файл становится исполняемым. Защита через расширение обходится ровно потому, что серверная сторона позволяет клиенту влиять на Content-Type. Чтобы этого не происходило, Content-Type должен жёстко задаваться на серверной стороне и не подменяться параметрами запроса — тогда браузер не интерпретирует загруженный контент как HTML или JS.

proxy_pass и семантика S3: методы и заголовки

Когда S3 подключают через nginx с директивой proxy_pass. nginx не фильтрует методы и заголовки (кроме префикса пути). Значит, PUT, POST, DELETE и любые заголовки — включая Content-Type, Authorization и response-content-type — беспрепятственно доходят до хранилища.

Последствия складываются из штатных возможностей протокола:

  • при включённом по умолчанию листинге бакета объекты можно перебирать;
  • если доступна загрузка (PUT) для неавторизованных пользователей, можно загрузить test.html с Content-Type: text/html и получить хранимую XSS — исполнение произвольного HTML/JS в контексте сайта;
  • через response-content-type в GET-запросе можно изменить Content-Type уже существующего объекта;
  • через PUT можно загрузить произвольный файл в бакет.

То есть отсутствие фильтрации методов и заголовков на уровне nginx оставляет снаружи весь набор возможностей S3-протокола: листинг, подписанные ссылки, подмену Content-Type.

Перезапись JS через проксирующий nginx

Отдельный сценарий — бакет доступен только через прокси с префиксом /static, листинг включён по умолчанию, а PUT доступен неавторизованным. Если помимо статических файлов в бакете лежит JavaScript, подключаемый на главной странице, его можно перезаписать и внедрить вредоносный скрипт для всех посетителей.

Условий для этого всего два: через прокси-префикс /static должна быть доступна запись объекта (PUT) без авторизации, а имя объекта должно совпадать с именем подключаемого JS-файла.

ACL: кто такой AuthenticatedUsers

ACL оперирует предопределёнными группами — это особые группы, задаваемые в ACL по URI, а не по идентификатору аккаунта. Одна из них — Authenticated Users, представленная URI http://acs.amazonaws.com/groups/global/AuthenticatedUsers. Согласно документации, эта группа «represents all AWS accounts» — то есть включает все учётные записи AWS, а не только аккаунт владельца ресурса.

Доступ, выданный этой группе, «allows any AWS account to access the resource», но при этом «all requests must be signed (authenticated)» — запрос обязан быть подписан. Именно поэтому группа не ограничивается пользователями одного аккаунта: предупреждение прямо говорит, что «any AWS authenticated user in the world can access your resource». Важная деталь: грантополучателем в ACL может быть AWS-аккаунт или одна из предопределённых групп, но не IAM-пользователь — «the grantee cannot be an IAM user».

Как выглядит грант для AuthenticatedUsers

Грант задаётся через элемент Grant — запись в ACL, которая связывает получателя с набором разрешений. Получатель описывается элементом Grantee: у него есть тип, и в нашем случае это uri (а не id). Тип uri используется именно тогда, когда разрешения выдаются предопределённой группе, а не конкретному аккаунту AWS; значением служит URI группы.

Набор разрешений в ACL ограничен: READ, WRITE, READ_ACP, WRITE_ACP и FULL_CONTROL. Их смысл различается для бакета и объекта:

  • для объекта READ позволяет читать данные и метаданные, READ_ACP — читать ACL объекта, WRITE_ACP — записывать ACL объекта, а WRITE не применимо;
  • для бакета READ позволяет перечислять объекты, WRITE — создавать новые (а владельцам существующих объектов также удалять и перезаписывать их), READ_ACP — читать ACL бакета, WRITE_ACP — записывать ACL бакета;
  • FULL_CONTROL для бакета даёт READ, WRITE, READ_ACP и WRITE_ACP, а для объекта — READ, READ_ACP и WRITE_ACP.

Последствия выдачи READ и WRITE группе AuthenticatedUsers

READ на бакет даёт право перечислять объекты. WRITE на бакет позволяет создавать новые объекты, а для владельцев бакета и существующих объектов — также удалять и перезаписывать их. В сочетании с включённым по умолчанию листингом и доступной для неавторизованных загрузкой (PUT) это даёт злоумышленнику полный цикл: перебрать объекты и загрузить собственный файл.

Дальше сценарии накладываются друг на друга. Загрузив test.html с Content-Type: text/html, можно исполнять произвольный HTML/JS в контексте сайта — классическая хранимая XSS. Если в бакете лежит JavaScript, подключаемый на главной странице, его можно перезаписать и внедрить вредоносный скрипт для всех посетителей. Есть и ещё один путь: если вместо пути к объекту указать слеш (/), сервер вернёт подписанную ссылку на корень бакета. Для S3-системы такая ссылка предоставляет список всех объектов, для которых затем можно сгенерировать подписанные ссылки и получить доступ к содержимому.

ACL против bucket policy: область действия

bucket policy и ACL различаются по области действия принципиально.

ACL — отдельный подресурс у каждого бакета и каждого объекта. Ошибка в ACL затрагивает только тот ресурс, к которому он прикреплён. Политика же задаёт ресурс через ARN: для операций уровня бакета — "Resource": "arn:aws:s3:::bucket_name", для операций уровня объекта — "Resource": "arn:aws:s3:::bucket_name/*" (все объекты) или "Resource": "arn:aws:s3:::bucket_name/prefix/*" (объекты под префиксом). Одна политика применяется ко всем объектам, соответствующим ARN, поэтому ошибка в ней открывает сразу весь бакет.

Смягчить риск позволяет настройка Bucket owner enforced: при ней все ACL отключены, владелец бакета владеет всеми объектами и управляет доступом исключительно политиками. Запросы на установку или обновление ACL при этом завершаются ошибкой AccessControlListNotSupported, а чтение ACL по-прежнему поддерживается.

Соотношение разрешений ACL и access policy

ACL даёт лишь конечный набор разрешений по сравнению с access policy. Таблица маппинга показывает соответствие:

  • READ на бакет → s3:ListBucket, s3:ListBucketVersions, s3:ListBucketMultipartUploads;
  • READ на объект → s3:GetObject, s3:GetObjectVersion;
  • WRITE на бакет → s3:PutObject;
  • WRITE на объект → не применимо;
  • FULL_CONTROL эквивалентно READ, WRITE, READ_ACP и WRITE_ACP и маппится на комбинацию соответствующих policy-разрешений.

Principal и анонимный пользователь

В bucket policy элемент Principal определяет получателя разрешения. В примере политики Principal вместе с Effect, Action и Resource разрешает пользователю Akua из аккаунта 123456789012 права s3:GetObject, s3:GetBucketLocation и s3:ListBucket на бакет amzn-s3-demo-bucket1.

Все неаутентифицированные запросы выполняются анонимным пользователем. Здесь кроется неочевидная механика: если объект загружен в бакет через неаутентифицированный запрос, анонимный пользователь становится владельцем объекта, а default object ACL даёт ему FULL_CONTROL как владельцу. Поэтому S3 разрешает неаутентифицированным запросам извлекать объект или изменять его ACL.

Чтобы объекты не изменялись анонимным пользователем, рекомендуется не применять bucket policies, разрешающие анонимную публичную запись в бакет, и не использовать ACL, дающие анонимному пользователю доступ на запись. Обеспечить это можно через Amazon S3 Block Public Access.

Делегирование разрешений и Condition keys

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

Ограничения задаются элементами политики. По префиксу — через Resource с ARN вида "Resource": "arn:aws:s3:::bucket_name/prefix/*". По конкретному пользователю — через Principal. Условия применения задаёт элемент Condition, где доступны AWS-wide и S3-специфичные ключи. Для ограничения по ACL есть контекстные ключи s3:x-amz-grant-read, s3:x-amz-grant-write, s3:x-amz-grant-read-acp, s3:x-amz-grant-write-acp, s3:x-amz-grant-full-control и s3:x-amz-acl — они позволяют требовать использования конкретного ACL в запросе.

Разведка: как отличить S3-совместимые реализации

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

Характерные ответы:

  • при обходе rewrite через %0A в Ceph Rados Gateway возникает ошибка NoSuchBucket — по ней видно, что правило подстановки не сработало;
  • уязвимость HTTP Request Splitting через $uri детектируется так: добавление пробела и буквы H вызывает ошибку 400, которая воспринимается как версия протокола;
  • попытка использовать параметр ?acl вернула ошибку 403 — такие ответы не попадают в кэш.

Эти различия в нормализации пути и в ответах (NoSuchBucket, 400, 403) позволяют отличать реализации и понимать поведение прокси.

Цепочка: подписанный URL, Service Worker и перехват fetch

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

Загружаем HTML-файл с обязательным постфиксом .webp — приложение автоматически подставляет Content-Type, и файл не исполняется. Меняем Content-Type на text/html через response-content-type в GET-запросе — но из-за изоляции поддомена это не даёт доступа к cookie основного приложения.

Тогда на поддомен загружается JavaScript-файл с правильным Content-Type, и с помощью отдельного HTML-файла он регистрируется как Service Worker. Теперь он перехватывает все fetch-запросы пользователя на этом поддомене, отправляет информацию (пути к файлам) на коллаборатор и подменяет ответы на ссылку, ведущую на вредоносный файл.

Что из этого следует на практике

  • Расширение файла — не защита. Content-Type должен задаваться серверной стороной жёстко и не подменяться через response-content-type. Иначе загруженный HTML исполняется.
  • proxy_pass не фильтрует. nginx пропускает PUT, POST, DELETE и любые заголовки (кроме префикса пути). Если через прокси доступна запись без авторизации, можно загрузить произвольный файл и перезаписать подключаемый JS.
  • Листинг по умолчанию опасен. Включённый листинг плюс доступный PUT для неавторизованных — это перебор объектов и загрузка своего файла.
  • AuthenticatedUsers — это весь мир. Группа включает все учётные записи AWS, а не аккаунт владельца. READ на бакет даёт перечисление объектов, WRITE — создание, а для владельцев существующих объектов ещё и удаление с перезаписью.
  • Слеш вместо пути отдаёт корень бакета. Подписанная ссылка на корень предоставляет список всех объектов, для которых затем генерируются подписанные ссылки.
  • Ошибка в policy шире, чем в ACL. ACL привязан к конкретному ресурсу, policy с ARN на весь бакет открывает сразу все объекты. Bucket owner enforced отключает ACL и оставляет управление только политиками.
  • Анонимный пользователь владеет загруженным объектом. При неаутентифицированной загрузке default object ACL даёт анониму FULL_CONTROL, поэтому он может извлекать объект и менять его ACL. Защита — Block Public Access и отказ от политик с анонимной записью.
  • Делегирование не кросс-аккаунтно. Получивший права аккаунт не может передать их третьему аккаунту. Ограничения задаются через Resource (префикс), Principal (пользователь) и Condition (в том числе ключи s3:x-amz-grant-* и s3:x-amz-acl).
  • Реализации различаются. Разная нормализация пути и разные ответы (NoSuchBucket, 400, 403) позволяют отличать S3-совместимые системы при разведке.
  • Изоляция поддомена обходится Service Worker. Если на поддомен можно загрузить JS с правильным Content-Type и зарегистрировать его как Worker, он перехватит fetch-запросы и подменит ответы.

Источники

Похожее