
Reproducteur pour CVE-2026-64640 — Apache Polaris Iceberg REST register/register-view fournit des identifiants de stockage et lit un emplacement de métadonnées choisi par l'attaquant avant de valider allowedLocations (lecture inter-locataires par député confus). Versions concernées ≤ 1.6.0, corrigé dans 1.7.0.
registerUn reproducteur autonome, en une commande, pour CVE-2026-64640 : les points de terminaison register de l'API REST Iceberg dans Apache Polaris génèrent des identifiants de stockage cloud pour un chemin fourni par l'appelant et lisent ce chemin côté serveur avant de le vérifier par rapport aux allowedLocations du catalogue.
Un principal dont le seul privilège est de créer des tables dans son propre catalogue peut amener Polaris à utiliser les identifiants de stockage du catalogue pour lire des objets que le catalogue n'a jamais été autorisé à toucher — le préfixe d'un autre locataire, un autre bucket, tout ce que le principal de stockage peut atteindre.
| CVE | CVE-2026-64640 |
| Composant | polaris-runtime-service — IcebergCatalog / LocalIcebergCatalog : registerTable, registerView |
| Points de terminaison | POST /api/catalog/v1/{prefix}/namespaces/{namespace}/registerPOST /api/catalog/v1/{prefix}/namespaces/{namespace}/register-view (1.6.0+) |
| CWE | CWE-441 (député confus), CWE-639 (contournement d'autorisation via une clé contrôlée par l'utilisateur), CWE-918 (SSRF) |
| Sévérité | Élevée — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N (8.1) |
| Versions affectées | ≤ 1.6.0 (vérifié sur 1.3.0-incubating, 1.4.0, 1.4.1, 1.5.0, 1.6.0) |
| Corrigé dans | 1.7.0 — 1.6.0 ne corrige que le chemin table et réintroduit la faille sur le nouveau chemin vue |
| Privilège requis | un principal authentifié disposant de TABLE_CREATE / CATALOG_MANAGE_CONTENT sur n'importe quel catalogue — pas d'administrateur |
1.6.0 n'est pas un correctif. Il ferme
register(au passage, dans une PR de fonctionnalité) et livre le tout nouveau point de terminaisonregister-viewavec la même erreur d'ordre. Le reproducteur prouve les deux moitiés. Passez à 1.7.0.
Prérequis : Docker avec le plugin Compose v2, curl, python3, bash. Rien d'autre — l'environnement est construit à partir d'images publiées puis détruit ensuite.
./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
Le code de sortie 0 signifie que la vulnérabilité a été reproduite, 1 qu'elle ne l'a pas été, 2 que l'environnement n'a pas pu démarrer. Chaque requête et réponse est écrite dans 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
Cette dernière ligne est la partie réaliste. Les opérateurs délimitent un catalogue avec allowedLocations ; le rôle IAM sous-jacent est presque toujours plus large qu'un seul préfixe. allowedLocations est le mur. Ce bug le contourne.
Test 0 — le mur est réel. La création d'une table avec un emplacement explicite dans s3://tenant-b-private est refusée avec 403 ForbiddenException, avant que quoi que ce soit ne soit lu. Polaris sait parfaitement que l'emplacement est hors limites.
Test 1 — register le lit quand même. Le même principal pointe register vers s3://tenant-b-private/sales/metadata/00007-tenant-b-sales.metadata.json. La réponse est aussi une 403 — mais elle cite s3://tenant-b-private/warehouse/CANARY-64640-4f1c9e2a-tenant-b-sales, une chaîne qui existe uniquement dans le corps de cet objet :
{"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 requête ne mentionne jamais warehouse/. Polaris ne pouvait produire ces chaînes qu'en récupérant et en analysant l'objet avec les identifiants du catalogue. La 403 est Polaris qui détecte la violation une étape après la lecture qu'elle était censée empêcher.
Test 2 — ce qui revient. Deux champs distincts extraits du document victime sont renvoyés à l'appelant : la location déclarée de la table et sa propriété write.data.path.
Test 3 — un oracle d'énumération de stockage. Le même appel répond différemment pour chaque état d'une cible hors de allowedLocations :
sonde (toutes hors allowedLocations) | réponse 1.4.1 |
|---|---|
| métadonnées Iceberg valides, existantes | 403 ForbiddenException + emplacements analysés renvoyés |
| clé manquante | 400 NotFoundException — Location does not exist: … |
| bucket inexistant | 400 NoSuchBucketException — erreur brute du SDK S3 |
| existe mais n'est pas de la métadonnée Iceberg | 503 RuntimeIOException — Failed to read file: … |
Quatre réponses distinguables signifient quatre faits sur le stockage que l'appelant n'a pas le droit d'apprendre. Sur un AWS réel, la sonde d'existence de bucket atteint l'espace de noms global des buckets S3, en utilisant les identifiants du catalogue.
Sur une version corrigée, chaque ligne de ce tableau est la même 403 ne mentionnant que le chemin demandé, et rien ne revient de l'intérieur de l'objet — le script détecte cette signature et rapporte NOT VULNERABLE — pre-validation observed.
Test 4 — la même faille sur register-view. Polaris 1.6.0 a ajouté POST .../namespaces/{ns}/register-view, atteignant registerView, qui charge le FileIO et analyse le document de l'appelant sans validation préalable — précisément ce que registerTable venait de cesser de faire. Sur 1.6.0, le canari de vue revient :
{"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}}
Une installation 1.6.0 est donc toujours exposée, via un point de terminaison différent, à la même primitive. Sur 1.4.1 et antérieurs, le point de terminaison n'existe pas et le script le signale.
IcebergCatalog.registerTable (LocalIcebergCatalog à partir de 1.6.0), avant le correctif — registerView a la forme identique :
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