☰
DVWA File Upload四级别拆解:文件上传漏洞绕过与防御实践
2026/11/6 4:15:32 网站建设 项目流程

做渗透测试入门训练的人,几乎都会在 DVWA 的 File Upload 模块上卡一段时间——不是因为它难,恰恰相反,是因为它太"简单"了,简单到很多人上传一个文件看到成功提示就以为结束了,根本没搞清楚自己刚刚跨过了哪条线。DVWA(Damn Vulnerable Web Application)是一个专门用来练手的开源靶场,File Upload 模块把文件上传漏洞从"完全没防护"到"防护基本到位"拆成了四个难度等级,等于给你摆了一条从裸奔到穿盔甲的完整演进路线。我前后带过几批新人走这个模块,发现真正拉开差距的不是能不能上传成功,而是能不能说清楚每一级校验挡住了什么、又漏了什么、为什么漏。这篇就把我自己的实验记录和踩坑过程摊开讲,从环境搭建、源码逻辑拆解、手工验证、抓包复现,到最后的排查清单和防御设计,尽量把每一步的"为什么"都讲透。适合刚接触 Web 安全的新手照着做,也适合已经会跑工具但没系统梳理过上传链路的人对照补漏。

1. 先搞清楚这个模块到底在教什么

1.1 文件上传漏洞的真正边界在哪

很多人对这个漏洞的理解停留在"能传 PHP 文件上去就是漏洞",这个说法不算错,但太窄了,窄到你在真实业务里会漏掉一大半问题。文件上传的本质是一次信任边界的错位:用户提交的内容本来是"数据",服务器把它当成"代码"来执行,或者当成"可信内容"来展示,问题就出来了。我习惯用一个类比来解释——公司前台帮忙代收快递,这本身没问题;但如果快递员能把包裹直接放到财务室的桌上,而且这个包裹会被当成公司内部下发的文件来执行,那就是灾难。前台代收是"上传功能",财务室是"Web 可访问目录","当成内部文件执行"就是"被解析器处理"。

所以判断一个上传点有没有风险,我一般拆成四个问题来问:能不能传(有没有前置校验)、传到哪(落盘路径是否可控、是否在 Web 根目录下)、叫什么名字(文件名是否可控,决定了扩展名和解析行为)、传完能不能被访问或执行(服务器有没有对应解析器、目录有没有执行权限)。这四个问题里任何一个答案是"是",风险等级就往上跳一档。DVWA 的四个难度级别,本质上就是在逐个收紧这四个问题上的控制点,你把每一级对应到这四个问题上,整个模块的设计意图就一目了然了。

顺带说一句,文件上传的危害也不止代码执行一种。上传 HTML 或 SVG 可以造成存储型 XSS;文件名里塞../可以做路径穿越覆盖配置文件;不限制大小和数量可以做存储耗尽;上传压缩包配合解压功能还能搞出解压目录穿越。这些在 DVWA 里不会全部体现,但脑子里要有这张地图,不然学完只会"传 shell",遇到真实业务场景照样懵。

1.2 四个难度等级的设计意图对照

DVWA 的难度滑块不是随便设的,它对应的是现实中防护能力的四个典型阶段。我整理了一张对照表,建议你在动手之前先看一遍,做完之后再回来看一遍,感受会完全不同。

难度核心校验手段想让你体会什么现实对应
Low无任何校验最原始的上传点长什么样早期内部系统、临时脚本
Medium检查请求里的 MIME 类型、限制大小客户端提交的元数据为什么不可信只做了"前端看起来像图片"的校验
High检查文件扩展名、检查是否为合法图片、限制大小单一维度的校验为什么会被组合拳打穿加了黑名单但没做隔离
Impossible扩展名白名单、强制重命名、限制权限、图片合法性校验纵深防御是什么感觉相对规范的生产实现

这里有个容易被忽略的细节:Medium 和 High 的校验代码都写在服务端 PHP 里,看着挺正经,但校验的对象选错了。Medium 校验的是$_FILES['uploaded']['type'],这个值来自 HTTP 请求头里的Content-Type,是浏览器(或者你手里的抓包工具)说了算的,服务器只是照单全收。High 校验的是文件名后缀和图片头,看起来靠谱多了,但它没有切断"攻击者控制落地文件名"这条链路,所以依然有文章可做。Impossible 之所以难破,不是因为它的校验写得多复杂,而是因为它把"文件名控制权"这个根子上的东西拿走了——重命名成随机哈希,你连自己传上去的文件叫什么都猜不到,后面所有花活都白搭。

1.3 实验环境怎么搭,安全边界怎么划

这个模块必须在完全隔离的本地环境里做,这不是客套话。你上传的验证文件一旦落到一台有公网入口的机器上,性质就从"学习"变成"给别人留后门"了,哪怕那个文件只是打印个 phpinfo,也是实打实的风险敞口。我的习惯是用容器跑,一个命令起来,用完直接删,连痕迹都不留。

典型的部署方式是拉官方镜像,把 80 端口映射到本机一个不常用端口,比如 8080:

docker run -d --name dvwa-lab -p 127.0.0.1:8080:80 --restart=no dvulnerable/dvwa

注意-p前面那个127.0.0.1:,这个绑定很关键,它保证容器端口只监听本机回环地址,局域网里的其他机器根本连不上。很多人图省事写成-p 8080:80,那就是 0.0.0.0 全网监听,同一个 WiFi 下的设备都能访问你的靶场,这个口子开得毫无必要。

起来之后进容器把配置文件拷一份改数据库连接,然后访问/setup.php点一下初始化,它会建库建表顺便重置数据。默认账号密码在登录页有提示,第一次登录会强制改密码,改完记住就行。

有几个 PHP 配置项建议提前确认,不然后面实验会莫名其妙失败:

file_uploads = On upload_max_filesize = 8M post_max_size = 8M upload_tmp_dir = /tmp

post_max_size必须大于等于upload_max_filesize,这是个经典坑——你把上传上限调成 8M,但 POST 上限还是默认的 8M,稍微带点表单字段就超了,PHP 直接把整个 POST 数据丢掉,你会看到$_FILES是空的,还以为是代码问题。另外容器里的/tmp目录权限和磁盘空间也要留意,临时文件写不进去同样表现为"上传失败但没报错"。

最后提醒一句实验纪律:靶场里所有的验证文件,统一放在hacks目录下,实验结束把容器停掉删掉。别把验证文件随手丢到自己电脑的 Web 目录里存着"以后可能用得上",这种习惯迟早出事。

2. 逐级拆解:源码逻辑与攻击面对照

2.1 Low 级别:一次完整的手工验证闭环

Low 级别的代码短得离谱,核心就是把临时文件挪到目标目录,中间没有任何判断:

$target_path = DVWA_WEB_PAGE_TO_ROOT . "hacks/" . $_FILES['uploaded']['name']; move_uploaded_file($_FILES['uploaded']['tmp_name'], $target_path);

读这段代码的时候别急着往下翻,先问自己三个问题:落地目录hacks/在不在 Web 根目录下?文件名是不是完全由$_FILES['uploaded']['name']决定?这个目录里的.php文件会不会被 PHP 解析?三个答案都是"是",那这就是一条完整的利用链。

手工验证的完整动作我建议按这个顺序走,每一步都要停下来看一眼结果,而不是一路点到底:

  1. 准备一个只有一行的验证文件,内容就写<?php phpinfo(); ?>,保存成probe.php。
  2. 在浏览器里选中这个文件,点上传,观察页面返回的路径提示。
  3. 记下返回的完整路径,通常形如.../hacks/probe.php。
  4. 新开标签页直接访问这个路径。
  5. 如果看到 PHP 信息和版本号,说明文件被执行了,整条链路打通。
  6. 如果不执行而是下载或者显示源码,问题不在上传,在服务器解析配置,这是另一类坑,后面第 4 节会专门讲。

这里我要强调"最小验证载荷"这个习惯。很多教程一上来就让你传一个功能完整的木马,我强烈不建议这么干,理由有两个:一是你根本不需要那么多功能,phpinfo()一个函数就足够证明执行成功;二是那些完整脚本在网络上流传多年,早就被各种安全产品拉进特征库,你把它存在本地笔记里,某天在真实环境里手滑传出去,就是实打实的事故。验证目的是证明"能执行",不是证明"能控制",这个分寸要拿捏住。

2.2 Medium 级别:客户端可控的 MIME 类型

切到 Medium,你会发现代码里多了一层判断:

if (($_FILES['uploaded']['type'] == "image/jpeg" || $_FILES['uploaded']['type'] == "image/png") && ($_FILES['uploaded']['size'] < 100000)) { // 执行移动 }

新手看到这段第一反应往往是"这不是防住了吗"。关键在于$_FILES['uploaded']['type']这个值到底从哪来。它对应的是 HTTP 请求体里 multipart 分段头部的Content-Type字段,而浏览器是根据文件扩展名去猜这个值的——.php文件会被猜成application/x-php或者text/plain,.jpg会被猜成image/jpeg。这个猜测过程发生在客户端,服务器只是读了一下请求头,没有任何独立验证。

也就是说,只要你能改请求,这个校验就等于不存在。改法有两种,看你的习惯:

方式一,抓包改包。开启抓包代理,浏览器流量走代理,选中probe.php上传,在代理里拦截请求,把那个分段的Content-Type: application/x-php手动改成image/jpeg,放行。服务器读到的是image/jpeg,校验通过,文件照落不误。

方式二,直接用 curl 构造请求。我个人更偏好这种方式,因为参数一目了然,还能写成脚本反复跑:

curl -s -b cookie.txt \ -F "uploaded=@probe.php;type=image/jpeg" \ -F "Upload=Upload" \ http://127.0.0.1:8080/vulnerabilities/upload/

-F后面的;type=image/jpeg就是在指定这个分段的 MIME 类型,服务器收到的就是你要的那个值。-b cookie.txt是因为 DVWA 需要登录态,你得先从浏览器里把 PHPSESSID 拿出来存进文件,或者用 curl 自己走一遍登录流程拿 cookie。

这里还有个小细节值得注意:Medium 同时限制了文件大小< 100000字节,也就是不到 100KB。你如果拿一张几 MB 的真实图片去做图片马,会直接被大小卡住。这个限制不是为了防攻击,主要是防止有人用超大文件把磁盘塞满,但它在实验里会给你制造一点小麻烦,心里有数就行。

2.3 High 级别:当校验对象换成文件本身

High 级别的代码逻辑明显认真了不少,大致是这样:

$uploaded_name = $_FILES['uploaded']['name']; $uploaded_ext = substr($uploaded_name, strrpos($uploaded_name, '.') + 1); $uploaded_size = $_FILES['uploaded']['size']; $uploaded_tmp = $_FILES['uploaded']['tmp_name']; if ((strtolower($uploaded_ext) == "jpg" || strtolower($uploaded_ext) == "jpeg" || strtolower($uploaded_ext) == "png") && ($uploaded_size < 100000) && getimagesize($uploaded_tmp)) { // 移动文件 }

这一段做了三件事:取最后一个点之后的部分当扩展名、转小写后和白名单比对、用getimagesize()检查临时文件是不是一张结构合法的图片。getimagesize()这个函数读的是文件头的魔数字节,不是扩展名,所以单纯改名字是骗不过去的,你得让文件真的以合法图片头开头。

绕过思路就集中在这里了。常见的探索方向有三条,我只讲原理,具体脚本你自己在本地环境里琢磨,复制粘贴跑通没有意义:

第一条,图片马 + 解析配置组合。构造一个前半段是合法 PNG 或 JPEG、后半段追加 PHP 代码的文件。getimagesize()读到合法图片头,校验通过,扩展名也确实是.png。但问题来了——.png文件默认不会被 PHP 解析器处理。所以你需要再找一个能让它被解析的条件,比如服务器上存在某个目录把.png也交给了 PHP 处理,或者存在文件包含漏洞能把这张图当代码引进来。单靠这个模块本身,这条链是断的,这也是 High 级别设计得比较讲究的地方:它没有彻底堵死,但把利用门槛抬到了需要"组合拳"的高度。

第二条,多扩展名与解析顺序。不同 Web 服务器对probe.php.png这种多扩展名文件的处理规则不一样,有的看最后一个点,有的从右往左找第一个认识的扩展名。DVWA 的 High 级别在这里取了最后一个点之后的部分做判断,所以.png结尾能过校验,但能不能执行取决于服务器配置。这条路在真实环境里存在,在 DVWA 默认配置下通常走不通,你要自己去查目标服务器的解析规则,这本身就是个很好的练习。

第三条,竞争条件。有些实现是"先落盘、再校验、校验不过就删除",中间存在一个极短的时间窗口。如果你能在文件被删掉之前访问到它,就能抢在删除之前完成执行。这条路在 DVWA 的 High 级别里是部分成立的,因为校验发生在移动之前,理论上窗口很小,但如果你的验证逻辑是"移动后马上删除",那就有得抢。这个思路的价值不在于能不能复现,而在于让你建立一个意识:安全检查的顺序和原子性同样重要,先落盘后检查永远是个坏设计。

提示:High 级别的实验做完就停,别去网上找现成的批量利用脚本往上面套。你要练的是"看懂校验逻辑然后推理出缺口",不是"攒一堆工具"。前者能带走,后者带不走。

2.4 Impossible 级别:它到底防住了什么

Impossible 的代码看着也不长,但每一行都在关键位置上:

$uploaded_ext = substr($uploaded_name, strrpos($uploaded_name, '.') + 1); if ((strtolower($uploaded_ext) == "jpg" || strtolower($uploaded_ext) == "jpeg" || strtolower($uploaded_ext) == "png") && ($uploaded_size < 100000) && getimagesize($uploaded_tmp)) { $target_path = DVWA_WEB_PAGE_TO_ROOT . "hacks/" . md5(uniqid()) . ".jpg"; move_uploaded_file($uploaded_tmp, $target_path); chmod($target_path, 0644); }

表面看它和 High 差不多,但有三处关键差异,每一处都在拆掉一条利用路径。

第一处是强制重命名。目标文件名变成了md5(uniqid()) . ".jpg",攻击者完全失去了对落地文件名的控制权。这一手直接把"猜路径访问"这条最省事的利用路径砍断了——你就算传上去了,也不知道文件叫什么,更没法构造一个能触发执行的访问请求。这是我认为整个模块里最有价值的一句话:防御上传漏洞最划算的一招,不是把校验写得多严密,而是让攻击者控制不了文件名。

第二处是扩展名被强制改成.jpg。哪怕你传的文件原本叫probe.php,落地之后也叫xxxx.jpg,服务器不会拿它当 PHP 处理。这一招配合重命名,基本上把"直接执行"这条路堵死了。

第三处是权限收紧到 0644。去掉了执行位,就算某种极端情况下这个文件被别的机制引入,也不会以可执行权限存在。这一招在很多真实项目里被忽略,但它是最便宜的一层兜底。

那 Impossible 就完全无解吗?也不能这么说。它仍然允许上传图片,那么"图片内容本身被解析"这条路径还在——比如某些图片处理库的解析漏洞、SVG 里的脚本在特定展示场景下被执行。这些就不属于本模块的范畴了,但你在做真实项目评估的时候必须想到:允许上传图片,等于允许上传一个复杂的二进制解析输入,解析器的安全性就成了你的安全性。

3. 完整实操:从环境到验证的闭环

3.1 部署阶段的关键参数与踩坑点

环境搭建这块我踩过的坑比实验本身还多,值得单独拎出来说。除了前面提到的端口绑定和 PHP 配置,还有几个参数需要你留意。

数据库连接是你第一个可能卡住的地方。DVWA 的配置文件里要填数据库主机、用户名、密码、库名,容器化部署时数据库通常和 Web 服务在同一个容器里或者用独立容器,主机名不能写localhost——在容器网络里localhost指的是容器自己,如果数据库是独立容器,你写localhost就是连自己,必然失败。要么用容器名做主机名,要么用编排工具给的服务别名。这个坑我见过太多人卡在上面,报错信息还特别模糊,就是一句"数据库连接失败"。

端口方面,如果你本机的 8080 已经被别的服务占了,容器起来但访问不通,先别怀疑镜像,docker ps看一眼端口映射状态,再用curl -I http://127.0.0.1:8080探一下。有时候是容器内部的 Apache 还没起来,等个十几秒再试。

登录之后第一件事是去/setup.php点初始化,这一步会重建数据库表并把数据恢复到默认状态。我第一次做实验的时候跳过了这步,直接去上传页面,页面能打开但功能全废,查了半天才发现是表没建。

最后是目录权限。hacks目录必须对 Web 服务进程可写,容器里通常是www-data用户。如果你挂载了宿主机目录做持久化,宿主机那边的属主和权限会覆盖容器里的设置,很容易出现"目录存在但写不进去"的情况。这个问题的典型现象是上传请求返回 200,页面上也有成功提示,但你进目录一看啥都没有——其实是move_uploaded_file失败了,只是错误被吞了。

3.2 上传请求的结构与关键字段

想把上传链路摸透,得先知道一个上传请求长什么样。用 multipart 编码的 POST 请求,body 长这样:

------WebKitFormBoundaryABC123 Content-Disposition: form-data; name="uploaded"; filename="probe.php" Content-Type: application/x-php <?php phpinfo(); ?> ------WebKitFormBoundaryABC123 Content-Disposition: form-data; name="Upload" Upload ------WebKitFormBoundaryABC123--

三个字段值得你反复看:name="uploaded"是表单字段名,必须和服务端$_FILES['uploaded']对上;filename是原始文件名,服务端拿它做扩展名判断和落地命名;Content-Type就是 Medium 级别校验的那个值。

理解了结构,你就能明白为什么改包能生效——服务端读到的所有"关于文件的信息",除了临时文件内容本身,全都来自这段文本。文本是可以随便改的。

用 curl 复现的时候,前面那个-F命令会帮你自动生成这个结构,;type=就是改Content-Type,;filename=还能改文件名,比如:

curl -s -b cookie.txt \ -F "uploaded=@probe.php;filename=probe.png;type=image/png" \ -F "Upload=Upload" \ http://127.0.0.1:8080/vulnerabilities/upload/

一行命令就把文件名和 MIME 都改了,比你手点抓包快得多,而且方便做对照实验——同一份内容,只改一个变量,看服务端反应有什么不同,这是排查问题最高效的方式。

3.3 验证载荷的写法与最小化原则

关于验证文件写什么,我前面提过要用最小化的方式,这里展开讲一下我的具体做法。

最基础的一版就是一行phpinfo(),作用是确认"这个目录里的文件会被 PHP 解析"。这个信息量其实很大:它同时证明了落地路径可访问、解析器正常工作、文件扩展名被正确识别。做实验的时候我建议你先传这一版,把基础链路打通,再去研究绕过。

如果想验证得再细一点,可以写一个只输出固定字符串的文件,比如输出当前时间戳。这种写法的好处是干扰小——phpinfo()输出的页面很长,容易被各种安全产品和浏览器插件拦,一个短字符串的响应反而更容易观察,也方便你在日志里定位。

我特别反对的一种做法是:在验证文件里写文件操作、命令执行这类功能。原因很直接,你根本不需要这些能力来完成实验,加了纯粹是给自己埋雷。而且这类代码一旦被安全软件扫到,你本地环境都可能被隔离,实验没做成先处理一堆误报。

还有一个细节:验证文件的扩展名。做 Low 级别的时候用.php是对的,但要意识到扩展到 Medium 和 High,扩展名本身就是被校验的对象,你需要根据当前关卡的规则去调整。这个调整过程就是实验的核心,别嫌麻烦。

3.4 结果验证与日志侧观察

上传成功只是第一步,验证"是否真的生效"才是闭环。我的验证动作分三层,由浅入深。

第一层是页面响应。DVWA 上传成功后会回显一个路径,你直接访问这个路径看结果。这一步最快,但也最容易被误导——有时候页面显示成功,实际文件根本没落盘,或者落盘了但路径拼错了。

第二层是文件系统确认。进容器用ls -l看hacks目录,确认文件真的存在,看属主、权限、大小。这一步能区分"上传失败"和"访问失败"两种完全不同的问题。我遇到过好几次页面回显成功但访问 404,进目录一看文件确实在,但是属主是 root、权限 600,Web 进程读不了,表现出来就是 403 或者 404。

第三层是日志。Apache 的 access log 和 error log 是排查神器,路径通常在/var/log/apache2/下。access log 能告诉你请求到底有没有打到服务器、返回码是什么;error log 能告诉你 PHP 层面出了什么问题,比如权限拒绝、文件找不到、解析失败。养成看日志的习惯,比在网上搜报错信息管用得多,因为日志里写的是你这台机器的真实情况。

4. 踩坑记录与排查清单

4.1 上传提示成功但访问不到

这是最高频的问题,成因至少有四种,得按顺序排查。

第一种是路径拼错。DVWA 回显的路径有时候是相对路径,你直接复制到地址栏可能少了一段。正确的做法是看页面给的完整链接,或者自己按"协议 + 主机 + 端口 + 目录 + 文件名"拼一遍。

第二种是文件名被服务端改了而你还在用原文件名访问。Impossible 级别会强制重命名,你传probe.php上去,落地叫3f2a...jpg,你按原名字访问当然 404。

第三种是目录不在 Web 根目录下,或者有额外的路由规则拦截。有些部署方式会把hacks目录放到 Web 根之外,那就完全没有直接访问的入口。

第四种是权限问题,前面提过了,文件在但读不了。判断方法是进容器ls -l看权限位,再看 Web 进程的运行用户,两边对一下就知道。

4.2 文件能访问但不执行,返回源码

这个现象说明请求打到了服务器,文件也读到了,但 PHP 解析器没接手。常见原因有这几个。

一是 PHP 模块根本没启用。容器里跑php -m看看有没有对应的模块加载,没有的话检查配置文件的引入。

二是文件扩展名不在解析器管辖范围内。比如你传的是.png,服务器只把.php交给解析器,那.png就是当静态文件返回。这时候返回源码反而是好事,说明文件确实在你手上,只是执行条件不满足。

三是 Web 服务器的转发规则。Nginx 需要显式配置把.php请求转给 PHP 进程,如果这个 location 规则没写或者写错了,请求会被当静态文件处理。Apache 相对省心一些,但模块没加载同样不行。

四是路径匹配。有些配置只对特定目录下的 PHP 做解析,你把文件传到别的目录就不生效。

排查顺序我一般是:先看响应头里的Content-Type和Server字段,判断是哪个服务在处理;再看文件扩展名和解析规则是否匹配;最后看日志里有没有解析相关的报错。三步基本能定位。

4.3 中文文件名、大文件与临时目录问题

中文文件名是个经典坑。上传请求里的文件名是 UTF-8 编码的,但服务端处理时如果没做编码统一,可能出现乱码或者截断,导致落地文件名和你预期的不一样。更麻烦的是,某些函数在处理多字节字符的扩展名截取时会出错,比如取最后一个点之后的字符串,如果文件名里有全角字符,截出来的结果可能完全不是你想的那个。做实验的时候建议先用纯英文文件名把链路跑通,再回头研究编码问题。

大文件问题前面提过,核心是三层限制要对齐:Web 服务器层(Nginx 的client_max_body_size)、PHP 层(post_max_size和upload_max_filesize)、应用层(代码里写死的字节数)。三层里任何一层卡住,表现都是上传失败,而且报错信息各不相同,得一层层对。

临时目录的问题比较隐蔽。PHP 接收上传时先把文件写到临时目录,再交给你的代码处理。如果临时目录不存在、没权限、或者磁盘满了,$_FILES里的tmp_name可能是空的,你的代码拿到空路径去move_uploaded_file,自然失败。这个问题的特征是没有任何明显报错,只能靠日志和phpinfo()里的upload_tmp_dir配置去确认。

4.4 常见问题速查表

现象最可能的原因快速验证方式
页面提示成功但目录里没文件目录不可写 / 临时目录异常看 error log,检查目录属主
文件在但访问 403权限位不对ls -l看权限,对比 Web 进程用户
文件在但访问 404路径拼错 / 文件名被改直接看目录里的真实文件名
访问返回源码解析器未接管该扩展名看响应头,检查解析配置
上传直接报错或页面白屏超过大小限制对比三层限制参数
中文名文件找不到编码不一致换成纯英文名对照测试
$_FILES为空POST 超限 / 表单字段名不匹配打印$_FILES看结构

5. 把靶场经验搬回真实项目

5.1 白名单和黑名单的工程取舍

做完这个模块,最该带走的一条结论是:上传校验用白名单,别用黑名单。这不是教条,是成本核算的结果。

黑名单的思路是"列出所有危险扩展名,逐个拒绝"。问题在于这个列表永远列不全——.php挡住了,.phtml呢?.php5、.pht、.phar呢?服务器换个配置,多出一个能被解析的扩展名,你的黑名单就漏了。而且黑名单的维护成本是持续的,每次环境变更都要重新审一遍。

白名单反过来,只允许明确需要的几种格式,其他一律拒绝。列表短、好维护、默认安全。代价是业务上要确认清楚到底需要哪些格式,以及拒绝之后给用户什么提示。这个代价是值得付的。

不过白名单也不是万能。你得保证比对逻辑本身没漏洞——大小写要统一处理,扩展名截取要取最后一个点之后的部分,别用字符串包含做判断(strpos找到.jpg就放行,那probe.jpg.php直接就过了)。这些细节在 DVWA 的 High 级别代码里其实演示得很清楚,你看它是怎么取扩展名的,照着学就对了。

5.2 存储与执行分离:最省钱的防御方案

如果只能给一条建议,我会说:把上传文件的存储目录和执行环境彻底分开。

具体怎么做?上传的文件落到一个独立的存储位置,这个位置不在 Web 根目录下,或者虽然在但通过 Web 服务器配置禁止执行任何脚本。用户要访问这些文件,走一个专门的下载接口,由后端读取文件再以流的方式返回,同时设置正确的Content-Type和Content-Disposition。

这一招的好处是:不管攻击者传上来什么内容,它都没有机会被解析执行。就算你的扩展名校验漏了,就算文件内容里藏着代码,它也只是躺在磁盘上的一堆字节,等着被当二进制流读出去。这是把"防不住"的风险降到最低的做法。

配合这一招,再加两件事就基本齐活了:强制重命名(用随机串,别用原始文件名,顺手解决了文件名注入和路径穿越)和目录不可执行(Web 服务器配置层面禁掉)。这三件事加起来,工作量不大,但能挡住绝大多数上传攻击。

5.3 图片处理链路上的隐性风险

真实业务里上传图片几乎是标配,而图片处理是条很长的链路,每一环都可能有坑。

第一环是格式识别。别信扩展名,也别全信Content-Type,读文件头魔数是最基本的。但要注意,魔数校验只能证明"开头像图片",不能证明"整个文件是合法图片"。

第二环是二次处理。很多项目会对上传的图片做压缩、裁剪、加水印,这个处理过程本身就是一次"重新编码",处理之后的产物是干净的图片,附加在原始文件里的任何多余内容都会被丢掉。这是最有效的一招,比任何校验都实在——你传什么进来,出去的都是重新生成的图片。

第三环是SVG 的特殊性。SVG 本质上是 XML,里面可以嵌脚本。如果你的业务允许上传 SVG,而且会在页面上直接渲染,那就要额外处理:要么禁止 SVG,要么在服务端做严格的白名单过滤,要么用独立的域名和Content-Security-Policy隔离渲染。这一条在 DVWA 里不会碰到,但真实项目里翻车的不少。

第四环是压缩包。允许上传 zip 的业务,解压时必须校验每个条目的路径,防止../穿越到上级目录覆盖文件。这个和上传本身是两个问题,但经常被放在一起做,值得单独测。

5.4 日志、监控与响应

最后说个容易被忽略的环节:你防不住的时候,能不能发现。

上传功能的日志至少要记录这几项:上传时间、来源 IP、原始文件名、文件大小、校验结果、落地路径。这些信息在事后追溯的时候价值极高。如果某个来源在短时间内反复上传被拒绝的文件,这就是一个明确的信号,值得告警。

另外,监控上传目录的文件数量增长和磁盘占用。有人拿上传功能做存储耗尽攻击,不传恶意文件,就是单纯传大文件把盘塞满,这种攻击不需要任何技术含量,但一样能让服务挂掉。

我自己的习惯是在上传接口上加一个简单的速率限制,同一来源单位时间内允许的上传次数有上限。这个措施成本很低,但能挡掉大部分自动化扫描和暴力尝试。

这个模块后续还可以往外延伸不少方向,比如结合文件包含漏洞做组合利用、研究不同 Web 服务器对多扩展名的解析差异、或者去啃一啃图片解析库的历史漏洞。我个人在做完这个实验之后最大的收获,不是学会了怎么传文件,而是养成了一个习惯:看到任何一个"用户提交内容"的功能,第一反应都是问那四个问题——能不能传、传到哪、叫什么名字、传完会怎样。这四个问题问顺了,很多漏洞你甚至不用动手测,看代码就能看出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询