
Reprodutor para CVE-2026-64640 — Apache Polaris Iceberg REST register/register-view fornece credenciais de armazenamento e lê um local de metadados escolhido pelo atacante antes de validar allowedLocations (leitura entre locatários por confusão de subordinado). Afetado ≤ 1.6.0, corrigido em 1.7.0.
registerUm reprodutor autocontido, acionado com um único comando, para a CVE-2026-64640: os endpoints
register do Iceberg REST no Apache Polaris emitem credenciais de armazenamento em nuvem para um
caminho fornecido pelo chamador e leem esse caminho no lado do servidor antes de verificá-lo
em relação aos allowedLocations do catálogo.
Um principal cujo único privilégio é criar tabelas em seu próprio catálogo pode fazer o Polaris usar as credenciais de armazenamento do catálogo para ler objetos que o catálogo nunca teve permissão de tocar — o prefixo de outro tenant, outro bucket, qualquer coisa que o principal de armazenamento consiga alcançar.
| CVE | CVE-2026-64640 |
| Componente | polaris-runtime-service — IcebergCatalog / LocalIcebergCatalog: registerTable, registerView |
| Endpoints | POST /api/catalog/v1/{prefix}/namespaces/{namespace}/registerPOST /api/catalog/v1/{prefix}/namespaces/{namespace}/register-view (1.6.0+) |
| CWE | CWE-441 (deputado confuso), CWE-639 (bypass de autorização por meio de chave controlada pelo usuário), CWE-918 (SSRF) |
| Gravidade | Alta — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N (8.1) |
| Afetadas | ≤ 1.6.0 (verificado em 1.3.0-incubating, 1.4.0, 1.4.1, 1.5.0, 1.6.0) |
| Corrigida em | 1.7.0 — a 1.6.0 corrige apenas o caminho de tabela e reintroduz a falha no novo caminho de view |
| Privilégio necessário | um principal autenticado com TABLE_CREATE / CATALOG_MANAGE_CONTENT em qualquer catálogo — sem admin |
A 1.6.0 não é uma correção. Ela fecha o
register(incidentalmente, dentro de um PR de funcionalidade) e entrega o recém-criado endpointregister-viewcom o mesmo erro de ordenação. O reprodutor prova as duas metades. Atualize para a 1.7.0.
Pré-requisitos: Docker com o plugin Compose v2, curl, python3, bash. Nada
mais — o ambiente é construído a partir de imagens publicadas e derrubado em seguida.
./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
O código de saída 0 significa que a vulnerabilidade foi reproduzida, 1 significa que não foi,
2 significa que o ambiente não conseguiu subir. Cada requisição e resposta é gravada em
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
Essa última linha é a parte realista. Os operadores delimitam um catálogo com
allowedLocations; a role de IAM subjacente é quase sempre mais ampla do que um
único prefixo. allowedLocations é a parede. Este bug a contorna.
Teste 0 — a parede é real. Criar uma tabela com uma localização explícita em
s3://tenant-b-private é recusado com 403 ForbiddenException, antes que qualquer coisa seja
lida. O Polaris sabe muito bem que a localização está fora dos limites.
Teste 1 — o register lê mesmo assim. O mesmo principal aponta register para
s3://tenant-b-private/sales/metadata/00007-tenant-b-sales.metadata.json. A
resposta também é um 403 — mas ela cita
s3://tenant-b-private/warehouse/CANARY-64640-4f1c9e2a-tenant-b-sales, uma string
que existe apenas no corpo daquele objeto:
{"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}}
A requisição nunca menciona warehouse/. O Polaris só poderia produzir essas strings
buscando e analisando o objeto com as credenciais do catálogo. O 403 é o Polaris
capturando a violação um passo depois da leitura que deveria prevenir.
Teste 2 — o que volta. Dois campos distintos extraídos do documento vítima são
ecoados de volta ao chamador: a location declarada da tabela e sua propriedade
write.data.path.
Teste 3 — um oráculo de enumeração de armazenamento. A mesma chamada responde de forma
diferente para cada estado de um alvo fora de allowedLocations:
sonda (todas fora de allowedLocations) | resposta da 1.4.1 |
|---|---|
| metadados Iceberg válidos, existem | 403 ForbiddenException + localizações analisadas ecoadas |
| chave ausente | 400 NotFoundException — Location does not exist: … |
| bucket não existe | 400 NoSuchBucketException — erro bruto do SDK S3 |
| existe, mas não é metadado Iceberg | 503 RuntimeIOException — Failed to read file: … |
Quatro respostas distinguíveis significam quatro fatos sobre o armazenamento que o chamador não tem o direito de aprender. Em AWS real, a sonda de existência de bucket alcança o namespace global de buckets S3, usando as credenciais do catálogo.
Em uma build corrigida, cada linha dessa tabela é o mesmo 403 mencionando apenas o
caminho solicitado, e nada de dentro do objeto volta — o script detecta essa assinatura
e reporta NOT VULNERABLE — pre-validation observed.
Teste 4 — a mesma falha em register-view. O Polaris 1.6.0 adicionou
POST .../namespaces/{ns}/register-view, alcançando o registerView, que carrega o
FileIO e analisa o documento do chamador sem validação prévia — exatamente o que o
registerTable tinha acabado de parar de fazer. Na 1.6.0, o canário de view volta:
{"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}}
Portanto, uma implantação na 1.6.0 ainda está exposta, por meio de um endpoint diferente, à mesma primitiva. Na 1.4.1 e em versões anteriores, o endpoint não existe e o script informa isso.
IcebergCatalog.registerTable (LocalIcebergCatalog a partir da 1.6.0), antes da correção
— registerView tem exatamente a mesma forma:
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
A cadeia de emissão (loadFileIOForTableLike → StorageAccessConfigProvider.getStorageAccessConfig
→ *StorageIntegration.getSubscopedCreds) não realiza nenhuma verificação própria de
allowedLocations; portanto, a única aplicação é a do momento do commit — e nesse ponto a
leitura privilegiada já aconteceu e o resultado dela está na resposta.