Spring Cloud Skipper _ 2.11.3 任意文件写入 CVE-2024-22263分析
2024-08-26 19:38:10

漏洞描述

Spring Cloud Data Flow(SCDF)是一个基于微服务的工具包,用于在 Cloud Foundry 和 Kubernetes 中构建流式和批量数据处理管道。SCDF中一个核心组件Spring Cloud Skipper负责处理应用程序的部署、升级和回滚等操作。
在受影响版本中,Skipper Server在接收上传请求时对zip文件中的路径校验不严,具有 Skipper Server API 访问权限的攻击者可以通过上传请求将任意文件写入文件系统中的任意位置,从而获得服务器权限。

影响范围

org.springframework.cloud:spring-cloud-skipper-server-core@[2.10.0, 2.11.3)

环境搭建

沟槽的环境搭建,沟槽的 docker。最近国内的 docker 被墙了,之前一直都用的是 linux 系统下的 docker 搭建,这次试试 windows 的直接来
总共 3 个镜像,其实漏洞点是在 springcloud-skiperserver,所以注意 skiper 的版本就行,dataflow 版本我们为了不出啥差错也选择 2.11.2 版本,从其他师傅手里拿到了一份 yaml 文件,应该是官方上的 yaml 文件?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
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"
- ${APPS_PORT_RANGE:-20000-20195:20000-20195}
- "5005:5005"
environment:
- LANG=en_US.utf8
- LC_ALL=en_US.utf8
- JDK_JAVA_OPTIONS=-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8
- 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
- JAVA_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005"
restart: always
volumes:
- ${HOST_MOUNT_PATH:-.}:${DOCKER_MOUNT_PATH:-/home/cnb/scdf}

直接 docker-compose up -d(这里开 5005 端口是为了远程调试)
image.png
然后访问 7577 端口和9393 端口
image.png
image.png
环境就搭建的差不多了,这里我们开始分析源码

漏洞分析

漏洞版本源码解析

https://github.com/spring-cloud/spring-cloud-dataflow/commit/2ac9bfa5c2f7cdcc86938ce036283a37008a
具体的漏洞点是在 PackageService 的 upload 方法,我们先看更新之前的 upload 方法的内容,前段部分内容
image.png
首先第一个方法是validateUploadRequest 方法来处理我们的请求,这里跟进validateUploadRequest
image.png
大致逻辑如下:

  • name,reponame,version,以及 extension 不能为空
  • 后缀必须为 zip 后缀
  • 并且 package 的 bytes 不能为空

其实就是对应了UploadRequest 中的参数
image.png
具体传参的话,我们往上寻找一级调用到 packageController 的 upload 路由处理,requestBody 注解对应 UploadRequest,所以我们要用 JSON 形式进行传参
image.png
稍微注意一点就是 version 变量对应的值应该为我们当前 skiper 服务的版本。
image.png
假设这里我们所有值都传好了,进入后续逻辑:
通过 reponame 来创建localRepositoryToUpload变量,并且初始化好packageDirPath变量
但是如果你进行调试的话会找到具体在 localRepositoryToUpload 的创建过程会报错
也就是下面这段逻辑
image.png
具体的话,其实这里的意思就是在本地或者远程通过 reponame 找到对应的 repository 仓库,如果没有的话就不能进行后续的上传的逻辑了。那这里就应该想到,在本地的话其实是存在一个默认的 repository 仓库的,我们回到对应的 api 目录,可以看到有 repositories
名称为:local,具体内容如下
image.png
所以我们 reponame 给到 local,就能够绕过这里的异常了
后续进入大段 try catch 块内容
image.png
这里的逻辑主要部分如下:

创建一段临时目录–packageDirPath,这一段目录的内容是随机生成的,具体怎么随机的呢就不深究了。
开始创建 packageDir ,就是在当前临时临时目录下拼接 name 参数,创建一段 name 目录
然后创建 packageFile,值是在packageDir 的基础上拼接name参数``/``-``version 参数``zip 后缀这几部分组成临时压缩文件对象
最后通过 File.write 将该文件对象对应的文件内容写入,目录就是 packageFile 对应的目录
然后在调用 java 原生的 zip 解压对象的方法,将其解压到packageDir 目录下

我们这里根据下面这段 JSON 的传参来具体看一下过程
{"name":"test","repoName":"local","version":"1.11.2","extension":"zip","packageFileAsBytes":[80]}
image.png
很明显,我们能够通过控制 name 参数进行目录穿越
构造如下 payload:
{"name":"../../etc","repoName":"local","version":"1.11.2","extension":"zip","packageFileAsBytes":[80]}
也是同样调试到当前 ZipUtil 的 uppack 方法,不后续跟进(因为会识别不了 Bytes 的内容为 zip 文件,无法解压而报错)
image.png
根据我们上面的逻辑分析,我们 payload 这里产生的效果说白了就两句话:

  1. 根目录创建一个临时 zip 文件
  2. 然后将该文件解压到 /etc 目录下

可能师傅们对这个目录不太理解
image.png
这里其实根据相对目录,是在根目录创建了一个 etc-1.11.2.zip 文件
我们进容器看看当前根目录产生的效果
image.png
所以接下来就是将这个etc-1.11.2.zip 文件解压了,调用ZipUtil.unpack(packageFile.toFile(), packageDir);进行解压操作
upack 的第一个参数是目标文件,第二个参数是解压到的具体目录
所以这里就是将该etc-1.11.2.zip 文件解压到/etc 目录,但是现在肯定是不能成功的,因为我们传入的 10 进制字节根本就不是合规的 zip 文件。

payload 构造

(我本来的想法就是覆盖 crontab 进行提权,谁想到容器里面没有 crontab 文件。。。)
接下来就是构造 payload 问题,我们在自己本地创建一个 crontab 文件,然后写入一段测试内容
image.png
然后将其压缩为 zip 文件,用 010 editor 查看他的 16 进制字节
image.png
ctrl shift c 将 16 进制复制一下,丢入 cypherchef 进行进制转化,就是先将他转为原数据,然后再将源数据转为 10 进制即可
image.png
将这一串数据传入 bytes 数组作为最终的 payload 的直接部分
{"name":"../../etc","repoName":"local","version":"1.11.2","extension":"zip","packageFileAsBytes":[80,75,3,4,20,0,0,0,0,0,85,112,211,88,210,22,157,65,2,0,0,0,2,0,0,0,7,0,0,0,99,114,111,110,116,97,98,108,115,80,75,1,2,20,0,20,0,0,0,0,0,85,112,211,88,210,22,157,65,2,0,0,0,2,0,0,0,7,0,0,0,0,0,0,0,1,0,32,0,0,0,0,0,0,0,99,114,111,110,116,97,98,80,75,5,6,0,0,0,0,1,0,1,0,53,0,0,0,39,0,0,0,0,0]}
写入成功,但是肯定如果要这么利用肯定是失败,因为它容器里面本身是不存在 crontab 的定时任务的/(ㄒoㄒ)/~~
image.png

源码 diff 解析

我们看看新版本怎么修的
image.png
首先是 validateUploadRequest 解析 uploadRequest 的步骤该到了 trycatch 块中,上半部分只留下了 repository 的寻找和定义。
然后是packageFile 的定义之前新增了一段 fullname 的定义,具体新的 fullname 拼接逻辑为:
name 参数``-``vesion 参数``.``zip 后缀
但其实后续依然还是packageDir.getPath() + File.separator + fullName作为 packageFile 参数,所以表面上逻辑还是没变的。
那具体的漏洞修复在哪呢?
来看到具体的validateUploadRequest方法,新增了那段随机生成的/tmp/xxxx 目录为参数传入了
image.png
重点是这一段
image.png

构造出/tmp/xxxx/test 目录
然后获取/temp/xxx/中的绝对路径和/tmp/xxx/test 的绝对路径(这里是拼接了 name 参数的绝对路径)
两者进行比较,如果两者不能够对应,就抛出错误

其实就是一段很典型的检测相对路径的逻辑,我们看一下getCanonicalPath()方法定义
image.png
所以暂时这条利用方式就利用不上了

后记

java 中存在文件写入的利用,我个人经验还是不足,可能存在更多的利用?

RCE 尝试

在 drunkbaby 师傅的指导下决定学习一手 spirng 下的任意文件上传该如何利用,不然光看着这个任意文件上传点不能 RCE 都很难受。
首先确定 charset.jar 的目录/layers/paketo-buildpacks_bellsoft-liberica/jre/lib
image.png
然后构造 jar 包,这里我们环境选的是 JDK8 的镜像,但是现在问题一旦我写入 jre 目录时都会报错 409,之前任何目录可以,但是一到 Jre 目录就不行,这个问题也是迟迟没有解决,希望知道的师傅们看到了这篇文章能够帮忙解决一下我这个问题吗?感谢师傅们!