文件上传原理
常见的上传流程如下: 表单提交->二进制编码->Servlet中使用二进制流获取内容。 通过为表单元素设置Method="post" enctype="multipart/form-data"属性,让表单提交的数据以二进制编码的方式提交,在接受此请求的Servlet中用二进制流来获取内容,就可以获得上传文件的内容,从而实现文件上传。 multipart/form-data这种编码方式的表单会以二进制流的方式来处理表单数据,这种编码方式会把文件域指定文件的内容也封装到请求参数里。
审计思路
定位文件上传代码
任意文件上传产生的主要原因就是在服务器端没有对用户上传的文件类型做校验或者校验不充分,导致用户可以上传恶意脚本到服务器。所以在审计过程中主要是快速定位相关的文件上传业务,然后进行相关的审计。 因为前端需要在使用包含文件上传控件的表单时,必须使用multipart/form-data这个值,所以可以通过搜索multipart/form-data,定位前端上传页面,然后找到对应的上传接口。 当然也可以通过关键字检索,直接定位到后端的上传代码,以下是常见的关键字:
DiskFileItemFactory
@MultipartConfig
MultipartFile
File
upload
InputStream
write
fileName
filePath
......
也可以根据是否引入相关的上传组件/配置进行判断,例如Commons-fileupload、SmartUpload组件,还有以下的SpringMvc配置等:
<!--文件上传,name必须等于multipartResolver-->
<bean name="multipartResolver"
class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
<!--设置文件大小:单位字节-->
<property name="maxUploadSize" value="5000000"></property>
</bean>
看一个实际例子,以下是基于SpringMVC实现的文件上传代码: 前端页面:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form method="post" action="/file/uploadFile.action" enctype="multipart/form-data">
<input type="file" name="multipartFile" >
<input type="submit" value="提交">
</form>
</body>
</html>
后端Controller:
import org.apache.commons.io.FilenameUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.IOException;
import java.util.UUID;
@Controller
public class UploadFileController {
@RequestMapping(value = "/file/uploadFile.action")
public void uploadFile(MultipartFile multipartFile, HttpServletRequest request) throws IOException {
//随机文件名
String fileName = UUID.randomUUID().toString().replace( "-", "" );
String path = request.getServletContext().getRealPath( "/img/" ); //
String extension = FilenameUtils.getExtension(multipartFile.getOriginalFilename());
File file = new File( path + fileName + "." + extension );
//保存文件
multipartFile.transferTo(file);
}
}
除了上述方法以外,还可以直接定位源代码中的工具类,为了降低耦合,上传相关的代码都会通过封装,不同的需求会通过重载或者重写进行实现。
检查要点
- 上传的文件后缀名
主要判断是否有检查后缀名,同时要查看配置文件是否有设置白名单或者黑名单,如果没有的话,那么攻击者利用该缺陷上传类似webshell等恶意文件。 举个例子,下面的代码没有检查相关的文件后缀,仅仅通过ContentType进行判断是否是图片文件,只需要修改Content-Type包含image即可上传任意文件了。
@WebServlet("/Upload.do")
@MultipartConfig()
public class UploadServlet extends HttpServlet {
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// TODO Auto-generated method stub
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter();
String path =this.getServletContext().getRealPath("/");//获取服务器地址
Part p =request.getPart("file");//获取用户选择的上传文件
if(p.getContentType().contains("image")) {//仅处理上传的图像文件
ApplicationPart ap = (ApplicationPart) p;
String filename = ap.getFilename();//获取上传文件名
System.out.println(filename);
int path_idx = filename.lastIndexOf("\\")+1;
String fname = filename.substring(path_idx, filename.length());
p.write(path+"/upload/"+fname);
out.write("文件上传成功");
}else {
out.write("文件上传失败");
}
}
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// TODO Auto-generated method stub
doGet(request, response);
}
}
同时考虑到00截断绕过的问题,在JDK1.7.0_40(7u40)开始对\00进行了检查,相关代码如下:
final boolean isInvalid(){
if(status == null){
status=(this.path.indexOf('\u0000')<0)?PathStatus.CHECKED:PathStatus.INVALID;
}
return status == PathStatus.INVALID;
}
也就是说在后面的版本Java就不存在这个问题了。
- 文件类型
跟后缀检查有点相似,例如判断上传的文件是不是图片,那么可能有时候开发会使用一些工具类或者第三方的组件进行实现。 举个例子,通过生成文件的Image对象,判断Image对象是否为null、Image对象的属性(例如像素、图片的长宽)合法性进行判断。
import java.awt.image.BufferedImage;
import java.io.InputStream;
import javax.imageio.ImageIO;
public class checkImage {
public static boolean checkImage(InputStream imgData) {
try {
BufferedImage bufferedImage = ImageIO.read(imgData);
if(bufferedImage.getHeight() == -1||bufferedImage.getWidth()==-1) {
return false;
}
return true;
} catch (Exception e) {
// TODO: handle exception
return false;
}
}
}
简单来看上述代码是存在一定的安全防护的,的确对当前的InputStream进行了是否是图片格式的检查,但是任何检查的前提都是有依据的,ImageIO.read方法的实现是通过读取文件流中前N个byte值来进行判断的,在没有后缀检查的情况下,仅仅依赖类似这样的文件类型检查,是存在风险的。 可以保留对应的字节内容后再加入Webshell的内容,然后在上传时成功获取到对应的元素属性,便可上传成功(这里是图片的长宽),即可绕过防御。也就是图片码。 所以在遇到一些文件类型检查时,需要额外注意其具体的实现,避免类似的绕过问题。具体的案例之前整理过一个File Upload关于图片马的思考。 常见的关键字如下:
BufferedImage
ImageIO
hutool(一个工具组件)
......
- 文件保存路径
一般来说,如果非业务需要,建议将上传文件保存于非解析目录:
// 将上传文件保存于非解析目录
String uploadPath ="/var/tmp/Upload/";
还要一个要点点就是,需要检查上传位置是否可控,可能的确上传的目录的确是非解析目录,但是如果上传位置可控,攻击者可以尝试通过../路径穿越调整上传位置,从而达到解析Webshell的效果。 举例说明,pathFile固定不变,若fileName可控,且为../../../../../../../../../usr/local/apache-tomcat/webapps/docs/ceshi.jsp,如果没有任何防护措施的情况下,可以跨越pathFile将文件写入到tomcat的docs目录中,达到解析的效果:
File file = new File(pathFile, fileName);
- 文件名
一般是增加攻击利用的难度,同时在一些静态资源越权访问问题场景下也可以起到一定的防护效果。
// 自定义不可预测文件名
String filename = UUID.randomUUID().toString().replaceAll("-","")+".jpg";
File file = new File(uploadPath+filename);
同时上传后的路径等信息也不应该在前端返回。
常见修复
- 采用白名单方式检查文件扩展名,禁止白名单以外的扩展名上传
- 上传文件的保存目录不可解析ASP、JSP、PHP等脚本语言
- 文件名随机命名,如UUID、GUID,不允许用户自定义