浅谈Springboot中的文件上传

  • A+

引言

  在JavaWeb应用中,任意文件上传一直是关注的重点,攻击者通过上传恶意jsp文件,可以获取服务器权限。但是在Springboot框架对JSP解析存在一定的限制。Spring官方原文如下,大概意思是jsp对内嵌的容器的支持不太友好,推荐使用thymeleaf这类的模版引擎进行渲染。

image.png
  那么针对Springboot应用,即使存在任意文件上传缺陷,按照传统的思路直接上传jsp文件,也是无法达到理想的效果的。下面通过查看其具体的实现方式来看看有没有相关的利用思路,同时在日常项目开发中应该注意些什么。

Springboot文件上传的实现

  首先看看在Springboot中如何实现文件上传功能,在网上找了个教程,Controller的代码如下,Spring会自动解析multipart/form-data请求,将multipart中的对象封装到MultipartRequest对象中:
```
@RequestMapping(value={"/uploadFile"},method={RequestMethod.POST})
public String uploadFile(MultipartFile file,String type,HttpServletResponse response) throws Exception{
String UPLOADED_FOLDER="/resource/upload/";
if(!file.isEmpty()){
String path = UPLOADED_FOLDER + file.getOriginalFilename();
File targetFile = new File(path);
FileUtils.inputStreamToFile(file.getInputStream(),targetFile);
......
......

}

}
  大致是通过getOriginalFilename()方法获取文件名,然后使用File对象创建对应的文件。接下来看看Springboot是如何解析multipart请求并封装OriinalFilename的。
  SpringBoot在MultipartAutoConfiguration自动装配了MultipartResolver来对multipart请求进行解析:
java
@Configuration
@ConditionalOnClass({ Servlet.class, StandardServletMultipartResolver.class,
MultipartConfigElement.class })
@ConditionalOnProperty(prefix \= "spring.http.multipart", name = "enabled", matchIfMissing = true)
@EnableConfigurationProperties(MultipartProperties.class) public class MultipartAutoConfiguration { private final MultipartProperties multipartProperties; public MultipartAutoConfiguration(MultipartProperties multipartProperties) { this.multipartProperties = multipartProperties;
}
@Bean
@ConditionalOnMissingBean public MultipartConfigElement multipartConfigElement() { return this.multipartProperties.createMultipartConfig();
}
@Bean(name \= DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME)
@ConditionalOnMissingBean(MultipartResolver.class) public StandardServletMultipartResolver multipartResolver() {
StandardServletMultipartResolver multipartResolver \= new StandardServletMultipartResolver();
multipartResolver.setResolveLazily(this.multipartProperties.isResolveLazily()); return multipartResolver;
}
}
``
  可以看到其默认装配的解析器是
org.springframework.web.multipart.support。StandardServletMultipartResolver`。查看对应的实现。

StandardServletMultipartResolver的处理方式

  对应的spring-web组件版本为5.3.3。使用StandardServletMultipartResolver解析multipart请求的关键过程如下:
  关键multipart请求的解析方法parseRequest
java
private void parseRequest(HttpServletRequest request)
{
try
{
Collection<Part> parts = request.getParts();
this.multipartParameterNames = new LinkedHashSet(parts.size());
MultiValueMap<String, MultipartFile> files = new LinkedMultiValueMap(parts.size());
for (Part part : parts)
{
String headerValue = part.getHeader("Content-Disposition");
ContentDisposition disposition = ContentDisposition.parse(headerValue);
String filename = disposition.getFilename();
if (filename != null)
{
if ((filename.startsWith("=?")) && (filename.endsWith("?="))) {
filename = MimeDelegate.decode(filename);
}
files.add(part.getName(), new StandardMultipartFile(part, filename));
}
else
{
this.multipartParameterNames.add(part.getName());
}
}
setMultipartFiles(files);
}
catch (Throwable ex)
{
handleParseFailure(ex);
}
}

  主要的解析方法在org.springframework.http.ContentDisposition的parse方法,在这里对相关的http内容进行了处理,获取文件名的关键内容如下,如果传入的multipart请求无法直接使用filename=解析出文件名,Spring还会使用content-disposition解析一次(使用filename*=解析文件名):
```java
public static ContentDisposition parse(String contentDisposition)
{
......
for (int i = 1; i < parts.size(); i++)
{
String part = (String)parts.get(i);
int eqIndex = part.indexOf('=');
if (eqIndex != -1)
{
String attribute = part.substring(0, eqIndex);

    String value = (part.startsWith("\"", eqIndex + 1)) && (part.endsWith("\"")) ? part.substring(eqIndex + 2, part.length() - 1) : part.substring(eqIndex + 1);
    if (attribute.equals("name"))
    {
      name = value;
    }
    else if (attribute.equals("filename*"))
    {
      int idx1 = value.indexOf('\'');
      int idx2 = value.indexOf('\'', idx1 + 1);
      if ((idx1 != -1) && (idx2 != -1))
      {
        charset = Charset.forName(value.substring(0, idx1).trim());
        Assert.isTrue((StandardCharsets.UTF_8.equals(charset)) || (StandardCharsets.ISO_8859_1.equals(charset)), "Charset should be UTF-8 or ISO-8859-1");

        filename = decodeFilename(value.substring(idx2 + 1), charset);
      }
      else
      {
        filename = decodeFilename(value, StandardCharsets.US_ASCII);
      }
    }
    else if ((attribute.equals("filename")) && (filename == null))
    {
      filename = value;
    }

&emsp;&emsp;这里发现一个点,整个过程没有对类似../的路径进行检查/过滤,获取文件名后会实例化`StandardMultipartFile`方便后续程序调用:java
private static class StandardMultipartFile
implements MultipartFile, Serializable
{
......

public String getOriginalFilename()
{
  return this.filename;
}
......

```
  实例化方法同样也没有对原始上传的filename进行检查/过滤,相关接口可以通过getOriginalFilename()方法获得对应的上传文件名,然后进行文件创建。
* 未做安全处理的文件上传

  由于获取的fileName未进行安全处理,在使用File创建文件时,若路径处path写入../../穿越符号,是可以跨目录新建文件的:
java
File file = new File("path")

image.png
  那么也就是说即使Springboot对jsp存在一定的支持限制,在特定情况下那么可以尝试上传定时任务进行权限获取,上传文件名为../../../../../../../../../var/spool/cron/root,结合前面百度到的demo成功反弹shell:

image.png
* 进行了后缀安全检查的文件上传

  到这里针对Springboot任意文件上传的缺陷利用已经有一些眉目了。这里发现一个有趣的点。
  针对任意文件上传,在进行业务开发的时候,常常会对后缀进行相关的白名单检查,如果上传非法后缀,那么拒绝对应的业务请求,例如如下代码:
```java
if(!file.isEmpty()){
String Filename = file.getOriginalFilename();
String suffix = originalFilename.substring(Filename.lastIndexOf("."));
if(!".xlsx".equals(suffix)&&!".xls".equals(suffix)){
throw new Exception("非法请求,请导入excel文件");
}

byte[] bytes = file.getBytes();
String path =ULOADED_FOLDER + Filename;

}
&emsp;&emsp;在进行文件上传时进行了后缀检查,如果不是xlsx或者xls后缀的话,拒绝请求。因为/etc/cron.d/目录下的文件可以任意后缀命名,那么此时可以上传文件名为“../../../../../../etc/cron.d/test.xls”绕过对应的安全检查:
![image.png](/img/sin/M00/00/54/wKg0C2ALqn-Ae2JdAABvSvSN7hg290.png)
![image.png](/img/sin/M00/00/54/wKg0C2ALqo-ADrPeAACPC29oPaw827.png)
&emsp;&emsp;上传成功后,本地监听端口等待定时任务执行,成功反弹 shell,获取服务器权限:
![image.png](/img/sin/M00/00/54/wKg0C2ALquSABADbAAEAet7CmB0657.png)
&emsp;&emsp;其他版本的spring-web组件也是大同小异,例如4版本会通过`extractFilename()`方法进行multipart请求的处理再封装,同样没有处理目录穿越符的问题:
java
private String extractFilename(String contentDisposition, String key)
{
if (contentDisposition == null) {
return null;
}
int startIndex = contentDisposition.indexOf(key);
if (startIndex == -1) {
return null;
}
String filename = contentDisposition.substring(startIndex + key.length());
if (filename.startsWith("\""))
{
int endIndex = filename.indexOf("\"", 1);
if (endIndex != -1) {
return filename.substring(1, endIndex);
}
}
else
{
int endIndex = filename.indexOf(";");
if (endIndex != -1) {
return filename.substring(0, endIndex);
}
}
return filename;
}
```
  综上,关于Springboot的文件上传可以通过结合目录遍历的方式尝试利用,当然也需要满足一定的利用条件,例如对跨目录具有写权限、未重命名文件名等。
  同样的SpringMVC也加载了默认的解析器,一般是CommonsMultipartResolver,查看其对应的处理方式进行对比。

CommonsMultipartResolver的处理方式

  解析部分就不细看了,直接查看getOriginalFilename()
https://github.com/spring-projects/spring-framework/blob/master/spring-web/src/main/java/org/springframework/web/multipart/commons/CommonsMultipartFile.java 95-121行
  可以看到其针对linux和windows的情况对multipart请求中的原始文件名进行了截断处理,防止了../../带来的目录穿越风险:

image.png

结语

  上述的问题已经报告给了[email protected],官方回复如下:

image.png
  因为某些原因暂不打算在StandardServletMultipartResolver上做更多的处理了。建议根据owasp提供的建议在实现上传业务时进行更多的安全检查。https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
  根据前面提及的案例,在采用白名单方式检查文件扩展名的同时,建议对文件名进行二次处理,将其文件名随机命名,如UUID、GUID,不允许用户自定义。避免相关的安全风险。