
Воспроизведение для CVE-2026-64640 — Apache Polaris Iceberg REST register/register-view выдаёт учётные данные хранилища и читает выбранное атакующим расположение метаданных до проверки allowedLocations (перекрёстное чтение между арендаторами через confused-deputy). Затронуты версии ≤ 1.6.0, исправлено в 1.7.0.
registerСамодостаточный воспроизводитель одной командой для CVE-2026-64640: конечные точки register в Iceberg REST в Apache Polaris выпускают учетные данные облачного хранилища для пути, указанного вызывающей стороной, и читают этот путь на стороне сервера до проверки его по allowedLocations каталога.
Участник (principal), единственное право которого — создание таблиц в собственном каталоге, может заставить Polaris использовать учетные данные хранилища каталога для чтения объектов, которых каталогу никогда не разрешалось касаться: префикс другого арендатора, другой bucket, всё, что доступно участнику хранилища.
| CVE | CVE-2026-64640 |
| Компонент | polaris-runtime-service — IcebergCatalog / LocalIcebergCatalog: registerTable, registerView |
| Конечные точки | POST /api/catalog/v1/{prefix}/namespaces/{namespace}/registerPOST /api/catalog/v1/{prefix}/namespaces/{namespace}/register-view (1.6.0+) |
| CWE | CWE-441 (confused deputy), CWE-639 (обход авторизации через контролируемый пользователем ключ), CWE-918 (SSRF) |
| Критичность | Высокая — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N (8.1) |
| Затронуто | ≤ 1.6.0 (проверено на 1.3.0-incubating, 1.4.0, 1.4.1, 1.5.0, 1.6.0) |
| Исправлено в | 1.7.0 — в 1.6.0 исправлен только путь таблицы, и ошибка появилась на новом пути представления |
| Требуемые привилегии | один аутентифицированный участник с TABLE_CREATE / CATALOG_MANAGE_CONTENT на любом каталоге — без админа |
1.6.0 — это не исправление. Он закрывает
register(непреднамеренно, в рамках feature-запроса) и поставляет новый конечный точкуregister-viewс той же ошибкой порядка. Воспроизводитель доказывает обе части. Обновитесь до 1.7.0.
Требования: Docker с плагином Compose v2, curl, python3, bash. Больше ничего — окружение разворачивается из опубликованных образов и удаляется после.
./exploit.sh # default: apache/polaris:1.4.1 -> reproduces via register
./exploit.sh --tag 1.6.0 # partially fixed -> reproduces via register-view
./exploit.sh --tag 1.7.0 # comprehensively fixed -> refuses cleanly on both
./scripts/version-matrix.sh # every release, side by side
Код выхода 0 означает, что уязвимость воспроизведена, 1 — не воспроизведена, 2 — окружение не удалось запустить. Каждый запрос и ответ сохраняются в evidence/<timestamp>-polaris-<tag>/.
catalog tenant_a_catalog allowedLocations = [ s3://bucket123 ]
actor low_priv_user CATALOG_MANAGE_CONTENT on that catalog, nothing else
target s3://tenant-b-private another tenant's bucket:
no allowedLocations entry, no grant, no relation
to the attacker's catalog — but reachable by the
credentials that back it
Последняя строка — самая реалистичная часть. Операторы ограничивают каталог с помощью allowedLocations; нижележащая IAM-роль почти всегда шире одного префикса. allowedLocations — это стена. Эта ошибка обходит её.
Тест 0 — стена реальна. Создание таблицы с явным расположением в s3://tenant-b-private отклоняется с 403 ForbiddenException, до чтения чего-либо. Polaris отлично знает, что расположение вне допустимых границ.
Тест 1 — register всё равно читает это. Тот же участник направляет register на s3://tenant-b-private/sales/metadata/00007-tenant-b-sales.metadata.json. Ответ также 403 — но в нём цитируется s3://tenant-b-private/warehouse/CANARY-64640-4f1c9e2a-tenant-b-sales, строка, которая существует только внутри тела этого объекта:
{"error":{"message":"Invalid locations '[s3://tenant-b-private/warehouse/sales/data,
s3://tenant-b-private/warehouse/CANARY-64640-4f1c9e2a-tenant-b-sales]' for identifier
'tenant_a_ns.pwn_canary': s3://tenant-b-private/warehouse/sales/data is not in the list
of allowed locations: [s3://bucket123/tenant_a_ns]","type":"ForbiddenException","code":403}}
В запросе никогда не упоминается warehouse/. Polaris мог получить эти строки только путём выборки и разбора объекта с учётными данными каталога. 403 — это Polaris, фиксирующий нарушение на один шаг после чтения, которое он должен был предотвратить.
Тест 2 — что возвращается. Два отдельных поля, извлечённых из документа-жертвы, возвращаются вызывающей стороне: объявленное расположение таблицы location и свойство write.data.path.
Тест 3 — оракул перечисления хранилища. Тот же вызов отвечает по-разному для каждого состояния цели за пределами allowedLocations:
зонд (все вне allowedLocations) | ответ 1.4.1 |
|---|---|
| допустимые метаданные Iceberg, существуют | 403 ForbiddenException + извлечённые расположения в ответе |
| ключ отсутствует | 400 NotFoundException — Location does not exist: … |
| bucket не существует | 400 NoSuchBucketException — необработанная ошибка S3 SDK |
| существует, но не метаданные Iceberg | 503 RuntimeIOException — Failed to read file: … |
Четыре различимых ответа означают четыре факта о хранилище, которые вызывающая сторона не имеет права узнавать. В реальном AWS зонд существования bucket достигает глобального пространства имён bucket'ов S3, используя учётные данные каталога.
На исправленной сборке каждая строка этой таблицы — один и тот же 403 с указанием только запрошенного пути, и ничего изнутри объекта не возвращается — скрипт обнаруживает эту сигнатуру и сообщает NOT VULNERABLE — pre-validation observed.
Тест 4 — та же ошибка на register-view. Polaris 1.6.0 добавил POST .../namespaces/{ns}/register-view, ведущий к registerView, который загружает FileIO и разбирает документ вызывающей стороны без предварительной проверки — именно то, что registerTable только что перестал делать. На 1.6.0 view-канарейка возвращает:
{"error":{"message":"Invalid locations '[s3://tenant-b-private/warehouse/CANARY-64640-VIEW-8d3b7a15-tenant-b]'
for identifier 'tenant_a_ns.pwn_view': … is not in the list of allowed locations:
[s3://bucket123/tenant_a_ns]","type":"ForbiddenException","code":403}}
Таким образом, развёртывание 1.6.0 по-прежнему остаётся уязвимым к тому же примитиву через другую конечную точку. В 1.4.1 и старше эта конечная точка не существует, и скрипт сообщает об этом.
IcebergCatalog.registerTable (LocalIcebergCatalog начиная с 1.6.0), до исправления — registerView имеет ту же структуру:
String locationDir = metadataFileLocation.substring(0, lastSlashIndex); // attacker-controlled
...
FileIO fileIO =
loadFileIOForTableLike(
identifier,
Set.of(locationDir), // credentials minted here
resolvedParent,
new HashMap<>(tableDefaultProperties),
Set.of(PolarisStorageActions.READ, PolarisStorageActions.LIST));
InputFile metadataFile = fileIO.newInputFile(metadataFileLocation); // server-side GET
TableMetadata metadata = TableMetadataParser.read(metadataFile); // server-side parse
ops.commit(null, metadata); // allowedLocations checked HERE
Цепочка выдачи (loadFileIOForTableLike → StorageAccessConfigProvider.getStorageAccessConfig → *StorageIntegration.getSubscopedCreds) не выполняет собственную проверку allowedLocations, поэтому единственная проверка — на этапе commit — и к этому моменту привилегированное чтение уже произошло, а его результат находится в ответе.