Spring Cloud Gateway攻击Redis

概述

案例来源于去年二月的一次攻防项目,在进入到目标公司外网nacos后,通过翻阅nacos中的配置文件发现存在gateway路由配置文件,以及内网redis地址、密码。

尝试在nacos中通过编辑路由配置文件,通过新增路由以及配置AddResponseHeader过滤器执行SPEL表达式实现RCE。

测试后发现由于目标是高版本的Spring Cloud修复了该漏洞只能执行简单SPEL表达式无法实现RCE,在最后我们通过Gateway攻击了内网的redis实现了RCE。
本文以spring-cloud-starter-gateway3.1.5测试。

漏洞利用

以actuator/gateway来介绍该利用方法(和nacos原理差不多,通过heapdump也能获取到redis地址密码)。

spring-cloud-starter-gateway#3.0.7、3.1.1修复了CVE-2022-22947,使用GatewayEvaluationContext替换了StandardEvaluationContext。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static class GatewayEvaluationContext implements EvaluationContext {
private final BeanFactoryResolver beanFactoryResolver;
private final SimpleEvaluationContext delegate;

public GatewayEvaluationContext(BeanFactory beanFactory) {
this.beanFactoryResolver = new BeanFactoryResolver(beanFactory);
Environment env = (Environment)beanFactory.getBean(Environment.class);
boolean restrictive = (Boolean)env.getProperty("spring.cloud.gateway.restrictive-property-accessor.enabled", Boolean.class, true);
if (restrictive) {
this.delegate = SimpleEvaluationContext.forPropertyAccessors(new PropertyAccessor[]{new RestrictivePropertyAccessor()}).withMethodResolvers(new MethodResolver[]{(context, targetObject, name, argumentTypes) -> {
return null;
}}).build();
} else {
this.delegate = SimpleEvaluationContext.forReadOnlyDataBinding().build();
}

}

在GatewayEvaluationContext中,生成了SimpleEvaluationContext以及限制了属性的访问和方法的调用。导致从3.0.7开始只能执行一些简单的的SPEL表达式,无法再实现RCE。

题外话,在实战中,经常会遇到通过/actuator/gateway/routes能够发现一些其他攻击者新增的路由,但是自己新增后refresh一直不存在。这种通常是因为被其他攻击者插入了错误路由,导致在refresh的时候一直异常没法新增。
比如其他攻击者在之前已经新增了一个命令执行的路由(或者语法错误的),

然后此时我们去查看路由,是不存在spel这个恶意路由的。

因为版本比较高,漏洞已经修复的原因,导致refresh的时候直接出了异常,所以没法新增路由。

这个时候我们需要先将存在错误的路由给删除掉。
/actuator/gateway/routes 没法看到恶意的路由,通过/actuator/gateway/routedefinitions 接口可以查看到所有的。

然后将这个错误路由删除掉,即可正常新增路由了。

再回到漏洞利用,没法通过SPEL来实现RCE,并且已知了内网redis地址以及密码,很容易想到能否通过新增一个路由来指向redis地址,然后来攻击redis。
虽然gateway新增的路由仅支持http/https协议,但是因为我们能够完全的控制请求包,意味着可以随意的注入新行,按理也能正常攻击redis。

首先创建一个指向redis的路由,然后刷新,访问路由。

1
2
3
4
5
6
7
8
9
{
"id": "redis",
"predicates": [
"Path=/xxxxxxxx/**"
],
"filters": [],
"uri": "http://localhost:6379/",
"order": 0
}

在请求gateway的路由后,gateway确实将完整的请求转发到了redis端口上。

[图片]

然后正常来说,只需要在一个新行里面注入slaveof xx xx即可实现RCE,此时又有了新的问题。

io.netty.handler.codec.DefaultHeaders

1
2
3
4
5
6
7
8
9
10
11
public T addObject(K name, Object value) {
return this.add(name, this.valueConverter.convertObject(ObjectUtil.checkNotNull(value, "value")));
}
public T add(K name, V value) {
this.nameValidator.validateName(name);
ObjectUtil.checkNotNull(value, "value");
int h = this.hashingStrategy.hashCode(name);
int i = this.index(h);
this.add0(h, i, name, value);
return this.thisT();
}

在Spring Cloud Gateway解析重组request的header时,首先通过:分割得到header的name和value,然后调用addObject方法来添加请求头,在该方法中通过validateName方法来验证header name是否合法,this.valueConverter.convertObject方法来转换header value。

验证了header name中是否存在空白符,如果存在空白符就直接抛出了异常。所以没法在header中插入slaveof xxx来实现RCE。

虽然没法在header name中插入redis语句,但是又很容易想到request body里面肯定不会存在限制,可以随意的插入redis语句。

成功在请求包中插入了redis语句。

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
int processCommand(client *c) {
if (!scriptIsTimedout()) {
/* Both EXEC and scripts call call() directly so there should be
* no way in_exec or scriptIsRunning() is 1.
* That is unless lua_timedout, in which case client may run
* some commands. */
serverAssert(!server.in_exec);
serverAssert(!scriptIsRunning());
}

/* in case we are starting to ProcessCommand and we already have a command we assume
* this is a reprocessing of this command, so we do not want to perform some of the actions again. */
int client_reprocessing_command = c->cmd ? 1 : 0;

/* only run command filter if not reprocessing command */
if (!client_reprocessing_command) {
moduleCallCommandFilters(c);
reqresAppendRequest(c);
}

/* Handle possible security attacks. */
if (!strcasecmp(c->argv[0]->ptr,"host:") || !strcasecmp(c->argv[0]->ptr,"post")) {
securityWarningCommand(c);
return C_ERR;
}

但是很容易想到一个问题,redis在很久以前逐行处理命令的时候,就会判断该行中是否含有host: 或者 post关键字,如果含有则会直接返回异常不再继续处理后续的命令。

POST这个关键字没影响,因为我可以随意修改请求包,GET+request body,但是host:这个关键字经过测试,就算我在请求包中删除了host头,经过了gateway的解析重组后它会自动的添加上host头导致没法解决。

在这里卡了几十分钟,一直没法解决。后面想到,既然之前的spel漏洞利用是通过新增filter AddResponseHeader来实现的,那么有没有什么其他的filter能帮助我删除掉host头。

https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway/gatewayfilter-factories/removerequestheader-factory.html

翻阅文档发现还真有一个removerequestheader的filter,最后经过测试发现并不能实现利用,在gateway解析重组的流程中,是先把filter链作用完后再添加了host header,导致无法实现利用。
然后又只能继续翻阅文档看看还有没有什么好玩的filter,

https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway-server-mvc/filters/addrequestheader.html

发现存在addrequestheader filter,能够想到如果在filter链中重组header时,如果gateway没有处理好crlf也可能利用。
addrequestheader组装header最后调用和之前提到的是一致的

io.netty.handler.codec.http.DefaultHttpHeaders#addObject,

1
2
3
public T addObject(K name, Object value) {
return this.add(name, this.valueConverter.convertObject(ObjectUtil.checkNotNull(value, "value")));
}

在this.valueConverter.convertObject中,HeaderValueConverterAndValidator对header value进行校验

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
private static final class HeaderValueConverterAndValidator extends HeaderValueConverter {
static final HeaderValueConverterAndValidator INSTANCE = new HeaderValueConverterAndValidator();

private HeaderValueConverterAndValidator() {
super(null);
}

public CharSequence convertObject(Object value) {
CharSequence seq = super.convertObject(value);
int state = 0;

for(int index = 0; index < seq.length(); ++index) {
state = validateValueChar(seq, state, seq.charAt(index));
}

if (state != 0) {
throw new IllegalArgumentException("a header value must not end with '\\r' or '\\n':" + seq);
} else {
return seq;
}
}

private static int validateValueChar(CharSequence seq, int state, char character) {
if ((character & -16) == 0) {
switch (character) {
case '\u0000':
throw new IllegalArgumentException("a header value contains a prohibited character '\u0000': " + seq);
case '\u000b':
throw new IllegalArgumentException("a header value contains a prohibited character '\\v': " + seq);
case '\f':
throw new IllegalArgumentException("a header value contains a prohibited character '\\f': " + seq);
}
}

switch (state) {
case 0:
switch (character) {
case '\n':
return 2;
case '\r':
return 1;
}
default:
return state;
case 1:
if (character == '\n') {
return 2;
}

throw new IllegalArgumentException("only '\\n' is allowed after '\\r': " + seq);
case 2:
switch (character) {
case '\t':
case ' ':
return 0;
default:
throw new IllegalArgumentException("only ' ' and '\\t' are allowed after '\\n': " + seq);
}
}
}

从该方法中可以看出,如果header value中只\n是不行的,但是只要\t在\n后面就可以,\t不会影响redis命令的解析。

\n抛出了异常,修改为 “value”: “\n\taaaa”

成功注入了新行,并且在host之前。

成功执行了redis命令,最终RCE了目标系统。

总结

Spring Cloud Gateway 3.1.6修复了CRLF注入,在添加header的方法中新增了validateValue方法对header value进行校验,不再允许存在换行等空白符。

1
2
3
4
5
6
7
8
9
public T add(K name, V value) {
this.validateName(this.nameValidator, true, name);
this.validateValue(this.valueValidator, name, value);
ObjectUtil.checkNotNull(value, "value");
int h = this.hashingStrategy.hashCode(name);
int i = this.index(h);
this.add0(h, i, name, value);
return this.thisT();
}

在攻击redis时,除了通过slaveof等方式rce(需要出网,以及版本不能太高),还可以尝试通过set token来利用。目前很多系统的鉴权都通过判断redis中是否含有对应的token实现,通过set token可以通过鉴权进入到目标系统中RCE或者配合fastjson等其他反序列化利用。

spring gateway处理{{}}此类数据时,会尝试解析,所以需要通过append实现。

当然除了攻击redis这种方式,也可以通过新增内网地址gateway尝试攻击内网http应用漏洞,但是由于不知道内网情况难度比较大。