S3-совместимое хранилище обычно подключают к веб-приложению как «просто файлопомойку»: статика, загрузки пользователей, бэкапы. Но протокол S3 сам по себе — это полноценный API с листингом, подписанными ссылками, управлением Content-Type и ACLсписком управления доступом, который привязан к каждому бакету и объекту и определяет, каким аккаунтам или группам AWS предоставлен доступ и какого типа. Когда интеграция сделана небрежно, эти штатные возможности превращаются в вектор атаки. Ниже — механика того, как это происходит.
Подписанные 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, которая передаёт запрос на вышестоящий сервер без изменения метода и заголовков. 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группа в ACL, представляющая все учётные записи AWS, представленная 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политика доступа, задающая ресурс через ARN и применяемая ко всем объектам, которые этому ARN соответствуют и 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получатель разрешения: аккаунт или пользователь, которым разрешён доступ к действиям и ресурсам в statement определяет получателя разрешения. В примере политики Principal вместе с Effect, Action и Resource разрешает пользователю Akua из аккаунта 123456789012 права s3:GetObject, s3:GetBucketLocation и s3:ListBucket на бакет amzn-s3-demo-bucket1.
Все неаутентифицированные запросы выполняются анонимным пользователемспециальный субъект, которым выполняются все неаутентифицированные запросы. Здесь кроется неочевидная механика: если объект загружен в бакет через неаутентифицированный запрос, анонимный пользователь становится владельцем объекта, а default object ACLACL, автоматически назначаемый объекту при загрузке даёт ему FULL_CONTROL как владельцу. Поэтому S3 разрешает неаутентифицированным запросам извлекать объект или изменять его ACL.
Чтобы объекты не изменялись анонимным пользователем, рекомендуется не применять bucket policies, разрешающие анонимную публичную запись в бакет, и не использовать ACL, дающие анонимному пользователю доступ на запись. Обеспечить это можно через Amazon S3 Block Public Accessнабор настроек, блокирующий публичный доступ к бакету и объектам.
Делегирование разрешений и Condition keys
делегирование разрешенийпередача владельцем ресурса прав другому AWS-аккаунту, который затем может передать их или их часть пользователям внутри своего аккаунта работает в одну сторону: аккаунт, получивший права от другого аккаунта, не может делегировать их кросс-аккаунтно третьему AWS-аккаунту.
Ограничения задаются элементами политики. По префиксу — через Resource с ARN вида "Resource": "arn:aws:s3:::bucket_name/prefix/*". По конкретному пользователю — через Principal. Условия применения задаёт элемент Conditionэлемент политики, задающий условия, при которых она действует, где доступны AWS-wide и S3-специфичные ключиключи условий: общие для AWS и специфичные для Amazon 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Предопределённая группа в ACL S3, представляющая все учётные записи AWS, доступ которой позволяет любому подписанному запросу любого AWS-аккаунта обратиться к ресурсу. — это весь мир. Группа включает все учётные записи 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-запросы и подменит ответы.