Skip to content

Repository files navigation

CVE-2026-64640 — Apache Polaris: credential vending before location validation in register

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-serviceIcebergCatalog / LocalIcebergCatalog: registerTable, registerView
Endpoints POST /api/catalog/v1/{prefix}/namespaces/{namespace}/register
POST /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-new register-view endpoint with the same ordering mistake. The reproducer proves both halves. Upgrade to 1.7.0.

Run it

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>/.

What the environment looks like

  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.

What it proves

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 NotFoundExceptionLocation does not exist: …
bucket does not exist 400 NoSuchBucketException — raw S3 SDK error
exists but is not Iceberg metadata 503 RuntimeIOExceptionFailed 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.

Root cause

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 (loadFileIOForTableLikeStorageAccessConfigProvider.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.

The fix, in two parts

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.

Verified version matrix

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.

Scope and honesty about impact

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 register on ≤ 1.5.0 and through register-view on 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.

Safety

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.

Credit and disclosure

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.

License

Apache License 2.0 — see LICENSE. The Compose environment is derived from the Apache Polaris quickstart; see NOTICE.

About

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.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages