
Riproduttore per CVE-2026-64640 — Apache Polaris Iceberg REST register/register-view fornisce credenziali di storage e legge una posizione di metadati scelta dall'attaccante prima di validare allowedLocations (lettura cross-tenant confuso-deputato). Interessate versioni ≤ 1.6.0, corretto in 1.7.0.
registerUno strumento di riproduzione autonomo, con un solo comando, per CVE-2026-64640: gli endpoint
register dell'Iceberg REST in Apache Polaris generano credenziali di storage cloud per un
percorso fornito dal chiamante e leggono quel percorso lato server prima di controllarlo
rispetto agli allowedLocations del catalogo.
Un principal il cui unico privilegio è creare tabelle nel proprio catalogo può far sì che Polaris usi le credenziali di storage del catalogo per leggere oggetti che il catalogo non avrebbe mai dovuto toccare — il prefisso di un altro tenant, un altro bucket, qualsiasi cosa raggiungibile dal principal di storage.
| CVE | CVE-2026-64640 |
| Componente | polaris-runtime-service — IcebergCatalog / LocalIcebergCatalog: registerTable, registerView |
| Endpoint | 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 (authorization bypass through user-controlled key), CWE-918 (SSRF) |
| Gravità | Alta — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N (8.1) |
| Versioni affette | ≤ 1.6.0 (verificato su 1.3.0-incubating, 1.4.0, 1.4.1, 1.5.0, 1.6.0) |
| Corretto in | 1.7.0 — la 1.6.0 corregge solo il percorso delle tabelle e reintroduce il difetto nel nuovo percorso delle viste |
| Privilegio richiesto | un principal autenticato con TABLE_CREATE / CATALOG_MANAGE_CONTENT su qualsiasi catalogo — nessun admin |
La 1.6.0 non è una correzione. Chiude
register(incidentalmente, dentro una PR di funzionalità) e introduce il nuovo endpointregister-viewcon lo stesso errore di ordinamento. Lo strumento di riproduzione dimostra entrambe le metà. Aggiornare alla 1.7.0.
Requisiti: Docker con il plugin Compose v2, curl, python3, bash. Nient'altro
— l'ambiente è costruito da immagini pubblicate e smantellato al termine.
./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
Il codice di uscita 0 significa che la vulnerabilità è stata riprodotta, 1 che non lo è stata, 2 che
l'ambiente non è riuscito ad avviarsi. Ogni richiesta e risposta viene scritta in
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
L'ultima riga è la parte realistica. Gli operatori delimitano un catalogo con
allowedLocations; il ruolo IAM sottostante è quasi sempre più ampio di un singolo
prefisso. allowedLocations è il muro. Questo bug lo aggira.
Test 0 — il muro è reale. La creazione di una tabella con una location esplicita in
s3://tenant-b-private viene rifiutata con 403 ForbiddenException, prima che venga letta
qualsiasi cosa. Polaris sa perfettamente che la location è fuori dai limiti.
Test 1 — register la legge comunque. Lo stesso principal punta register a
s3://tenant-b-private/sales/metadata/00007-tenant-b-sales.metadata.json. La
risposta è anch'essa un 403 — ma cita
s3://tenant-b-private/warehouse/CANARY-64640-4f1c9e2a-tenant-b-sales, una stringa
che esiste solo all'interno del corpo di quell'oggetto:
{"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}}
La richiesta non menziona mai warehouse/. Polaris può produrre quelle stringhe
solo recuperando e analizzando l'oggetto con le credenziali del catalogo. Il 403 è
Polaris che intercetta la violazione un passo dopo la lettura che avrebbe dovuto impedire.
Test 2 — cosa torna indietro. Due campi distinti estratti dal documento
della vittima vengono restituiti al chiamante: la location dichiarata della tabella e la sua
proprietà write.data.path.
Test 3 — un oracolo di enumerazione dello storage. La stessa chiamata risponde in modo
diverso per ogni stato di un target esterno agli allowedLocations:
probe (tutti fuori dagli allowedLocations) | risposta 1.4.1 |
|---|---|
| metadati Iceberg validi, esistenti | 403 ForbiddenException + location analizzate restituite |
| chiave mancante | 400 NotFoundException — Location does not exist: … |
| bucket inesistente | 400 NoSuchBucketException — errore S3 SDK grezzo |
| esiste ma non è metadati Iceberg | 503 RuntimeIOException — Failed to read file: … |
Quattro risposte distinguibili significano quattro informazioni sullo storage che il chiamante non ha il diritto di apprendere. Su AWS reale la sonda sull'esistenza del bucket raggiunge lo spazio dei nomi globale dei bucket S3, usando le credenziali del catalogo.
Su una build corretta ogni riga di quella tabella è lo stesso 403 che nomina solo il
percorso richiesto, e nulla dall'interno dell'oggetto torna indietro — lo script
rileva quella firma e riporta NOT VULNERABLE — pre-validation observed.
Test 4 — lo stesso difetto su register-view. Polaris 1.6.0 ha aggiunto
POST .../namespaces/{ns}/register-view, che raggiunge registerView, il quale carica la
FileIO e analizza il documento del chiamante senza alcuna validazione preliminare — esattamente ciò
che registerTable aveva appena smesso di fare. Sulla 1.6.0 il canary della vista torna:
{"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}}
Quindi un'installazione 1.6.0 è ancora esposta, attraverso un endpoint diverso, alla stessa primitiva. Sulla 1.4.1 e precedenti l'endpoint non esiste e lo script lo segnala.
IcebergCatalog.registerTable (LocalIcebergCatalog dalla 1.6.0), prima della correzione
— registerView ha la forma identica:
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
La catena di emissione (loadFileIOForTableLike → StorageAccessConfigProvider.getStorageAccessConfig
→ *StorageIntegration.getSubscopedCreds) non esegue alcun controllo su allowedLocations
per conto proprio, quindi l'unica verifica è quella al momento del commit — e a quel punto la
lettura privilegiata è già avvenuta e il suo risultato è nella risposta.