A 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 sideExit 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 HEREThe 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:
validateLocationForTableLike(identifier, metadataFileLocation, resolvedParent);
FileIO fileIO = loadFileIOForTableLike(identifier, Set.of(locationDir), ...);It arrived inside a feature PR ("add RegisterTable overwrite support"), not a security fix, so 1.6.0 shipped the correction with no advisory naming it.
View path — commit 7e822f23 ("Validate locations when registering tables and views", #5114),
2026-07-20, first released in 1.7.0, added the same guard to registerView
and consolidated the post-parse checks for both paths.
1.7.0 also carries 85a0c292
(#4860, "Fix native catalog credential vending skipping allowedLocations
re-validation"), which closes the related defence-in-depth gap: the credential
path itself now re-validates instead of trusting each caller. That is the
structural half of the problem, and it is another reason 1.7.0 rather than 1.6.0
is the release to be on.
Containment was checked against the release tags: 1dd5feeb is in 1.6.0 and
1.7.0; 7e822f23 and 85a0c292 are in 1.7.0 only.
For anyone pinned to an older line, both changes are provided as patches:
0001
for the ≤ 1.5.0 table path, 0002
for the 1.6.0 view path.
Operator guidance — upgrade path, mitigations, and how to look for exploitation in existing logs — is in docs/REMEDIATION.md.
Produced with ./scripts/version-matrix.sh; each row is a full stack start and a
live exploit attempt. See docs/AFFECTED-VERSIONS.md.
| Release | register (table) |
register-view |
Verdict |
|---|---|---|---|
| 1.3.0-incubating | vulnerable | endpoint absent | vulnerable |
| 1.4.0 | vulnerable | endpoint absent | vulnerable |
| 1.4.1 | vulnerable | endpoint absent | vulnerable |
| 1.5.0 | vulnerable | endpoint absent | vulnerable |
| 1.6.0 | validated before vending | vulnerable | vulnerable |
| 1.7.0 | validated before vending | validated before vending | fixed |
1.4.1 matters: it is the release that fixed CVE-2026-42809, the same
validate-after-vend mistake in the stage-create path. That fix was scoped to the
endpoint in the report, and the pattern then repeated twice — register kept the
behaviour until 1.6.0, and 1.6.0's new register-view was born with it.
What this reproducer demonstrates, and nothing more:
- Polaris performs a credential-vended server-side read of an arbitrary
attacker-chosen location, outside the catalog's declared storage boundary —
through
registeron ≤ 1.5.0 and throughregister-viewon 1.6.0. - Location-shaped fields parsed out of that object are returned to the caller.
- Object existence, bucket existence and object type are observable across everything the catalog's storage principal can reach.
What it does not demonstrate: bulk retrieval of an out-of-scope object's full
contents through the API. Two later checks (metadata-location must sit under the
table location, and the parsed location must be in allowedLocations) stop the
registration from completing, so the caller gets an oracle plus metadata
fragments, not the whole document. With an S3 endpoint override configured on the
catalog, the same primitive is a server-side request forgery against the
configured host.
Everything runs locally: one Docker Compose project on loopback ports, a
throwaway S3 (RustFS) instance, and synthetic "victim" files this repository
plants itself. No external host is contacted, no credentials leave the machine,
and docker compose down -v runs on exit unless you pass --keep. The
victim-data/ objects are fabricated; there is no real data anywhere in here.
Use it on systems you own or are authorised to test.
Found during a review of the CVE-2026-42809 fix, reported to the Apache Software
Foundation security team through the process in the project's SECURITY.md, and
tracked as CVE-2026-64640.
Apache License 2.0 — see LICENSE. The Compose environment is derived from the Apache Polaris quickstart; see NOTICE.