
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.
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 :
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
La chaîne de délivrance (loadFileIOForTableLike → StorageAccessConfigProvider.getStorageAccessConfig → *StorageIntegration.getSubscopedCreds) n'effectue aucun contrôle allowedLocations qui lui soit propre, si bien que la seule application est celle au moment du commit — et à ce stade, la lecture privilégiée a déjà eu lieu et son résultat est dans la réponse.
Les chemins frères dans le même fichier font les choses dans le bon ordre : la création de vue et sendNotificationForTableLike valident toutes deux avant de charger le FileIO. registerTable était celui qui était incohérent. Analyse détaillée dans docs/ANALYSIS.md.
Chemin table — commit 1dd5feeb (2026-06-02, publié pour la première fois dans 1.6.0) a ajouté une ligne au bon endroit :
validateLocationForTableLike(identifier, metadataFileLocation, resolvedParent);
FileIO fileIO = loadFileIOForTableLike(identifier, Set.of(locationDir), ...);
Il est arrivé dans une PR de fonctionnalité (« add RegisterTable overwrite support »), pas dans un correctif de sécurité, si bien que 1.6.0 a livré la correction sans qu'aucun avis de sécurité la mentionne.
Chemin vue — commit 7e822f23 (« Validate locations when registering tables and views », #5114), 2026-07-20, publié pour la première fois dans 1.7.0, a ajouté la même protection à registerView et consolidé les vérifications post-analyse pour les deux chemins.
1.7.0 contient également 85a0c292 (#4860, « Fix native catalog credential vending skipping allowedLocations re-validation »), qui comble la lacune de défense en profondeur associée : le chemin des identifiants lui-même revalide désormais au lieu de faire confiance à chaque appelant. C'est la moitié structurelle du problème, et c'est une raison de plus pour être sur 1.7.0 plutôt que sur 1.6.0.
La présence des correctifs a été vérifiée par rapport aux tags de version : 1dd5feeb est dans 1.6.0 et 1.7.0 ; 7e822f23 et 85a0c292 sont uniquement dans 1.7.0.
Pour quiconque est resté sur une ancienne ligne de version, les deux modifications sont fournies sous forme de correctifs : 0001 pour le chemin table ≤ 1.5.0, 0002 pour le chemin vue 1.6.0.
Les recommandations aux opérateurs — parcours de mise à niveau, mesures d'atténuation et comment rechercher une exploitation dans les journaux existants — se trouvent dans docs/REMEDIATION.md.
Produite avec ./scripts/version-matrix.sh ; chaque ligne est un démarrage complet de la pile et une tentative d'exploitation réelle. Voir docs/AFFECTED-VERSIONS.md.
1.4.1 compte : c'est la version qui a corrigé CVE-2026-42809, la même erreur de validation après délivrance dans le chemin stage-create. Ce correctif était limité au point de terminaison mentionné dans le rapport, et le schéma s'est ensuite reproduit deux fois — register a conservé ce comportement jusqu'à 1.6.0, et le nouveau register-view de 1.6.0 est né avec ce comportement.
Ce que ce reproducteur démontre, et rien de plus :
register sur ≤ 1.5.0 et via register-view sur 1.6.0.Ce qu'il ne démontre pas : la récupération en masse du contenu complet d'un objet hors périmètre via l'API. Deux vérifications ultérieures (l'emplacement des métadonnées doit se trouver sous l'emplacement de la table, et l'emplacement analysé doit être dans allowedLocations) empêchent l'enregistrement d'aboutir, si bien que l'appelant obtient un oracle ainsi que des fragments de métadonnées, et non le document entier. Avec une surcharge de point de terminaison S3 configurée sur le catalogue, la même primitive est une falsification de requête côté serveur contre l'hôte configuré.
Tout s'exécute en local : un projet Docker Compose sur des ports de loopback, une instance S3 jetable (RustFS), et des fichiers « victime » synthétiques que ce dépôt installe lui-même. Aucun hôte externe n'est contacté, aucun identifiant ne quitte la machine, et docker compose down -v s'exécute à la sortie, sauf si vous passez --keep. Les objets victim-data/ sont fabriqués ; il n'y a aucune donnée réelle nulle part ici.
Utilisez-le sur des systèmes que vous possédez ou que vous êtes autorisé à tester.
Découverte lors d'une revue du correctif de CVE-2026-42809, signalée à l'équipe de sécurité de l'Apache Software Foundation via la procédure décrite dans le SECURITY.md du projet, et suivie sous la référence CVE-2026-64640.
Licence Apache 2.0 — voir LICENSE. L'environnement Compose est dérivé du guide de démarrage rapide d'Apache Polaris ; voir NOTICE.
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: … |
| Version | register (table) | register-view | Verdict |
|---|
| 1.3.0-incubating | vulnérable | point de terminaison absent | vulnérable |
| 1.4.0 | vulnérable | point de terminaison absent | vulnérable |
| 1.4.1 | vulnérable | point de terminaison absent | vulnérable |
| 1.5.0 | vulnérable | point de terminaison absent | vulnérable |
| 1.6.0 | validation avant délivrance | vulnérable | vulnérable |
| 1.7.0 | validation avant délivrance | validation avant délivrance | corrigé |