文件上传的漏洞和防御-设置黑名单和白名单
以PHP
脚本语言为例,一些文件上传功能实现代码没有严格限制用户上传的文件后缀以及文件类型,导致允许攻击者向某个可通过Web
访问的目录上传任意PHP
文件,并能够将这些文件传递给PHP
解释器,就可以在远程服务器上执行任意PHP
脚本。当系统存在文件上传漏洞时攻击者可以将病毒,木马,WebShell
,其他恶意脚本或者是包含了脚本的图片上传到服务器,这些文件将对攻击者后续攻击提供便利。
1. 文件上传漏洞产生原因:
① 文件上传时检查不严:
一些应用在文件上传时根本没有进行文件格式检查,导致攻击者可以直接上传恶意文件。一些应用仅仅在客户端进行了检查,而在专业的攻击者眼里几乎所有的客户端检查都等于没有检查,攻击者可以通过NC
,Fiddler
等断点上传工具轻松绕过客户端的检查。一些应用虽然在服务器端进行了黑名单检查,但是却可能忽略了大小写,如将.php
改为.Php
即可绕过检查;一些应用虽然在服务器端进行了白名单检查却忽略了%00
截断符,如应用本来只允许上传jpg
图片,那么可以构造文件名为xxx.php%00.jpg
,其中%00
为十六进制的0x00
字符,.jpg
骗过了应用的上传文件类型检测,但对于服务器来说,因为%00
字符截断的关系,最终上传的文件变成了xxx.php
。
② 文件上传后修改文件名时处理不当:
一些应用在服务器端进行了完整的黑名单和白名单过滤,在修改已上传文件文件名时却百密一疏,允许用户修改文件后缀。如应用只能上传.doc
文件时攻击者可以先将.php
文件后缀修改为.doc
,成功上传后在修改文件名时将后缀改回.php
。
对于文件上传模块来说,尽量避免上传可执行的脚本文件,为了防止上传脚本需要设置对应的验证方式,使用白名单和黑名单进行文件校验,校验的时候不仅要校验文件的后缀名,还需要校验文件的真实类型。
1、基于白名单验证:允许上传的文件类型,只针对白名单中有的文件类型,文件才能上传成功。
2、基于黑名单验证:不允许上传的文件类型,只针对黑名单中没有的文件类型,文件才能上传成功。
2. 文件类型校验的方法
假如文件上传类型的白名单为:[ jpeg, jpg, bmp, png, rtf, pdf, doc, docx]
① 第一步,校验文件后缀名:
首先获取上传文件的后缀名,然后判断是否在白名单中,如果在就说明是允许上传的文件类型,否则就不允许上传。
② 第二步,校验文件真实类型:
大多数情况下,我们都是通过扩展名来识别一个文件的类型的,比如我们看到一个.pdf
类型的文件我们就知道他是一个pdf
文件。但是,扩展名是可以修改的,当一个文件的扩展名被修改过,怎么识别一个文件的类型呢?这就用到了我们提到的“魔数”。很多类型的文件,其起始的几个字节的内容是固定的,这几个字节的内容也被称为魔数,因为根据这几个字节的内容就可以确定文件类型。有了这些魔术数字,我们就可以很方便的区别不同的文件。
比如,java文件头魔数为CAFEBABE
,可以通过这个魔数来识别class文件格式,所有的.class
文件魔数都是CAFEBABE
比如,应用只能上传.doc
文件时,如果只判断文件后缀名还会引发文件漏洞问题,因为攻击者可以先将.php
文件后缀修改为.doc
,成功上传后在修改文件名时将后缀改回.php
。所以需要根据文件头信息判断文件真实类型,假如用户上传的是.php
文件,只是把后缀名改成了.doc
文件,我们就可以根据文件魔数判断出用户上传的真实文件类型是.php
文而不是.doc
文件,从而禁止用户上传该文件。
3. 本项目中遇到的问题
项目中允许上传的文件类型白名单:[ jpeg, jpg, bmp, png, rtf, pdf, doc, docx, txt ]
,这个白名单的txt
文件比较特殊,它没有固定的文件头魔数,就是说每个txt
文件的文件头信息都不同,那么就没有办法根据文件头信息判断这个文件到底是不是txt
文件,此时就需要用到文件上传类型黑名单,黑名单中是常见的可能会导致文件漏洞,木马,病毒的文件类型,当上传的txt
文件的真实类型是这种文件时,需要禁止上传,但黑名单总归是不能预防所有安全问题的,所以直接禁止用户上传txt
文件。
方式1 :配置文件
① 定义允许上传的附件类型:
attachment:
file:
maxSize: 10
types:
jpeg: FFD8FF
jpg: FFD8FF
bmp: 424D
png: 89504E47
rtf: 7B5C727466
pdf: 255044462D312E
doc: D0CF11E0
docx: 504B030414
② 读取application.yml中的配置文件:
@Data
@ConfigurationProperties(prefix = "attachment.file")
public class AttachmentFileConfig {
private Double maxSize;
private Map<String, String> types;
}
③ 校验上传文件的类型,文件的大小,根据文件的头信息返回文件的真实类型
public class AttachmentTypes {
@Autowired
private AttachmentFileConfig imageConfig;
public AttachmentTypes(AttachmentFileConfig imageConfig) {
this.imageConfig = imageConfig;
}
public String isValid(MultipartFile multipartFile) {
Double maxSize = imageConfig.getMaxSize();
// 校验上传文件的类型,文件的大小,根据文件的头信息返回文件的真实类型
return FileUtils.checkFile(multipartFile, maxSize, imageConfig.getTypes());
}
}
@Slf4j
public class FileUtils {
/**
* 文件类型和文件大小校验
*
* @param file 上传的附件
* @param fileMaxSize 限制上传附件的大小
* @param allowedFileType 限制上传附件的类型
*/
public static String checkFile(MultipartFile file, Double fileMaxSize, Map<String, String> allowedFileType) {
String fileType;
// 文件类型判断 - 校验文件后缀
String fileName = file.getOriginalFilename();
if (StringUtils.isNotBlank(fileName)) {
String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
if (!fileTypeAllowed(suffix, allowedFileType.keySet())) {
throw new CommonException(BizCodeEnum.FILE_UPLOAD_TYPE_NOT_ALLOWED);
}
} else {
throw new CommonException(BizCodeEnum.FILE_UPLOAD_FILENAME_NOT_ALLOWED);
}
// 文件类型判断 - 校验文件头内容
try (InputStream inputStream = file.getInputStream()) {
// 获取到上传文件的文件头信息
String fileHeader = FileUtils.getFileHeader(inputStream);
if (StringUtils.isBlank(fileHeader)) {
log.error("Failed to get file header content.");
throw new CommonException(BizCodeEnum.FILE_UPLOAD_TYPE_NOT_ALLOWED);
}
// 根据上传文件的文件头获取文件的真实类型
fileType = getFileType(fileHeader,allowedFileType);
if (StringUtils.isBlank(fileType) || !fileTypeAllowed(fileType, allowedFileType.keySet())) {
log.error("Unsupported file type: [{}]", fileType);
throw new CommonException(BizCodeEnum.FILE_UPLOAD_TYPE_NOT_ALLOWED);
}
} catch (IOException e) {
log.error("Get file input stream failed.", e);
throw new CommonException(BizCodeEnum.ATTACHMENT_UPLOAD_ERROR);
}
// 文件大小校验 - 单位:MB
long fileBytes = file.getSize();
double fileSize = (double) fileBytes / 1048576;
if (fileSize <= 0) {
throw new CommonException(BizCodeEnum.FILE_UPLOAD_EMPTY_FILE);
} else if (fileSize > fileMaxSize) {
throw new CommonException(BizCodeEnum.FILE_UPLOAD_EXCEED_LIMIT);
}
return fileType;
}
/**
* 文件类型校验
*
* @param fileType 待校验的类型
* @param allowedType 允许上传的文件类型
* @return true - 满足,false - 不满足
*/
private static boolean fileTypeAllowed(String fileType, Set<String> allowedType) {
if (StringUtils.isBlank(fileType) || CollectionUtils.isEmpty(allowedType)) {
return false;
}
return allowedType.contains(fileType);
}
/**
* 据文件的头信息获取文件类型
*
* @param fileHeader 文件头信息
* @return 文件类型
*/
public static String getFileType(String fileHeader,Map<String, String> allowedFileType) {
if (fileHeader == null || fileHeader.length() == 0) {
return null;
}
fileHeader = fileHeader.toUpperCase();
Set<String> types = allowedFileType.keySet();
for(String type:types){
boolean b = fileHeader.startsWith(allowedFileType.get(type));
if (b) {
return type;
}
}
return null;
}
/**
* 文件头字节数组转为十六进制编码
*
* @param content 文件头字节数据
* @return 十六进制编码
*/
private static String bytesToHexString(byte[] content) {
StringBuilder builder = new StringBuilder();
if (content == null || content.length <= 0) {
return null;
}
String temp;
for (byte b : content) {
temp = Integer.toHexString(b & 0xFF).toUpperCase();
if (temp.length() < 2) {
builder.append(0);
}
builder.append(temp);
}
return builder.toString();
}
/**
* 获取文件的文件头信息
*
* @param inputStream 输入流
* @return 文件头信息
* @throws IOException 异常
*/
private static String getFileHeader(InputStream inputStream) throws IOException {
byte[] content = new byte[28];
inputStream.read(content, 0, content.length);
return bytesToHexString(content);
}
}
④ Controller层:
@Api("文档附件相关接口")
@RestController
@ResponseResult
@RequestMapping("/api/v1")
public class DocAttachmentController implements CommonConstant {
@Autowired
private DocAttachmentService docAttachmentService;
@Autowired
private AttachmentTypes attachmentTypes;
@ApiOperation(value = "上传附件")
@PostMapping("/attachments")
public DocAttachment add(@Valid AttachmentAddReqVo attachmentAddReqVo) {
MultipartFile file = attachmentAddReqVo.getMultipartFile();
// 校验上传附件的类型和文件大小,并返回文件的真实类型
String fileType = attachmentTypes.isValid(file);
return docAttachmentService.save(new DocAttachment(attachmentAddReqVo, file, fileType), file);
}
}
方式2 :枚举类
① 定义允许上传的附件类型
@Getter
public enum FileTypeEnum {
/**
* 允许上传的附件类型集合
*/
JPEG("jpeg", "FFD8FF"),
JPG("jpg", "FFD8FF"),
PNG("png", "89504E47"),
BMP("bmp", "424D"),
RTF("rtf", "7B5C727466"),
DOC("doc", "D0CF11E0"),
DOCX("docx", "504B030414"),
PDF("pdf", "255044462D312E");
/**
* 允许上传的文件类型的文件后缀
*/
private final String suffixName;
/**
* 允许上传的文件类型的文件头信息
*/
private final String headCode;
/**
* 构造方法
*
* @param suffixName 文件后缀名
* @param headCode 文件头信息
*/
FileTypeEnum(String suffixName, String headCode) {
this.suffixName = suffixName;
this.headCode = headCode;
}
/**
* 获取允许上传的文件类型集合
*
* @return List-String
*/
public static List<String> getFileType() {
List<String> fileTypeList = new ArrayList<>();
for (FileTypeEnum fileTypeEnum : FileTypeEnum.values()) {
fileTypeList.add(fileTypeEnum.getSuffixName());
}
return fileTypeList;
}
}
② 校验文件类型:
@Slf4j
public class FileUtils {
/**
* 文件类型和文件大小校验
*
* @param file 上传的附件
* @param fileMaxSize 限制上传附件的大小
* @param allowedFileType 限制上传附件的类型
*/
public static String checkFile(MultipartFile file, Double fileMaxSize, Set<String> allowedFileType) {
String fileType;
// 文件类型判断 - 校验文件后缀
String fileName = file.getOriginalFilename();
if (StringUtils.isNotBlank(fileName)) {
String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
if (!fileTypeAllowed(suffix, allowedFileType)) {
throw new CommonException(BizCodeEnum.FILE_UPLOAD_TYPE_NOT_ALLOWED);
}
} else {
throw new CommonException(BizCodeEnum.FILE_UPLOAD_FILENAME_NOT_ALLOWED);
}
// 文件类型判断 - 校验文件头内容
try (InputStream inputStream = file.getInputStream()) {
// 获取到上传文件的文件头信息
String fileHeader = FileUtils.getFileHeader(inputStream);
if (StringUtils.isBlank(fileHeader)) {
log.error("Failed to get file header content.");
throw new CommonException(BizCodeEnum.FILE_UPLOAD_TYPE_NOT_ALLOWED);
}
// 根据上传文件的文件头获取文件的真实类型
fileType = getFileType(fileHeader);
if (StringUtils.isBlank(fileType) || !fileTypeAllowed(fileType, allowedFileType)) {
log.error("Unsupported file type: [{}]", fileType);
throw new CommonException(BizCodeEnum.FILE_UPLOAD_TYPE_NOT_ALLOWED);
}
} catch (IOException e) {
log.error("Get file input stream failed.", e);
throw new CommonException(BizCodeEnum.ATTACHMENT_UPLOAD_ERROR);
}
// 文件大小校验 - 单位:MB
long fileBytes = file.getSize();
double fileSize = (double) fileBytes / 1048576;
if (fileSize <= 0) {
throw new CommonException(BizCodeEnum.FILE_UPLOAD_EMPTY_FILE);
} else if (fileSize > fileMaxSize) {
throw new CommonException(BizCodeEnum.FILE_UPLOAD_EXCEED_LIMIT);
}
return fileType;
}
/**
* 文件类型校验
*
* @param fileType 待校验的类型
* @param allowedType 允许上传的文件类型
* @return true - 满足,false - 不满足
*/
private static boolean fileTypeAllowed(String fileType, Set<String> allowedType) {
if (StringUtils.isBlank(fileType) || CollectionUtils.isEmpty(allowedType)) {
return false;
}
return allowedType.contains(fileType);
}
/**
* 据文件的头信息获取文件类型
*
* @param fileHeader 文件头信息
* @return 文件类型
*/
public static String getFileType(String fileHeader) {
if (fileHeader == null || fileHeader.length() == 0) {
return null;
}
fileHeader = fileHeader.toUpperCase();
FileTypeEnum[] fileTypes = FileTypeEnum.values();
for (FileTypeEnum type : fileTypes) {
boolean b = fileHeader.startsWith(type.getHeadCode());
if (b) {
return type.getSuffixName();
}
}
return null;
}
/**
* 文件头字节数组转为十六进制编码
*
* @param content 文件头字节数据
* @return 十六进制编码
*/
private static String bytesToHexString(byte[] content) {
StringBuilder builder = new StringBuilder();
if (content == null || content.length <= 0) {
return null;
}
String temp;
for (byte b : content) {
temp = Integer.toHexString(b & 0xFF).toUpperCase();
if (temp.length() < 2) {
builder.append(0);
}
builder.append(temp);
}
return builder.toString();
}
/**
* 获取文件的文件头信息
*
* @param inputStream 输入流
* @return 文件头信息
* @throws IOException 异常
*/
private static String getFileHeader(InputStream inputStream) throws IOException {
byte[] content = new byte[28];
inputStream.read(content, 0, content.length);
return bytesToHexString(content);
}
}