您的浏览器过于古老 & 陈旧。为了更好的访问体验, 请 升级你的浏览器
Ready 发布于2019年12月24日 17:12 最近更新于 2019年12月24日 21:34

原创 面向开发者的 Web 应用安全入门指南( 6 ):文件上传

3706 次浏览 读完需要≈ 38 分钟 Java

内容目录

文件的上传和管理,是绝大多数网站都必不可少的功能,不过,这也是Web应用中非常容易出现安全隐患的地方。

要实现一个基本的文件上传功能,这对绝大多数开发者来说都非常简单。

例如,我们可以在前端页面显示如下的表单内容:

<form action="https://codeplayer.vip/file/upload" method="POST" enctype="multipart/form-data">
	<div class="form-group">
		<label>选择文件:</label>
		<input id="J_File" name="file" type="file" />
	</div>
	<div class="toolbar">
		<button type="submit">上传</button>
	</div>
</form>

当用户提交上传文件的表单请求后,我们还需要在服务器端进行相应的文件接收、保存等后续处理工作:

@RequestMapping(value = "/file/upload", method = RequestMethod.POST)
public String fileUpload(@RequestParam("file") MultipartFile uploadedFile) {
	// 存储上传文件的根目录
	String dirToSave = Config.get("file.uploadDir");
	// 存储的目标路径
	File target = new File(dirToSave + "/subdir/" + uploadedFile.getName());
	try {
		uploadedFile.transferTo(target);
	} catch (IOException e) {
		// TODO log and handle exception
	}
	// …… omitted for brevity
	return "fileUploadView";
}

可是,这样就够了吗?当然不行!上述代码除了能够实现文件上传的基本功能外,对安全方面的考虑几乎为零 。这样的功能实现,对于攻击者来说,简直就是「漏洞百出」。

下面,我们来简单聊聊与文件上传和文件管理相关的一些安全注意事项。

1、文件名称

1.1、文件扩展名必须被重命名

文件扩展名就是我们常说的文件名后缀,例如 txt、png、gif、exe 等。

上传到服务器的任何文件,在没有通过严格的合法性校验之前,该文件不能以原始的扩展名出现在服务器上,哪怕是一秒钟都不允许!更不能出现在支持对外公开访问的目录内!

因为,哪怕你在迅速校验发现不合法后就立即删除,在此之前的时间差也可能被利用。攻击者可以在上传文件的同时,就一直并发访问该文件,从而让该可执行文件包含的恶意代码被迅速执行。

<?php 
# 用户上传了一个包含如下代码的PHP文件,利用时间差,迅速创建一个可以执行任何恶意代码的PHP文件
fputs(fopen("./attack.php", "w"), '<?php @eval($_POST["cmd"]) ?>');
?>

因此,我们需要将用户上传的文件数据保存在一个形如fa7659ae88cc.tmp的临时文件内,并且一般均存放在系统的临时文件夹内。直到该临时文件通过严格的合法性校验后,我们才能将该文件转移存储到真正的目标路径并恢复原扩展名。

当然,对上传的临时文件直接重命名的工作一般不需要我们来完成。几乎所有的文件上传工具库都已经帮我们实现了该功能。不过,我们也要注意——不要在自行处理临时文件的过程中犯下类似的错误。

1.2、不要使用原文件名来保存文件

如果上传的多个文件会保存在同一目录中,请务必对每个文件进行重命名,否则用户上传的文件名称很容易出现重复,从而可能导致新文件直接覆盖掉原文件(就算无权覆盖也会报错)。

文件的重命名规则请尽量确保每个文件名称的唯一性,这样才能尽可能地避免文件同名冲突。

当然,如果命名规则无法100%保证文件名称的唯一性,你还需要检测该文件名在当前目录中是否已存在注意并发访问问题)。如果存在,则重新生成新的文件名(直到新的文件名未被占用为止)。

# 按照 当前时间 + N位随机数 的方式来重命名文件
hello.png => yyyy-MM-dd-HH-mm-ss-SSS-random.png

# 直接使用 UUID 来重命名文件
hello.jpg => 6fa7659a-e6f6-452b-88cc-4ea819de9a20.jpg

如果你需要记录上传文件的原始名称,建议你将其存储在数据库中,或将原文件名称按照可逆向的规律包含到新的文件名称中,例如:

hello.png => 6fa7659a-e6f6-452b-88cc-4ea819de9a20_hello.png

1.3、尽量不要让原文件名直接参与重命名

如果直接将原文件名称作为新文件名称的一部分,则可能存在一定的安全风险:

  1. 极端情况下新的文件名有可能会超出操作系统所支持的文件名的最大长度限制
  2. 原文件名称中可能包含/\..等具有特殊含义的字符,从而导致攻击者可以自行创建目录或前往目录,进而可能发起恶意攻击。
  3. 如果原文件名以. (空格)结尾,在 Windows 服务器上会被文件系统自动去掉末尾的. ,从而有可能绕过一些限制,进行恶意攻击(Linux服务没有这样的特殊处理)。

2、文件存储路径

文件在服务器端保存的路径也是有讲究的。一般情况下,我们希望用户上传文件后,能够直接通过公开的URL访问到对应的文件。

但是,请务必小心,不要不加限制地将文件直接存放在可以公开执行脚本文件的目录

例如:在PHP语言中,不要将文件存放在可以执行PHP文件的目录;在Java语言中,不要将文件存放在可以执行JSP文件的目录。

否则,用户直接上传一个PHP或JSP脚本文件,并通过URL访问该文件,可能会导致脚本文件中的代码全部被执行。

<%@ page language="java" pageEncoding="UTF-8"%>
<!DOCTYPE HTML>
<html>
<head>
	<title>直接关闭服务器!!!</title>
</head>
<body>
	<% System.exit(0); %>
</body>
</html>

不只是PHP或JSP文件,.jsf.jspx.phtml.php5.php4等类型变种也都可能存在类似的安全风险,甚至连.htaccess.html.htm.shtml.apk.exe.sh等文件和.lnk链接文件也都可能会被恶意滥用。

如何避免上传的PHP或JSP文件中的代码在访问时被解析执行?

答案很简单,就是要确保服务器将所有上传的文件都当作普通文件,不能区分文件扩展名来搞特殊对待,不能将其视作包含代码的任意可执行文件。

因此,我们可以将上传的文件存放在服务器的语言引擎的「势力范围」之外,然后我们再自行编写相应的业务代码,读取并对外输出文件数据以响应用户的访问请求。

当然,我们也可以无需自行编写对外提供文件访问功能相关的代码,我们可以直接使用第三方的代理服务器(如 Apache 和 Nginx)来为用户提供静态资源文件的访问服务。不过,我们需要修改服务器的 MIME配置 (例如 修改Apache中的conf/mime.types文件),确保所有文件的MIME配置都是符合期望的(建议最好采用白名单的方式)。

另外,如果我们的代理服务器还同时集成了某些语言的解析引擎,例如 Apache 集成了 PHP 引擎模块,那么我们必须确保在配置文件中将文件的存储目录排除在该引擎的工作范围之外

<Directory /path/to/save>
	php_admin_flag engine off
	# …… omitted for brevity
</Directory>

出于安全考虑,不建议将上传的文件与 Web应用(或数据库)置于同一服务器(至少不能同一个磁盘)内。此外,我们还要为上传的文件尽量设置合理低权限的 Owner、Group、Others(比如为 代理服务器 和 Web应用服务器 设置不同的系统用户,并尽量降低操作权限)。

3、文件类型

上面我们介绍了如果允许上传所有类型的文件,就需要在文件的存储路径上做多方面的安全考虑。

当然,很多情况,我们的业务需求并不需要支持所有类型的文件,比如只允许上传图片、音频或视频等部分类型的文件。此时,我们就必须编写代码来限制用户上传的文件类型(这种情况,我们就无需过多担心上传的文件可能是PHP或JSP文件了)。

我们以仅允许上传主流类型的图片文件(png、jpg/jpeg、gif、bmp、webp)为例:

<!-- 限制文件域可选择的文件类型 -->
<input name="file" type="file" accept="image/png, image/gif, image/jpeg, image/bmp, image/webp" />
<!-- 你还可以使用 JavaScript 进行校验 -->

当然,我们一定要记住:在前端的任何校验都只是为了提高用户体验,并不能真正提高Web应用的安全性

我们必须老老实实、认认真真地在服务器端编写严格的校验代码(绝不能偷懒)。

而且,请尽量使用白名单方式进行严格检测!

public boolean isValidFileExt(MultipartFile uploadedFile) {
	// 不能只用 MIME 来判断,因为不太可靠,可以篡改数据而绕过
	// String mime = uploadedFile.getContentType();
	//
	// 一定要注意大小写统一,否则可能被绕过
	String filename = uploadedFile.getOriginalFilename().toLowerCase();
	final String[] whiteList = { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
	for (String ext : whiteList) {
		// 最好使用 endsWith()
		// 若使用 indexOf()、lastIndexOf()、contains()、replace() 等方法,一定要足够谨慎,因为有可能绕过
		if (filename.endsWith(ext)) {
			return true;
		}
	}
	return false;
}

4、文件权限

4.1、基于文件系统的权限设置

我们都知道,在大多数文件系统中,针对某个文件,我们可以为不同的用户组或成员分配读(r)、写(w)、执行(x)三种权限。

因此,我们对上传的文件进行严格的权限设置,也能够有效地避免一些安全隐患。

多数情况下,对于那些普通文件,我们可以直接禁用其执行权限(只保留读写权限),从而减少其被恶意利用的风险。

File uploaded = fromUpload();
uploaded.setExecutable(false, true); // 禁止所有人对该文件的执行权限

4.2、基于Web应用的权限设置

除了在文件系统中对文件设置权限外,我们还要考虑是否需要严格控制用户对资源文件的 Web 访问权限。

在一些高度涉及用户隐私、资金敏感的应用场景中,例如用户可能会上传身份证、房产证、驾驶证或各种合同协议之类的图片或扫描件,我们就需要高度重视这个问题。

例如,张三在申请实名认证时就上传了自己的身份证,上传完成后公开的图片访问路径假设为:https://codeplayer.vip/public/2019-12/1357.pnghttps://codeplayer.vip/photo/view?id=1357。如果我们没有在Web应用上进行任何权限控制,那么任何知道该URL的人都能够盗用这些隐私文件,进而可能会给用户造成重大利益损失。而且,这些URL极有规律,恶意攻击者只需要简单枚举并替换掉URL中有规律的时间和数字部分,就能够轻松窃取所有的用户隐私文件。

在这种情况下,我们就需要在应用中采取相应的权限控制策略,譬如:

  1. 只有用户本人和系统后台的工作人员才有权访问相应的文件;
  2. 调整文件名的命名规则,增加文件的名称被无限枚举成功的难度(比如:不要使用连续性的数字序号来命名,而要更加分散更加随机,比如使用 UUID)。
public class FileAccessControlFilter implements Filter {

	@Override
	public void init(FilterConfig filterConfig) throws ServletException { }

	protected boolean canAccess(@Nonnull User sessionUser, HttpServletRequest fileRequest) {
		return // 检测当前会话用户是否为后台工作人员
				sessionUser.isAdmin()
						||
						// 检测用户是否是该文件的上传者
						// 可能需要根据请求地址在数据库中检索文件上传记录,并检测上传用户与当前用户是否一致。
						// 如果想要提高判断效率,我们可以将上传用户的 ID或昵称 等具有唯一确定性的非敏感信息拼接在文件名称中,例如 "xxxyyyzzz-1357.png"
						// 然后我们可以直接从文件名中截取出用户ID(例如:1357),然后直接与当前用户的 ID 进行相等判断,进而无需访问数据库即可快速完成权限检测
						sessionUser.hasOwn(fileRequest);

	}

	@Override
	public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
		User sessionUser = getSessionUser(); // 获取当前会话的登录用户
		if (sessionUser == null || !canAccess(sessionUser, (HttpServletRequest) servletRequest)) {
			// 如果当前用户无权访问该隐私资源,直接响应 404 Not Found
			// 这里之所以不是响应 401 Unauthorized 或 403 Forbidden ,是不想让无关用户知道该资源路径是否存在
			HttpServletResponse response = (HttpServletResponse) servletResponse;
			response.setStatus(HttpServletResponse.SC_NOT_FOUND);
		} else {
			filterChain.doFilter(servletRequest, servletResponse);
		}
	}

	@Override
	public void destroy() {	}
}
	<filter>
		<filter-name>fileAccessControl</filter-name>
		<filter-class>vip.codeplayer.web.filter.FileAccessControlFilter </filter-class>
	</filter>

	<filter-mapping>
		<filter-name>fileAccessControl</filter-name>
		<!-- 建议将 公开资源(public)和 隐私资源(private)分开存放,以便于过滤器或业务代码进行快速判断 -->
		<url-pattern>/resource/private/*</url-pattern>
	</filter-mapping>

如果已经采取了上述的策略1,那么策略2就是可选的。在某些文件隐私不太敏感的场景下,可以采取策略2加大枚举难度即可。如果涉及到身份证、房产证等高度隐私的文件,那么策略1是必需的,绝不能省略!

不只是文件的访问,同理,文件的替换删除等操作也要在服务器端进行严格的逻辑检查。

5、文件大小

5.1、单个文件的大小

实现文件上传功能时,对上传文件的大小限制绝不容忽视!否则攻击者上传一个超大文件,不仅会急剧占用服务器的磁盘空间,还会占用更长时间的网络带宽!

当服务器磁盘空间被攻击者恶意占满后,真正的用户将无法正常进行文件上传。更有甚者,如果文件和Web应用(或数据库)位于同一服务器的同一个磁盘,则可能导致Web应用(或数据库)在进行IO操作时由于磁盘空间不足而失败,进而可能导致整个Web应用(或数据库)都无法正常对外提供访问服务!

因此我们必须在文件上传的业务代码对文件的大小进行合理的限制:

@RequestMapping(value = "/file/upload", method = RequestMethod.POST)
public String fileUpload(@RequestParam("file") MultipartFile uploadedFile) {
	if (uploadedFile.getSize() > 1024 * 1024 * 5) {
		// TODO 这里还应该将这个上传的临时文件删除掉

		// 如果前端已经进行了 5MB 的限制,我们在这里还可以采取一些合理的惩罚措施(短时间内禁止上传、封禁账号/IP等)
		// 因此如果前端已经限制了,还能够在服务器端触发文件大小限制的基本可以肯定是恶意的攻击者
		throw new IllegalArgumentException("上传的单个文件不能超过 5 MB !");
	}
	// TODO code……
}

参照上面的代码,我们就可以对单个文件的大小进行合理的限制了。

但是,大家有没有想过这里面还存在一个问题呢?思考ing……

如果你比较注重细节的话,你应该会发现:在这个时候对文件大小进行限制并非完全有用!

为什么呢?因为这个时候,用户的文件已经完整地成功地上传到了服务器,虽然这还只是一个abcxyz.tmp形式的临时文件。

如果用户上传的单个文件有5GB大小,当我们的应用执行到「校验文件大小」的代码时,用户已经将这5GB的文件成功地放在了服务器上。

因为,只有文件完整地上传成功后,我们的校验代码才会执行!此时,攻击者已经成功地占用了服务器5GB的磁盘空间!

虽然这些文件在上传成功后,由于无法通过文件大小的校验而很快被删去。但是我们知道上传如此大的文件是需要比较长的时间的。如果攻击者同时上传几十个这样的文件,且每个文件都已经上传了约 4.9 GB 时,「校验文件大小」的代码尚未执行。而我们的磁盘空间可能已经因为被占满而导致Web应用无法正常工作。

因此,为了避免这种问题,我们应该在文件开始上传或上传的过程中就尽早地进行文件大小校验,一旦发现文件超过了 5MB,就直接快速报错,拒绝其继续上传!

要实现这样的功能,其实也很简单,因为几乎所有的文件上传组件都自带相应的参数设置。

<!-- Spring MVC文件配置-->
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
	<property name="defaultEncoding" value="UTF-8" />
	<property name="maxUploadSizePerFile" value="5242880" /> <!-- 单文件最大5MB -->
	<!-- 这里其实还【必须】加上总的文件大小限制=单文件大小限制,请参看下一小节 -->
</bean>

不同的Web服务器、不同的Web应用框架或组件 对文件大小限制的配置方式也不尽相同,请自行根据实际情况参考其官方文档进行参数配置。

5.2、批量文件的大小

有些Web应用可能要用到一次性上传多个文件的功能。同理,在这种情况下,我们不仅要限制上传的单个文件的大小,还要限制总的文件大小。

<!-- Spring MVC文件配置-->
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
	<property name="defaultEncoding" value="UTF-8" />
	<property name="maxUploadSizePerFile" value="5242880" /> <!-- 单文件最大5MB -->
	<property name="maxUploadSize" value="52428800" /> <!-- 批量文件最大50MB -->
</bean>

5.3、同步服务器的设置

除了在Web应用中进行文件大小的参数设置外,别忘了还要 同步调整 代理服务器(Apache 或 Nginx 等) 和 应用服务器(Tomcat 或 Jetty 等) 自身的请求数据大小的阈值。

以 Tomcat 9.0 为例,其官方文档表示 Tomcat 9.0 默认支持的最大POST数据大小为 2MB。因此,我们需要根据实际情况对相应的参数(maxPostSize)进行调整。

<Connector port="8080" protocol="HTTP/1.1"
            connectionTimeout="20000"
            redirectPort="8443"
			maxPostSize="7340032" />

注意:我们如果只允许单次上传的总文件大小为 5MB,在这里我们就要将maxPostSize调整为 7MB 左右!为什么呢?因为服务器中定义的POST请求数据并不只是包含文件数据,还包含所有额外的附加字段数据。如果你允许其他附加数据最高有2MB(默认值),那么调整后就应该为2 + 5 = 7MB!请根据实际需求自行调整。

注意:在调节 请求数据大小 限制的同时,在某些服务器中,你可能还需要调整 连接/请求的超时时间 设置,否则较为漫长的上传过程可能会因为请求超时而失败!

5.4、文件数据处理

有些时候,我们并不只是简单地将上传的文件重命名后直接保存,还需要经过预先的文件数据检测或处理(比如 裁切、加水印、压缩/解压缩 等)。

在我们对文件数据进行处理的过程中,我们一定要记住尽可能文件流的方式进行数据处理,而尽量不要直接加载全部的文件数据到内存中!

还记得多年以前,有一个已经工作了好几年的同事,在其维护的某个系统中,就因为错误的文件处理方式导致堆内存溢出,进而引发了严重的宕机事故(请直接参考上述链接中的案例,希望引以为戒)。

6、请求频率限制

限制了单个文件的大小和总的文件大小就能够避免磁盘空间被快速占满吗?严谨地思考一下,这也不行。因为占用的磁盘空间 = 单个文件平均大小 * 文件数量。我们已经对单个文件的大小进行了合理的限制,但是参数因子 文件数量 可还没有被限制呢!

尽管我们已经限制了单文件最大为5MB,但攻击者可以同时并行发起多个上传请求,那么磁盘空间仍然可能被恶意占用。

因此,我们也需要对单个用户、单个IP的文件上传请求进行合理的限制。但是,需要特别注意一些细节:

  1. 要以一定时间成功完成上传的文件数作为阈值,不能纯粹以请求次数(包含失败的),否则,一旦出现网络或应用故障,可能会导致正常用户的合法请求也被大面积误伤。
  2. 完全以成功上传的文件数为阈值也存在一点问题,因为从开始上传到成功是有一个过程的,如果网速比较慢,这个过程也会比较长。攻击者可以利用这个时间差来进行大面积的并发请求。因此,我们还要限制单用户或单IP的并发请求数。
  3. 对单个IP进行限制时,其阈值的设定一定要合理,可以适当地放宽松。因为多个局域网用户可能共用一个公网IP,尤其是在移动网络的IPv4环境下,很多电信运营商采取的NAT网关模式,就是局域网多用户共享一个公网IP的典型案例。
  4. 在设定阈值时,一定要充分考虑当前产品的实际业务需求,比如旅拍、社交类网站的传图频率就很高。此外,我们还可以结合其他反欺诈技术进行多方联控。

7、远程抓取

有些时候,我们还需要根据用户指定的URL去远程抓取(下载)对应的资源文件,并将其视作用户上传的文件进行保存。

由于涉及到从服务器自身发出网络请求,因此安全风险将更大,相关风险因素也更加复杂。

下面我们进行一些简单的概括:

7.1、注意URL的合法性

必须高度警惕攻击者利用该方式对服务器自身、内网其它服务器、外网其它服务器发起攻击!

例如攻击者指定的URL可能为:

  • http://localhost/path/to/attack
  • http://127.0.0.1/path/to/attack
  • http://192.168.0.1/path/to/attack
  • file:///usr/local/any/file
  • ftp://192.168.0.1/path/to/attack
  • ssh://192.168.0.2/path/to/attack
  • http://www.google.com/path/to/attack

一定要严格检查URL的合法性,采用黑名单和白名单等多种方式对 协议类型、主机名、端口号 等进行严格检查。

7.2、以临时文件进行存放

下载的文件在本地必须以形如abcxyz.tmp的临时文件形式存放在系统的临时文件夹内。

7.3、快速甄别响应头

请求获得响应时,务必检查响应头信息,包括Content-LengthContent-Type等,一旦发现不符合业务需求(文件大小、文件类型等),无需接收响应数据,直接快速中断连接。

7.4、文件大小和扩展名的检测仍必不可少

前面我们说过Content-Type等字段信息是可以伪造的,因此我们仍然需要严格检查请求路径的扩展名。由于某些动态请求没有明确的扩展名,我们也需要在保存文件时根据白名单内的Content-Type为其强行分配对应的文件扩展名。下载完成的文件仍然需要再次进行文件大小的校验,甚至还可以调用对应文件类型的处理函数库对文件内容进行检测。

7.5、比文件上传更严格的频率限制

因为远程抓取的风险因素更多,因此需要更加严格的频率限制。尤其要警惕攻击者利用我方服务器作为中转对任意站点发起 DDoS 攻击。业务代码一旦检测到明显的恶意利用,需要进行更加严重的惩罚(较长时间乃至永久封禁账号、IP等)。

7.6、使用独立的服务器

最好使用独立的服务器来负责资源文件的远程抓取(下载),并且禁用该服务器对其他内网服务器的大多数甚至所有的连接发起权限。此外,尽量降低请求发起程序所属用户/组的操作权限。

7.7、去污无残留

任何中断或失败的请求,记得同步清除掉本地已经下载的文件部分。

8、第三方集成

有些时候,我们可能需要集成第三方组件,并要求第三方组件支持文件上传功能,比如富文本编辑器。

庆幸的是,很多第三方的富文本编辑器就已经默认集成了文件上传功能,并且提供了服务器端的示范代码。

以百度的开源富文本编辑器 UEditor 为例,官方当前最新版本为 1.4.3,其提供了JSP、ASP、PHP等多种语言的后端代码示例。很多开发人员基于「不能重复造轮子」的想法(嗯……实际上是偷懒 ^_^),直接就在自己的Web应用中集成了官方的示例代码,并且没有进行全面而合理的重构。

但是,官方的 Demo 纯粹就只是实现了文件上传等基本功能,并没有严格的文件校验,更没有与Web应用无缝集成的用户访问权限控制。因此,任何用户——哪怕是未登录的用户,只要知道请求路径,都可以随意发起任意多个文件上传请求!

对于支持文件上传和管理功能的第三方组件,我们同样需要严防死守,必须要认真谨慎地核查该组件的相关代码逻辑,并在此基础上进行合理重构,添砖加瓦,以确保第三方组件不会成为Web应用这艘大船底部的一个窟窿!

  • CodePlayer技术交流群1
  • CodePlayer技术交流群2

0 条评论

撰写评论

打开导航菜单