
Reproductor para CVE-2026-64640 — Apache Polaris Iceberg REST register/register-view entrega credenciales de almacenamiento y lee una ubicación de metadatos elegida por el atacante antes de validar allowedLocations (lectura entre inquilinos por confusión de diputado). Afecta a ≤ 1.6.0, corregido en 1.7.0.
registerUn reproductor autónomo de un solo comando para CVE-2026-64640: los endpoints register de Iceberg REST en Apache Polaris generan credenciales de almacenamiento en la nube para una ruta proporcionada por el llamante y leen esa ruta en el lado del servidor antes de verificarla contra los allowedLocations del catálogo.
Una entidad (principal) cuyo único privilegio es crear tablas en su propio catálogo puede hacer que Polaris use las credenciales de almacenamiento del catálogo para leer objetos que el catálogo nunca tuvo permitido tocar — el prefijo de otro tenant, otro bucket, cualquier cosa a la que el principal de almacenamiento pueda acceder.
| CVE | CVE-2026-64640 |
| Componente | 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 (subordinado confundido), CWE-639 (bypass de autorización mediante clave controlada por el usuario), CWE-918 (SSRF) |
| Gravedad | Alta — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N (8.1) |
| Afectadas | ≤ 1.6.0 (verificado en 1.3.0-incubating, 1.4.0, 1.4.1, 1.5.0, 1.6.0) |
| Corregida en | 1.7.0 — 1.6.0 corrige solo la ruta de tablas y reintroduce el fallo en la nueva ruta de vistas |
| Privilegio requerido | un principal autenticado con TABLE_CREATE / CATALOG_MANAGE_CONTENT en cualquier catálogo — sin administrador |
1.6.0 no es una corrección. Cierra
register(incidentalmente, dentro de un PR de funcionalidad) y entrega el nuevo endpointregister-viewcon el mismo error de orden. El reproductor demuestra ambas partes. Actualiza a 1.7.0.
Requisitos: Docker con el plugin Compose v2, curl, python3, bash. Nada más: el entorno se construye a partir de imágenes publicadas y se elimina al final.
./exploit.sh # predeterminado: apache/polaris:1.4.1 -> reproduce vía register
./exploit.sh --tag 1.6.0 # parcialmente corregido -> reproduce vía register-view
./exploit.sh --tag 1.7.0 # corregido de forma integral -> rechaza limpiamente en ambos
./scripts/version-matrix.sh # todas las versiones, lado a lado
El código de salida 0 significa que la vulnerabilidad se reprodujo, 1 que no se reprodujo, 2 que el entorno no logró levantarse. Cada petición y respuesta se escribe en 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
Esa última línea es la parte realista. Los operadores acotan un catálogo con allowedLocations; el rol IAM subyacente casi siempre es más amplio que un solo prefijo. allowedLocations es el muro. Este error lo rodea.
Prueba 0 — el muro es real. Crear una tabla con una ubicación explícita en s3://tenant-b-private se rechaza con 403 ForbiddenException, antes de que se lea nada. Polaris sabe perfectamente que la ubicación está fuera de los límites.
Prueba 1 — register la lee de todos modos. El mismo principal apunta register a s3://tenant-b-private/sales/metadata/00007-tenant-b-sales.metadata.json. La respuesta también es un 403, pero cita s3://tenant-b-private/warehouse/CANARY-64640-4f1c9e2a-tenant-b-sales, una cadena que existe solo dentro del cuerpo de ese objeto:
{"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 petición nunca menciona warehouse/. Polaris solo pudo producir esas cadenas obteniendo y analizando el objeto con las credenciales del catálogo. El 403 es Polaris detectando la violación un paso después de la lectura que se suponía que debía evitar.
Prueba 2 — lo que devuelve. Dos campos distintos extraídos del documento víctima se devuelven al llamante: la location declarada de la tabla y su propiedad write.data.path.
Prueba 3 — un oráculo de enumeración de almacenamiento. La misma llamada responde de forma distinta para cada estado de un objetivo fuera de allowedLocations:
sonda (todas fuera de allowedLocations) | respuesta de 1.4.1 |
|---|---|
| metadatos Iceberg válidos, existe | 403 ForbiddenException + ubicaciones analizadas devueltas |
| clave inexistente | 400 NotFoundException — Location does not exist: … |
| bucket inexistente | 400 NoSuchBucketException — error crudo del SDK de S3 |
| existe pero no son metadatos Iceberg | 503 RuntimeIOException — Failed to read file: … |
Cuatro respuestas distinguibles significan cuatro datos sobre el almacenamiento que el llamante no tiene derecho a conocer. En AWS real, la sonda de existencia de bucket alcanza el espacio de nombres global de buckets de S3, usando las credenciales del catálogo.
En una compilación corregida, cada fila de esa tabla es el mismo 403 que menciona solo la ruta solicitada, y no vuelve nada del interior del objeto; el script detecta esa firma e informa NOT VULNERABLE — pre-validation observed.
Prueba 4 — el mismo fallo en register-view. Polaris 1.6.0 añadió POST .../namespaces/{ns}/register-view, que llega a registerView, el cual carga el FileIO y analiza el documento del llamante sin validación previa — exactamente lo que registerTable acababa de dejar de hacer. En 1.6.0 el canario de vista devuelve:
{"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}}
Así que un despliegue 1.6.0 sigue expuesto, a través de un endpoint diferente, a la misma primitiva. En 1.4.1 y anteriores el endpoint no existe y el script lo indica.
IcebergCatalog.registerTable (LocalIcebergCatalog desde 1.6.0), antes de la corrección — registerView tiene la misma forma:
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 cadena de emisión (loadFileIOForTableLike → StorageAccessConfigProvider.getStorageAccessConfig → *StorageIntegration.getSubscopedCreds) no realiza ninguna comprobación propia de allowedLocations, así que la única aplicación de la política es la del momento del commit — y para entonces la lectura privilegiada ya ha ocurrido y su resultado está en la respuesta.