CVE-2024-37084 là lỗ hổng bảo mật trong Spring Cloud Skipper, liên quan cụ thể đến cách ứng dụng xử lý input data YAML.
Lỗ hổng phát sinh từ việc sử dụng hàm tạo Yaml chuẩn (standard Yaml constructor) , cho phép hủy tuần tự hóa (deserialization )các object tùy ý. Từ đây attacker có thể tận dụng để khai thác bằng cách cung cấp dữ liệu YAML độc hại (malicious YAML data) , có khả năng dẫn đến thực thi mã từ xa ( RCE ) .
Lỗ hổng ảnh hưởng đến các phiên bản 2.11.x & 2.10.x của Spring Cloud Skipper.
DiffCode
Diffing
Bản vá cho CVE-2024-37084, được nêu chi tiết trên GitHub có thể so sánh source code , diff nó với bản 2.11.x hoặc theo dõi thay đổi trên github về report này
Các thay đổi tác động đến một số file đặc biệt là hàm SafeConstructor để đảm bảo YAML deserialization an toàn hơn so với bản 2.11.x , ở đây mình sẽ dùng bản 2.11.0.
Update Constructor cho PackageMetadata
File PackageMetadataSafeConstructor.java -> Update của file PackageMetadata có 1 số thay đổi , ta sẽ xem code diff ở đây :
Update PackageMetadataSafeConstructor an toàn deserializing object hơn so với bản 2.11.0 là PackageMetadata.
Set Up
Download bản 2.11.0 Spring Cloud Data Flow trên link GitHub :
Trong spring-cloud-dataflow-2.11.0/src/docker-compose, mở file docker-compose.yml,
Thêm
JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address =*:5005 vào mục environment skipper-server để setup debug :
Để có thể debug remote ở localhost với port listen là 5005 nên phần ports cũng cần thêm port 5005
Để deloy chạy lệnh :
sudo docker-compose up -d
Sau khi chạy thành công sẽ có thể xem được dashboard và Skipper Server API :
Analysis
View hàm upload() ở 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:
Soure code :
publicPackageMetadataupload(UploadRequest uploadRequest) {validateUploadRequest(uploadRequest);Repository localRepositoryToUpload =getRepositoryToUpload(uploadRequest.getRepoName());Path packageDirPath =null;try { packageDirPath =TempFileUtils.createTempDirectory("skipperUpload");File packageDir =newFile(packageDirPath +File.separator+uploadRequest.getName());packageDir.mkdir();Path packageFile =Paths.get(packageDir.getPath() +File.separator+uploadRequest.getName() +"-"+uploadRequest.getVersion() +"."+uploadRequest.getExtension());Assert.isTrue(packageDir.exists(),"Package directory doesn't exist.");Files.write(packageFile,uploadRequest.getPackageFileAsBytes());ZipUtil.unpack(packageFile.toFile(), packageDir);String unzippedPath =packageDir.getAbsolutePath() +File.separator+uploadRequest.getName()+"-"+uploadRequest.getVersion();File unpackagedFile =newFile(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())) {thrownewSkipperException(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(newPackageFile((uploadRequest.getPackageFileAsBytes())));returnthis.packageMetadataRepository.save(packageMetadata); }catch (IOException e) {thrownewSkipperException("Failed to upload the package.", e); }finally {if (packageDirPath !=null&&!FileSystemUtils.deleteRecursively(packageDirPath.toFile())) {logger.warn("Temporary directory can not be deleted: "+ packageDirPath); } } }
Tóm tắt cách hoạt động của hàm này
Xác thực yêu cầu tải lên.
Tạo thư mục tạm thời để lưu trữ package.
Giải nén package để kiểm tra nội dung.
Xác thực metadata trong package để đảm bảo tính hợp lệ.
Lưu metadata vào cơ sở dữ liệu và liên kết với repository.
Xóa các file tạm sau khi hoàn tất.
Cụ thể hơn :
1. Xác thực yêu cầu tải lên
validateUploadRequest(uploadRequest);
Hàm này kiểm tra dữ liệu của yêu cầu tải lên để đảm bảo rằng nó hợp lệ trước khi tiếp tục.
Tạo một thư mục tạm thời trên hệ thống để chứa package tải lên. Sau đó, một file tạm thời được tạo ra từ dữ liệu của package và lưu vào thư mục này.
4. Giải nén file tải lên
ZipUtil.unpack(packageFile.toFile(), packageDir);
Sau khi file được tải lên, nó sẽ được giải nén để có thể đọc và xử lý dữ liệu bên trong.
5. Xác minh package đã giải nén tồn tại
String unzippedPath =packageDir.getAbsolutePath() +File.separator+uploadRequest.getName() +"-"+uploadRequest.getVersion();File unpackagedFile =newFile(unzippedPath);Assert.isTrue(unpackagedFile.exists(),"Package is expected to be unpacked, but it doesn't exist");
Xác nhận rằng quá trình giải nén đã diễn ra thành công và file đã giải nén tồn tại.
6. Đọc và xác thực metadata của package
Package packageToUpload =this.packageReader.read(unpackagedFile);PackageMetadata packageMetadata =packageToUpload.getMetadata();if (!packageMetadata.getName().equals(uploadRequest.getName()) ||!packageMetadata.getVersion().equals(uploadRequest.getVersion())) {thrownewSkipperException(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()));}
Đọc metadata từ package đã giải nén và so sánh với dữ liệu yêu cầu tải lên
7. Lưu metadata và liên kết với repository
if (localRepositoryToUpload !=null) {packageMetadata.setRepositoryId(localRepositoryToUpload.getId());packageMetadata.setRepositoryName(localRepositoryToUpload.getName());}packageMetadata.setPackageFile(newPackageFile((uploadRequest.getPackageFileAsBytes())));returnthis.packageMetadataRepository.save(packageMetadata);
Link package metadata với repository tương ứng (nếu có), rồi lưu metadata của package vào database
8. Xử lý exception và xóa thư mục tạm thời
finally {if (packageDirPath !=null&&!FileSystemUtils.deleteRecursively(packageDirPath.toFile())) {logger.warn("Temporary directory can not be deleted: "+ packageDirPath); }}
Thư mục tạm thời được tạo ra trước đó sẽ được xóa sau khi quá trình hoàn thành. Nếu không thể xóa được, ghi lại cảnh báo vào log.
Phân tích thêm về method read() qua file DefaultPackageReader.java khi ta search link method này trong project :
Source code :
publicclassDefaultPackageReaderimplementsPackageReader { @OverridepublicPackageread(File packageDirectory) {Assert.notNull(packageDirectory,"File to load package from can not be null");List<File> files;try (Stream<Path> paths =Files.walk(Paths.get(packageDirectory.getPath()),1)) { files =paths.map(i ->i.toAbsolutePath().toFile()).collect(Collectors.toList()); }catch (IOException e) {thrownewSkipperException("Could not process files in path "+packageDirectory.getPath() +". "+e.getMessage(), e); }Package pkg =newPackage();List<FileHolder> fileHolders =newArrayList<>();// Iterate over all files and "deserialize" the package.for (File file : files) {// Package metadataif (file.getName().equalsIgnoreCase("package.yaml") ||file.getName().equalsIgnoreCase("package.yml")) {pkg.setMetadata(loadPackageMetadata(file));continue; }if (file.getName().endsWith("manifest.yaml") ||file.getName().endsWith("manifest.yml")) {fileHolders.add(loadManifestFile(file));continue; }// Package property values for configurationif (file.getName().equalsIgnoreCase("values.yaml") ||file.getName().equalsIgnoreCase("values.yml")) {pkg.setConfigValues(loadConfigValues(file));continue; }// The template filesfinalFile absoluteFile =file.getAbsoluteFile();if (absoluteFile.isDirectory() &&absoluteFile.getName().equals("templates")) {pkg.setTemplates(loadTemplates(file));continue; }// dependent packagesif ((file.getName().equalsIgnoreCase("packages") &&file.isDirectory())) {File[] dependentPackageDirectories =file.listFiles();List<Package> dependencies =newArrayList<>();for (File dependentPackageDirectory : dependentPackageDirectories) {dependencies.add(read(dependentPackageDirectory)); }pkg.setDependencies(dependencies); } }if (!fileHolders.isEmpty()) {pkg.setFileHolders(fileHolders); }return pkg; }privateList<Template> loadTemplates(File templatePath) {List<File> files;try (Stream<Path> paths =Files.walk(Paths.get(templatePath.getAbsolutePath()),1)) { files =paths.map(i ->i.toAbsolutePath().toFile()).collect(Collectors.toList()); }catch (IOException e) {thrownewSkipperException("Could not process files in template path "+ templatePath, e); }List<Template> templates =newArrayList<>();for (File file : files) {if (isYamlFile(file)) {Template template =newTemplate();template.setName(file.getName());try {template.setData(newString(Files.readAllBytes(file.toPath()),"UTF-8")); }catch (IOException e) {thrownewSkipperException("Could read template file "+file.getAbsoluteFile(), e); }templates.add(template); } }return templates; }privatebooleanisYamlFile(File file) {Path path =Paths.get(file.getAbsolutePath());String fileName =path.getFileName().toString();if (!fileName.startsWith(".")) {return (fileName.endsWith("yml") ||fileName.endsWith("yaml")); }returnfalse; }privateConfigValuesloadConfigValues(File file) {ConfigValues configValues =newConfigValues();try {configValues.setRaw(newString(Files.readAllBytes(file.toPath()),"UTF-8")); }catch (IOException e) {thrownewSkipperException("Could read values file "+file.getAbsoluteFile(), e); }return configValues; }privateFileHolderloadManifestFile(File file) {try {returnnewFileHolder(file.getName(),Files.readAllBytes(file.toPath())); }catch (IOException e) {thrownewSkipperException("Could read values file "+file.getAbsoluteFile(), e); } }privatePackageMetadataloadPackageMetadata(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 objectDumperOptions options =newDumperOptions();Representer representer =newRepresenter(options);representer.getPropertyUtils().setSkipMissingProperties(true);LoaderOptions loaderOptions =newLoaderOptions();Yaml yaml =newYaml(new Constructor(PackageMetadata.class, loaderOptions), representer);String fileContents =null;try { fileContents =FileUtils.readFileToString(file); }catch (IOException e) {thrownewSkipperException("Error reading yaml file", e); }PackageMetadata pkgMetadata = (PackageMetadata) yaml.load(fileContents);return pkgMetadata; }}
Tóm tắt:
Hàm read() của lớp DefaultPackageReader duyệt qua thư mục của package, phân tích các file YAML, manifest, cấu hình, và template để tạo đối tượng Package hoàn chỉnh.
Nó xử lý các file theo loại, kiểm tra và tải chúng vào các object Java tương ứng.
Sử dụng thư viện SnakeYAML để đọc và ánh xạ các file YAML thành đối tượng PackageMetadata.
Vuln sẽ nằm ở hàm này.
Quá trình đọc này giúp tải và chuẩn bị package để sử dụng trong các bước tiếp theo.
Cụ thể hơn hàm read() trong DefaultPackageReader
1. Kiểm tra và duyệt qua các file trong thư mục
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) {thrownewSkipperException("Could not process files in path "+packageDirectory.getPath() +". "+e.getMessage(), e);}
Kiểm tra thư mục truyền vào có hợp lệ không. Sau đó sử dụng Files.walk() để duyệt qua các file trong thư mục, lấy danh sách file và lưu vào files.
Đảm bảo rằng thư mục không rỗng và xử lý exception trong trường hợp không thể đọc được.
Hàm này sử dụng thư viện SnakeYAML để đọc file YAML và chuyển nó thành objectPackageMetadata. Nó sử dụng Constructor để ánh xạ các thuộc tính của file YAML thành object Java tương ứng.
Đảm bảo rằng nếu có thuộc tính nào trong YAML không tồn tại trong đối tượng Java, nó sẽ bỏ qua mà không gây lỗi.
Hàm này duyệt qua thư mục "templates", đọc từng file YAML và chuyển đổi thành object Template. Nó load nội dung của file và lưu vào template để sử dụng sau
Đọc file values.yaml hoặc values.yml và lưu trữ nội dung của nó trong đối tượng ConfigValues.
6.Load packageMetadata :
privatePackageMetadataloadPackageMetadata(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 objectDumperOptions options =newDumperOptions();Representer representer =newRepresenter(options);representer.getPropertyUtils().setSkipMissingProperties(true);LoaderOptions loaderOptions =newLoaderOptions();Yaml yaml =newYaml(new Constructor(PackageMetadata.class, loaderOptions), representer);String fileContents =null;try { fileContents =FileUtils.readFileToString(file); }catch (IOException e) {thrownewSkipperException("Error reading yaml file", e); }PackageMetadata pkgMetadata = (PackageMetadata) yaml.load(fileContents);return pkgMetadata; }
Nội dung của chuỗi YAML (fileContents) được chuyển đổi thành object PackageMetadata bằng cách sử dụng yaml.load(fileContents) từ thư viện SnakeYAML.
deserialization: Khi SnakeYAML gặp phải các tag như !!javax.script.ScriptEngineManager, tạo object của các class Java tương ứng. Điều này có thể dẫn đến việc thực thi mã Java.
Debug
Chọn Edit :
Chọn dấu + và thêm remote debug JVM :
Click ok , chú ý port listen đúng với port đã setup ( 5005 )
Đặt breakpoint tại hàm upload() đễ debug xem cách nó load file lên , đặt thêm ở các phần unzip file , check path để kiểm tra :
Trước tiên chuẩn bị payload :
Note : lỗ hổng yaml deserialize này có để cập trong CVE-2022-1471.
Sau khi deserialize -> tạo object -> instance ScriptEngineManager -> sử dụng URLClassLoader exec class từ 1 URL cụ thể ( ở đây là http://localhost:8080)
ScriptEngineManager sẽ tải các script engine có thể từ URL http://localhost:8080/.