
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.
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:
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.
Las rutas hermanas en el mismo archivo hacen el orden correcto: la creación de vistas y sendNotificationForTableLike ambas validan antes de cargar el FileIO. registerTable era la inconsistente. Recorrido completo en docs/ANALYSIS.md.
Ruta de tablas — commit 1dd5feeb (2026-06-02, publicado por primera vez en 1.6.0) añadió una línea en el lugar correcto:
validateLocationForTableLike(identifier, metadataFileLocation, resolvedParent);
FileIO fileIO = loadFileIOForTableLike(identifier, Set.of(locationDir), ...);
Llegó dentro de un PR de funcionalidad («add RegisterTable overwrite support»), no como corrección de seguridad, así que 1.6.0 distribuyó la corrección sin ningún aviso que la nombrara.
Ruta de vistas — commit 7e822f23 («Validate locations when registering tables and views», #5114), 2026-07-20, publicado por primera vez en 1.7.0, añadió la misma protección a registerView y consolidó las comprobaciones posteriores al análisis para ambas rutas.
1.7.0 también incluye 85a0c292 (#4860, «Fix native catalog credential vending skipping allowedLocations re-validation»), que cierra la brecha relacionada de defensa en profundidad: la propia ruta de credenciales ahora revalida en lugar de confiar en cada llamante. Esa es la mitad estructural del problema, y es otra razón por la que 1.7.0, y no 1.6.0, es la versión en la que hay que estar.
La contención se verificó contra las etiquetas de lanzamiento: 1dd5feeb está en 1.6.0 y 1.7.0; 7e822f23 y 85a0c292 solo están en 1.7.0.
Para quien esté anclado a una línea anterior, ambos cambios se proporcionan como parches: 0001 para la ruta de tablas ≤ 1.5.0, 0002 para la ruta de vistas 1.6.0.
La guía para operadores — ruta de actualización, mitigaciones y cómo buscar explotación en registros existentes — está en docs/REMEDIATION.md.
Producida con ./scripts/version-matrix.sh; cada fila es un arranque completo de la pila y un intento de explotación en vivo. Ver docs/AFFECTED-VERSIONS.md.
1.4.1 importa: es la versión que corrigió CVE-2026-42809, el mismo error de validar después de emitir en la ruta stage-create. Esa corrección se limitó al endpoint del informe, y el patrón se repitió entonces dos veces: register mantuvo el comportamiento hasta 1.6.0, y el nuevo register-view de 1.6.0 nació con él.
Lo que este reproductor demuestra, y nada más:
register en ≤ 1.5.0 y a través de register-view en 1.6.0.Lo que no demuestra: la recuperación masiva del contenido completo de un objeto fuera de alcance a través de la API. Dos comprobaciones posteriores (la ubicación de metadatos debe estar bajo la ubicación de la tabla, y la ubicación analizada debe estar en allowedLocations) impiden que el registro se complete, así que el llamante obtiene un oráculo más fragmentos de metadatos, no el documento completo. Con una anulación del endpoint de S3 configurada en el catálogo, la misma primitiva es una falsificación de petición en el lado del servidor contra el host configurado.
Todo se ejecuta localmente: un proyecto Docker Compose en puertos de loopback, una instancia S3 desechable (RustFS) y archivos sintéticos de «víctima» que este repositorio planta por sí mismo. No se contacta ningún host externo, ninguna credencial sale de la máquina, y docker compose down -v se ejecuta al salir a menos que pases --keep. Los objetos de victim-data/ son fabricados; no hay datos reales en ninguna parte de aquí.
Úsalo en sistemas que sean tuyos o para los que estés autorizado a hacer pruebas.
Encontrado durante una revisión de la corrección de CVE-2026-42809, notificado al equipo de seguridad de la Apache Software Foundation a través del proceso descrito en el SECURITY.md del proyecto, y registrado como CVE-2026-64640.
Apache License 2.0 — ver LICENSE. El entorno Compose se deriva del quickstart de Apache Polaris; ver NOTICE.
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: … |
| Versión | register (tabla) | register-view | Veredicto |
|---|
| 1.3.0-incubating | vulnerable | endpoint ausente | vulnerable |
| 1.4.0 | vulnerable | endpoint ausente | vulnerable |
| 1.4.1 | vulnerable | endpoint ausente | vulnerable |
| 1.5.0 | vulnerable | endpoint ausente | vulnerable |
| 1.6.0 | validado antes de emitir | vulnerable | vulnerable |
| 1.7.0 | validado antes de emitir | validado antes de emitir | corregida |