
Reproducer for CVE-2026-64640 — Apache Polaris Iceberg REST register/register-view vends storage credentials and reads an attacker-chosen metadata location before validating allowedLocations (confused-deputy cross-tenant read). Affected ≤ 1.6.0, fixed 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:
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.
I percorsi gemelli nello stesso file hanno l'ordine giusto: la creazione di viste e
sendNotificationForTableLike validano entrambi prima di caricare la FileIO.
registerTable era quello incoerente. Analisi completa in
docs/ANALYSIS.md.
Percorso tabelle — commit 1dd5feeb
(2026-06-02, pubblicata per la prima volta nella 1.6.0) ha aggiunto una riga nel punto giusto:
validateLocationForTableLike(identifier, metadataFileLocation, resolvedParent);
FileIO fileIO = loadFileIOForTableLike(identifier, Set.of(locationDir), ...);
È arrivata all'interno di una PR di funzionalità ("add RegisterTable overwrite support"), non di una correzione di sicurezza, quindi la 1.6.0 ha distribuito la correzione senza alcun advisory che la nominasse.
Percorso viste — commit 7e822f23 ("Validate locations when registering tables and views", #5114),
2026-07-20, pubblicata per la prima volta nella 1.7.0, ha aggiunto la stessa guardia a registerView
e ha consolidato i controlli post-analisi per entrambi i percorsi.
La 1.7.0 include anche 85a0c292
(#4860, "Fix native catalog credential vending skipping allowedLocations
re-validation"), che chiude il relativo gap di difesa in profondità: il percorso delle credenziali
stesso ora rivalida invece di fidarsi di ogni chiamante. Questa è la metà
strutturale del problema, ed è un altro motivo per cui la 1.7.0, e non la 1.6.0,
è la release su cui stare.
Il contenimento è stato verificato sui tag di release: 1dd5feeb è nella 1.6.0 e nella
1.7.0; 7e822f23 e 85a0c292 sono solo nella 1.7.0.
Per chi è vincolato a una linea più vecchia, entrambe le modifiche sono fornite come patch:
0001
per il percorso tabelle ≤ 1.5.0, 0002
per il percorso viste della 1.6.0.
Linee guida per gli operatori — percorso di aggiornamento, mitigazioni e come cercare segni di sfruttamento nei log esistenti — sono in docs/REMEDIATION.md.
Prodotta con ./scripts/version-matrix.sh; ogni riga è un avvio completo dello stack e un
tentativo di exploit dal vivo. Vedi docs/AFFECTED-VERSIONS.md.
La 1.4.1 è importante: è la release che ha corretto CVE-2026-42809, lo stesso
errore "valida dopo l'emissione" nel percorso stage-create. Quella correzione era limitata
all'endpoint del report, e poi il pattern si è ripetuto due volte — register ha mantenuto il
comportamento fino alla 1.6.0, e la nuova register-view della 1.6.0 è nata con esso.
Ciò che questo strumento di riproduzione dimostra, e nulla di più:
register su ≤ 1.5.0 e tramite register-view sulla 1.6.0.Ciò che non dimostra: il recupero in blocco del contenuto completo di un oggetto
fuori ambito tramite l'API. Due controlli successivi (la metadata-location deve trovarsi sotto la
location della tabella, e la location analizzata deve essere negli allowedLocations) impediscono
alla registrazione di completarsi, quindi il chiamante ottiene un oracolo più frammenti di metadati,
non l'intero documento. Con un override dell'endpoint S3 configurato sul catalogo, la stessa
primitiva è una falsificazione di richieste lato server contro l'host configurato.
Tutto viene eseguito in locale: un progetto Docker Compose su porte di loopback, un'istanza
S3 monouso (RustFS) e file "vittima" sintetici che questo repository crea da sé.
Non viene contattato alcun host esterno, nessuna credenziale lascia la macchina,
e docker compose down -v viene eseguito all'uscita a meno che non si passi --keep. Gli
oggetti victim-data/ sono fabbricati; non ci sono dati reali da nessuna parte qui.
Usalo su sistemi di tua proprietà o che sei autorizzato a testare.
Trovato durante una revisione della correzione di CVE-2026-42809, segnalato al team di
sicurezza dell'Apache Software Foundation tramite la procedura descritta nel SECURITY.md del
progetto, e tracciato come CVE-2026-64640.
Apache License 2.0 — vedi LICENSE. L'ambiente Compose deriva dalla quickstart di Apache Polaris; vedi NOTICE.
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: … |
| Release | register (tabella) | register-view | Verdetto |
|---|
| 1.3.0-incubating | vulnerabile | endpoint assente | vulnerabile |
| 1.4.0 | vulnerabile | endpoint assente | vulnerabile |
| 1.4.1 | vulnerabile | endpoint assente | vulnerabile |
| 1.5.0 | vulnerabile | endpoint assente | vulnerabile |
| 1.6.0 | validato prima dell'emissione | vulnerabile | vulnerabile |
| 1.7.0 | validato prima dell'emissione | validato prima dell'emissione | corretto |