
Reprodutor que explora a emissão de credenciais antes da validação de localização no Apache Polaris Iceberg REST, comprovando leituras de nuvem entre locatários (cross-tenant) e oráculo de existência de bucket.
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:
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.
Os caminhos irmãos no mesmo arquivo acertam a ordem: a criação de view e o
sendNotificationForTableLike validam antes de carregar o FileIO. O registerTable era o
inconsistente. Análise completa em docs/ANALYSIS.md.
Caminho de tabela — commit 1dd5feeb
(2026-06-02, lançado pela primeira vez na 1.6.0) adicionou uma linha no lugar certo:
validateLocationForTableLike(identifier, metadataFileLocation, resolvedParent);
FileIO fileIO = loadFileIOForTableLike(identifier, Set.of(locationDir), ...);
Ela chegou dentro de um PR de funcionalidade ("add RegisterTable overwrite support"), não de uma correção de segurança, então a 1.6.0 entregou a correção sem nenhum advisory que a mencionasse.
Caminho de view — commit 7e822f23 ("Validate locations when registering tables and views", #5114),
2026-07-20, lançado pela primeira vez na 1.7.0, adicionou a mesma proteção ao registerView
e consolidou as verificações de pós-análise para ambos os caminhos.
A 1.7.0 também traz o 85a0c292
(#4860, "Fix native catalog credential vending skipping allowedLocations
re-validation"), que fecha a lacuna relacionada de defesa em profundidade: o próprio caminho
de credenciais agora revalida em vez de confiar em cada chamador. Essa é a metade
estrutural do problema e é mais um motivo para estar na 1.7.0 em vez da 1.6.0.
A contenção foi verificada em relação às tags de release: 1dd5feeb está na 1.6.0 e na
1.7.0; 7e822f23 e 85a0c292 estão apenas na 1.7.0.
Para quem estiver em uma linha mais antiga, ambas as mudanças são fornecidas como patches:
0001
para o caminho de tabela ≤ 1.5.0, 0002
para o caminho de view da 1.6.0.
A orientação para operadores — caminho de atualização, mitigações e como procurar exploração em logs existentes — está em docs/REMEDIATION.md.
Produzida com ./scripts/version-matrix.sh; cada linha é uma subida completa da stack e uma
tentativa real de exploração. Veja docs/AFFECTED-VERSIONS.md.
A 1.4.1 importa: é a versão que corrigiu a CVE-2026-42809, o mesmo erro de
validar-após-emitir no caminho stage-create. Essa correção foi limitada ao endpoint do
relatório, e o padrão então se repetiu duas vezes — o register manteve o comportamento
até a 1.6.0, e a nova register-view da 1.6.0 nasceu com ele.
O que este reprodutor demonstra, e nada mais:
register em ≤ 1.5.0 e de register-view em 1.6.0.O que ele não demonstra: recuperação em massa do conteúdo completo de um objeto fora do
escopo por meio da API. Duas verificações posteriores (a localização dos metadados deve estar
sob a localização da tabela, e a localização analisada deve estar em allowedLocations) impedem
que o registro seja concluído; portanto, o chamador recebe um oráculo mais fragmentos de
metadados, não o documento inteiro. Com um override de endpoint S3 configurado no catálogo, a
mesma primitiva é uma falsificação de requisição no lado do servidor contra o host configurado.
Tudo roda localmente: um projeto Docker Compose em portas de loopback, uma instância S3
descartável (RustFS) e arquivos "vítima" sintéticos que este repositório planta por conta
própria. Nenhum host externo é contatado, nenhuma credencial sai da máquina, e docker compose down -v é executado na saída, a menos que você passe --keep. Os objetos em victim-data/
são fabricados; não há dados reais em lugar nenhum aqui.
Use-o em sistemas que você possui ou está autorizado a testar.
Encontrada durante uma revisão da correção da CVE-2026-42809, reportada à equipe de segurança
da Apache Software Foundation por meio do processo descrito no SECURITY.md do projeto e
rastreada como CVE-2026-64640.
Apache License 2.0 — consulte LICENSE. O ambiente Compose é derivado do quickstart do Apache Polaris; consulte NOTICE.
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: … |
| Versão | register (tabela) | register-view | Veredito |
|---|
| 1.3.0-incubating | vulnerável | endpoint ausente | vulnerável |
| 1.4.0 | vulnerável | endpoint ausente | vulnerável |
| 1.4.1 | vulnerável | endpoint ausente | vulnerável |
| 1.5.0 | vulnerável | endpoint ausente | vulnerável |
| 1.6.0 | validado antes da emissão | vulnerável | vulnerável |
| 1.7.0 | validado antes da emissão | validado antes da emissão | corrigido |