
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.
registerA self-contained, one-command reproducer for CVE-2026-64640: the Iceberg REST
register endpoints in Apache Polaris mint cloud storage credentials for a
caller-supplied path and read that path server-side before checking it
against the catalog's allowedLocations.
A principal whose only privilege is creating tables in its own catalog can make Polaris use the catalog's storage credentials to read objects the catalog was never allowed to touch — another tenant's prefix, another bucket, anything the storage principal can reach.
| CVE | CVE-2026-64640 |
| Component | 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 (confused deputy), CWE-639 (authorization bypass through user-controlled key), CWE-918 (SSRF) |
| Severity | High — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N (8.1) |
| Affected | ≤ 1.6.0 (verified on 1.3.0-incubating, 1.4.0, 1.4.1, 1.5.0, 1.6.0) |
| Fixed in | 1.7.0 — 1.6.0 fixes only the table path and reintroduces the flaw on the new view path |
| Privilege required | one authenticated principal with TABLE_CREATE / CATALOG_MANAGE_CONTENT on any catalog — no admin |
1.6.0 is not a fix. It closes
register(incidentally, inside a feature PR) and ships the brand-newregister-viewendpoint with the same ordering mistake. The reproducer proves both halves. Upgrade to 1.7.0.
Requirements: Docker with the Compose v2 plugin, curl, python3, bash. Nothing
else — the environment is built from published images and torn down afterwards.
./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
Exit code 0 means the vulnerability reproduced, 1 means it did not, 2 means
the environment failed to come up. Every request and response is written to
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
That last line is the realistic part. Operators scope a catalog with
allowedLocations; the IAM role underneath is almost always broader than one
prefix. allowedLocations is the wall. This bug walks around it.
Test 0 — the wall is real. Creating a table with an explicit location in
s3://tenant-b-private is refused with 403 ForbiddenException, before anything
is read. Polaris knows perfectly well the location is out of bounds.
Test 1 — register reads it anyway. The same principal points register at
s3://tenant-b-private/sales/metadata/00007-tenant-b-sales.metadata.json. The
response is also a 403 — but it quotes
s3://tenant-b-private/warehouse/CANARY-64640-4f1c9e2a-tenant-b-sales, a string
that exists only inside the body of that object:
{"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}}
The request never mentions warehouse/. Polaris could only produce those strings
by fetching and parsing the object with the catalog's credentials. The 403 is
Polaris catching the violation one step after the read it was supposed to prevent.
Test 2 — what comes back. Two distinct fields parsed out of the victim
document are echoed to the caller: the table's declared location and its
write.data.path property.
Test 3 — a storage enumeration oracle. The same call answers differently for
every state of a target outside allowedLocations:
probe (all outside allowedLocations) | 1.4.1 response |
|---|---|
| valid Iceberg metadata, exists | 403 ForbiddenException + parsed locations echoed |
| key missing | 400 NotFoundException — Location does not exist: … |
| bucket does not exist | 400 NoSuchBucketException — raw S3 SDK error |
| exists but is not Iceberg metadata | 503 RuntimeIOException — Failed to read file: … |
Four distinguishable answers means four facts about storage the caller has no right to learn. On real AWS the bucket-existence probe reaches the global S3 bucket namespace, using the catalog's credentials.
On a fixed build every row of that table is the same 403 naming only the
requested path, and nothing from inside the object comes back — the script
detects that signature and reports NOT VULNERABLE — pre-validation observed.
Test 4 — the same flaw on register-view. Polaris 1.6.0 added
POST .../namespaces/{ns}/register-view, reaching registerView, which loads the
FileIO and parses the caller's document with no prior validation — precisely what
registerTable had just stopped doing. On 1.6.0 the view canary comes back:
{"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}}
So a 1.6.0 deployment is still exposed, through a different endpoint, to the same primitive. On 1.4.1 and older the endpoint does not exist and the script says so.
IcebergCatalog.registerTable (LocalIcebergCatalog from 1.6.0), before the fix
— registerView has the identical shape:
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
The vending chain (loadFileIOForTableLike → StorageAccessConfigProvider.getStorageAccessConfig
→ *StorageIntegration.getSubscopedCreds) performs no allowedLocations check of
its own, so the only enforcement is the commit-time one — and by then the
privileged read has happened and its result is in the response.
Sibling paths in the same file get the order right: view creation and
sendNotificationForTableLike both validate before loading the FileIO.
registerTable was the inconsistent one. Full walkthrough in
docs/ANALYSIS.md.
Table path — commit 1dd5feeb
(2026-06-02, first released in 1.6.0) added one line in the right place: