这一课是我这期黑客笔记里比较靠后的一课了,第33课,主题是“不安全文件上传(Client)”。很多人一听到文件上传漏洞,脑子里蹦出来的都是服务端过滤怎么绕、后缀怎么改、解析漏洞怎么打——但这一课特意把 Client 写在标题里,就是想先补上一个概念:在你遇到的上传点里,有一大类“看着很严、实际一戳就破”的情况,问题根本不复杂,只是校验逻辑只写在了浏览器这一侧。
我见过不少刚入行的朋友,拿到一个上传点就急着上大payload,结果连最简单的前端限制都没绕干净,还一脸懵地问我为什么抓包改了Extension还是失败。这课说白了就是解决两件事:第一,让你一眼认出哪些校验是 Client 侧的“纸老虎”;第二,给你一套无论前端口味怎么变都能直接套用的绕过和验证流程。做渗透测试的、写业务后端的、搞CTF Web方向的,都能从中找到自己能用的东西。下面直接进入正题。
1. 课程定位:为什么单把“Client”拎出来讲
1.1 上传校验的两层世界:前端JS与后端程序
任何文件上传功能,本质上都有两道“安检门”。第一道门在浏览器里,由 HTML、JavaScript 负责,它拦的是普通用户不小心传错文件,属于用户体验优化;第二道门在服务器上,由 PHP、Java、Python、Go 这类后端代码负责,它才真正决定哪些文件能落到磁盘上、能不能被当作代码执行。
用个经常说的类比:前端校验像小区门口的保安,你进门时他抬头看你一眼,眼熟就挥手放行;后端校验才是物业系统,进门之后刷没刷卡、你是住客还是临时工、有没有权限进机房,都是后面这套系统说了算。问题在于,小区保安是可以“收买”的——而前端的 JavaScript 本身就是公开交付给用户的代码,用户随时可以改、可以停、可以绕过。前端校验做得再花哨,只要后端没做校验,就等于保安一眼都没看就放人进了机房。
我一直强调,看到上传功能先别急着分析后缀和 Content-Type,先分清你面对的校验到底在哪一层。判断标准很简单:请求是浏览器发出的,响应是服务器返回的。前端校验只是浏览器在你提交前自己执行的逻辑,它根本不参与网络请求,所以任何仅存在于前端的限制,对于改包者来说都是透明的。这一点是这一课理解上的关键前提,后面所有绕过动作都是围绕它展开的。
1.2 一个“不安全文件上传(Client)”的完整画像
这类漏洞的典型画像是什么样的?我把它归纳成三个特征。
第一个特征:页面上有明显的类型限制提示,比如“只支持 JPG/PNG/GIF 格式”,但抓包之后你会发现,请求里除了文件名和 Content-Type 之外,没有任何额外的安全校验字段,服务器就默默把文件收下了。第二个特征:你用浏览器正常上传一张图片能成功,但把图片内容换成 PHP/Python/JSP 脚本、只把文件名改成 .jpg 再抓包放行,服务器依然能收下,甚至还能直接访问到。第三个特征:响应包或者 UI 提示里出现“上传成功”,但页面没有提示任何文件内容、文件头、服务器端重命名之类的结果——说明后端基本处于“裸奔”状态。
危害就不用我多说了。一旦攻击者能把可控内容的文件写进服务器,就等于往你家门缝里塞了一个能自己挪动的东西:它可能是个可执行脚本,可能是个钓鱼页面,也可能是伪装成正常资源的恶意文件。如果这个上传点所在的服务还能解析脚本,那后果就彻底升级了,从“能传文件”变成“能执行命令”,后续就是大家熟悉的提权、横向、留后门一条龙。
有一点这里必须说清楚:以上所有讨论,包括后面的实操,你都只能在自己的靶机、授权的测试环境或者漏洞赏金项目中去做。拿不授权的网站练手,那是给自己挖坑,不是搞安全该有的样子。心里这条红线立住了,再往下看。
2. 客户端校验的常见形态与识别方法
2.1 三类最常见的客户端校验
前端校验虽然写法千奇百怪,但翻来覆去不外乎三类。把这三类认熟,你在判断一个上传点是不是“纸老虎”时,效率会高很多。
第一类是扩展名白名单/黑名单校验。页面上通常会写“仅支持图片”,JS 里则用正则或者字符串截取去判断文件名后缀。这类代码我在真实业务里见得太多了,典型写法长这样:
function checkFile(file) { const ext = file.name.split('.').pop().toLowerCase(); const allowList = ['jpg', 'jpeg', 'png', 'gif']; if (!allowList.includes(ext)) { alert('只允许上传图片格式'); return false; } return true; }第二类是 MIME 类型校验。它检查的是文件对象的 type 属性,比如 image/jpeg、image/png。这里有个很容易让人误判的细节:浏览器里的 file.type 值来源于操作系统对这个文件的识别,并不完全等于文件真实内容。把一个 .txt 改名成 .jpg,大多数浏览器照样会把它识别成 image/jpeg,因为很多系统只根据扩展名推断 MIME。所以这类校验比第一类还要虚。
第三类是文件大小与数量校验。限制只能传一张、不能超过 2MB 之类的,一般用 File.size 或 Blob 属性判断。这类限制主要是为性能和业务服务,跟安全边界几乎不搭边,但我在测试中见过把大小校验和类型校验写在一起的情况,所以也归进来。
我把它们整理成一张表,方便对照:
| 校验类型 | 典型实现 | 抓包时观察点 | 实际安全性 |
|---|---|---|---|
| 扩展名校验 | JS 正则、accept 属性 | 文件名后缀 | 完全可控 |
| MIME校验 | file.type 判断 | Content-Type 字段 | 完全可控 |
| 大小/数量校验 | File.size、文件数量判断 | 请求体大小、文件个数 | 仅影响正常用户 |
| 混合校验 | 以上两两组合 | 多个字段都可能是假的 | 只要服务端不校验,全等于0 |
2.2 怎么快速判断当前上传点是否只做了Client校验
判断一个上传点是否只依赖前端校验,不需要什么高深工具,浏览器加一个代理就够。我平时按下面这个顺序来,速度很快。
先从最简单的看起:在页面上右键查看源代码,或者用开发者工具直接看上传表单关联的 JS,找找有没有 checkFile、checkType、beforeSubmit 这类函数。找到之后跟着代码走一遍,看它是不是只做了上面说的那几类检查,有没有发起任何额外的校验接口。如果前端 JS 里拦完就直接让表单提交了,那就说明这个上传点大概率没走服务端校验流程,得重点测试。
再看服务器到底校验了什么,最快的办法是“作弊”:正常上传一个 .jpg 图片成功后,再传一个把扩展名改成 .jpg 的文本文件,也用正常的表单方式提交,看服务器接不接受。如果服务器接受了,说明后端要么没有校验,要么只校验了扩展名。如果服务器拒绝,就再结合返回包判断,看它报的是“格式不支持”还是“内容不是图片”。
还有一个我常用的终极大法:直接禁用 JavaScript 再上传。绝大多数浏览器都有禁用 JS 的入口,禁用后刷新页面,如果上传功能照样能弹出来、照样能提交成功,那你面前这个上传点,前端校验就等于不复存在了。有些人会问,这么做会不会影响页面本身?确实会影响一部分动态页面,但对绝大多数上传功能来说,禁 JS 之后只是少了弹窗提示,提交动作本身不受影响,这恰好证明了前端限制不是生效的边界。
判断这步走完,基本就能给上传点定性了:是“裸奔型”还是“穿了铠甲”。但很多人的误区是,觉得判断完就可以直接上payload了。别急,我们先把绕过前面这层“纸窗户”的手法练扎实。
3. 绕过前端限制的三板斧实操
3.1 在本地搭一个练习靶场
实操之前先准备环境,我推荐用 Docker 直接拉起一个 upload-labs 或者 DVWA,几分钟就完事,而且能随便折腾。upload-labs 是老牌的靶场库,每一关都是独立的上传校验场景,非常适合练前面说的判断手法。
# 拉取并启动 upload-labs docker run -d --name upload-labs -p 8080:80 c0ny1/upload-labs跑起来之后,浏览器访问 http://127.0.0.1:8080/,选一个关卡进去,就能看到带前端校验的上传表单。DVWA 也可以,在 Docker 里搜 dvwa 官方镜像就能拉起来,它的文件上传模块 Low 级别就是一个非常典型的只做客户端校验的例子。
环境这东西,我建议每个人都在本地备一套。测试手法这东西光看永远学不会,很多细节只有亲手操作才会形成肌肉记忆。而且本地靶场可以让你放心大胆地去试,试错了也不怕,这对建立“请求包视角”很重要。
3.2 第一斧:直接禁用JavaScript
这是最原始但很有效的一招。在浏览器设置里把 JavaScript 关闭,刷新上传页面,你会发现原本“只支持图片”的限制提示不再弹出,使用文件选择器选中一个 .php 文件,上传表单照样能提交。
为什么有效?因为前端校验这段 JS 根本就没执行。禁用了 JS,等于把小区门口的保安调走了,你直接大摇大摆走到物业前台,如果物业前台不问你问题,你就能进去。在靶场上这一招的基本玩法是:先正常上传一张图片看响应,再禁用 JS 传一个改了后缀的文件,对比两次响应。
但现实中这招效率不算高,因为很多现代前端框架的交互逻辑强依赖 JS,禁用 JS 后页面可能直接白屏或报错,而且如果目标环境里有服务端校验,禁用 JS 并不能替你绕过它。所以这一斧更多是验证性质,告诉你“这个上传点在前端这一侧没有任何真防线”,真正的重头戏在后面两斧。
3.3 第二斧:从开发者工具里改JS逻辑
既然前端 JS 是在浏览器里执行的,那直接让这段校验逻辑失效也是一种路线。打开开发者工具,找到上传按钮绑定的事件监听器,把调用校验函数的地方注释掉,或者把校验函数的返回值强行改成 true,再触发上传,结果和禁用 JS 相似。
以我们前面贴的那段 JS 为例,你甚至不用改代码,直接在 Console 里重新定义这个函数:
function checkFile(file) { return true; }后面再上传文件时,浏览器调用的就是被覆盖后的函数。这个技巧在测试老系统时偶尔会省事,因为它不用动整个页面的 JS 开关。
但说句实在话,这一招在安全测试里更多是教学意义。因为浏览器前端代码怎么改,都是你自己这一侧的修改,最终还是得靠发出的 HTTP 请求说话。改 JS 本质上是“让前端放行”,但真正重要的是“让服务器收下”。明白这一点,你就自然能理解为什么下一斧才是核心。
3.4 第三斧:Burp抓包直接改请求,彻底绕开前端
核心来了。不管前端校验写了多少行,最终上传动作都会变成一次 HTTP 请求。Burp Suite 这类代理工具能让你在请求到达服务器之前拦截下来,把里面的字段改到你想要的样子。这是绕过所有客户端校验的根本手段,因为它绕开的不是某一层规则,而是整个“客户端”这个角色。
具体操作流程,我拆成步骤写清楚。
第一步,配置好 Burp 的代理监听,浏览器的代理指向 127.0.0.1:8080,确保 Burp 能抓到目标上传页面的流量。
第二步,在靶场上正常选择一个图片文件,比如新建一个 text.txt 文件改成 test.jpg,先不过分追求内容,目的是让请求产生。点击上传,Burp 里立刻就能拦到一个 multipart/form-data 类型的 POST 请求。
第三步,找到请求体里的关键字段。典型的上传请求长这样:
POST /upload.php HTTP/1.1 Host: 127.0.0.1:8080 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="file"; filename="test.jpg" Content-Type: image/jpeg [文件内容] ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="submit" upload ------WebKitFormBoundary7MA4YWxkTrZu0gW--第四步,修改 filename 字段,把 test.jpg 改成 test.php。同时把相邻的 Content-Type 改成 image/jpeg 或 application/octet-stream——这一步很有讲究,因为有些后端会检查 MIME,你先伪装成图片,等服务器把文件收进去再看它是否按图片处理。
第五步,点击 Forward 放行,回到浏览器看响应。如果响应显示上传成功,再直接访问上传目录下对应路径,比如 http://127.0.0.1:8080/uploads/test.php,看服务器是否真的把文件按 PHP 解析了。
说句很多人会惊讶的事实:相当一部分老系统,在这一步就已经失守了。因为它们把前端那几行 JS 当成了全部安全措施,后端什么都没做。这也是为什么我一直强调,拿到上传点第一反映应该是“抓包看看”,而不是“猜它怎么过滤的”。
3.5 后端没有任何校验时会发生什么
如果我们抓包改完文件,后端直接收下并返回了路径,说明这个上传点已经踩过了“收文件”这一关。为了确认它是否还能被当成代码执行,我们需要做一个无害验证,不要一上来就上保真木马,那样既危险又没有必要。安全测试里常用的做法是上传一个只输出指定内容的测试脚本,比如:
<?php echo "upload-ok"; ?>扩展名改成 .php,内容就这一行,没有任何有害逻辑。上传成功之后访问上传地址,如果页面输出 upload-ok,说明服务器真的把 PHP 当成代码执行了——到这一步,漏洞的影响级别就非常清晰了,攻击者后续可以做的,就是往同一条链路上放置任何他想要的脚本文件。
这里必须强调:这个测试文件本身是干净的,它不会帮你做任何越权行为,只是帮你验证“可执行”这个结论。从这往后,如果你继续深入,那已经超出了本课作为“不安全文件上传(Client)”这个主题的防御和识别范畴,而且非常容易走偏。我的建议是:验证到这里,你已经能给漏洞定性:上传点无服务端校验、可上传任意类型文件、可能被当作脚本执行。这个结论足以驱动后续修复了。
4. 修复与自检:从源头上堵住这个漏洞
4.1 服务端必须接管完整的校验职责
既然客户端校验可以被绕过,那么唯一可靠的防线只能是服务端。服务端要做的事情,描述起来就一句话:不信任任何浏览器传上来的内容。这句话执行起来,需要分成几步。
第一步是扩展名白名单。注意是白名单不是黑名单,黑名单永远列不全。常见图片扩展名就那几种,jpg、jpeg、png、gif、webp,用数组固定住即可。第二步是文件内容和 MIME 类型校验。用服务端函数读取文件的真实类型,不要信请求头里那个 Content-Type。第三步是限制文件大小。前端限制不管用,后端在接收文件时同样要卡一道大小上限。第四步是对文件内容做深度检查,比如用图片库尝试重新解码,能正常解码才认为是合法图片。
一个能体现这几步思路的 PHP 示例,大概长这样:
$file = $_FILES['file'] ?? null; if (!$file) { die('未收到文件'); } // 检查是否上传出错 if ($file['error'] !== UPLOAD_ERR_OK) { die('上传失败'); } // 1. 用真实文件头判断 MIME,而不是请求里的 Content-Type $finfo = finfo_open(FILEINFO_MIME_TYPE); $realMime = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); $extMap = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', ]; if (!isset($extMap[$realMime])) { die('不允许的文件类型'); } // 2. 校验扩展名是否匹配 $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); if ($extMap[$realMime] !== $ext) { die('扩展名与文件内容不一致'); } // 3. 限制大小:200KB以内 if ($file['size'] > 204800) { die('文件过大'); } // 4. 重命名,避免用户控制文件名 $newName = date('YmdHis') . '_' . bin2hex(random_bytes(8)) . '.' . $ext; if (!move_uploaded_file($file['tmp_name'], __DIR__ . '/uploads/' . $newName)) { die('保存失败'); } echo '上传成功: ' . htmlspecialchars($newName);这段代码不是唯一标准答案,但四个关键点都覆盖了:真实 MIME、扩展名白名单、大小限制、不可预测的文件名。尤其是最后一点,很多开发者会忘。只要文件名由服务器随机生成,攻击者就很难提前猜出上传后的地址,这能显著降低上传成果被利用的概率。
4.2 存储与执行分离,别让上传点变成运行点
代码层面校验做足了,还有一个容易被忽视的隐患:上传目录可执行脚本。很多系统把上传目录放在 Web 根目录里,后缀校验又刚好被绕过,那文件就能直接通过 URL 访问并执行。修复思路是存储和解析彻底隔离。
具体做法有三类。一类是把上传目录配置成禁止执行脚本。以 Nginx 为例,如果你把上传目录放在 /uploads 下,可以在配置里显式禁掉该目录下的脚本解析:
location ^~ /uploads/ { location ~ \.php$ { deny all; } }Apache 的话,可以用 .htaccess 或者在虚拟主机配置里加 FilesMatch 规则:
<FilesMatch "\.(?i:php|php5|phtml)$"> Require all denied </FilesMatch>另一类是直接把上传目录放到 Web 根目录之外,文件不通过 URL 直接访问,而是由后端程序按需读取并输出到页面。这样可以彻底杜绝脚本执行,因为请求根本不会经过文件落盘目录。还有一类是额外的纵深防御:对上传的图片做二次渲染。用 PHP 的 GD 库或 Java 的 ImageIO 把图片重新解码再编码,原本藏在图片里的代码往往在这个过程中被破坏掉,无法再正常解析执行。
我个人做项目复盘时,见过太多“校验全做了,但忘了改上传目录权限”的例子。尤其是 Windows + IIS 环境,解析配置和历史遗留目录的问题更容易炸。所以,上传目录的执行权限,必须当作单独一项写进自检清单。
4.3 开发与测试的最终自检清单
不管你是开发自测,还是安全测试复测,下面这张表基本可以作为文件上传点的验收标准。我把它贴出来,直接照着打勾就行。
| 自检项 | 测试方法 | 通过标准 |
|---|---|---|
| 服务端扩展名校验 | 抓包把 filename 改成 .php/.jsp/.asp | 请求被拒绝或文件不落盘 |
| 服务端内容校验 | 上传一个改名 .jpg 的文本文件 | 请求被拒绝 |
| 真实文件头校验 | 上传带图片头+脚本内容的文件 | 请求被拒绝(或重编码后失效) |
| 文件大小限制 | 上传超大文件 | 请求被拒绝 |
| 文件名随机化 | 连续上传两次,观察文件名 | 文件名不可预测 |
| 上传目录禁止执行脚本 | 成功上传后访问 .php 路径 | 不解析代码或返回403 |
| 存储目录隔离 | 检查文件落盘路径 | 目录不可被 URL 直接访问 |
这张表看着简单,但每一条都能对应到现实漏洞案例。我在实际工作中发现,很多团队做完前三项就认为“上传安全了”,结果一测,目录解析漏洞还开着,前面等于白做。所以自检一定要按整条链路来,不能只看单一环节。
5. 踩坑记录与排障技巧
5.1 改了filename还是失败,不要只怀疑前端
很多人在抓包时改了 filename 为 test.php,放行后发现响应报错,第一反应是“我这改法不对”。其实这时候要冷静下来,报错的原因可能有好几种:第一种是服务端有白名单,改了后缀直接被拒绝;第二种是服务端检查了文件真实内容,改了后缀但文件内容还是图片,所以被拦;第三种是服务端在你上传后做了重命名,你就算传 .php 进去,出来也可能变成一串随机字符串加 .jpg。
正确做法是先看响应包的内容和状态码。如果返回 200 且提示“上传成功”,那说明服务端收下了,只是可能重命名了;如果返回 200 但提示“格式不支持”,那基本可以判定后端有白名单校验,可以再去试大小写、双扩展名。如果返回 500,大概率是文件内容触发了服务端的异常处理或者安全组件。一层层排查,比反复乱改文件名要快得多。
5.2 Content-Type改成了image/jpeg还报错
这又是一个经典误区。很多人以为只要把请求里的 Content-Type 改成 image/jpeg,后端就会认为这是图片。对一部分没经验的后端程序来说确实如此,但现在的安全组件很多会用文件内容识别真实类型,最常用的就是 magic bytes,也就是文件开头的几个十六进制字节。
比如 JPEG 文件开头通常是 FF D8 FF,PNG 是 89 50 4E 47,GIF 是 47 49 46 38。如果后端用这类信息校验,你光改请求头是没用的,因为你上传的文件内容第一个字节还是 3C(对应 <),一看就不是图片。
这种情况下,如果你确定要验证这个上传点是否存在更深的漏洞,常见的思路是把脚本代码拼到一张合法图片后面,比如在图片末尾追加一段 PHP 代码,再上传。但这属于更复杂的绕过场景,而且需要环境确实存在包含漏洞才能利用。在入门阶段,你至少应该知道:请求头里的 Content-Type 是“自报家门”,服务器信不信,是服务器的事。所以遇到报错,先看返回信息,再看服务器日志,不要上来就怀疑自己改包工具坏了。
5.3 靶场与生产环境的差异
很多人用靶场练完手,换到实际授权测试环境时会有落差,最常见的就是:靶场里改个后缀就成功了,生产环境上传后访问总是 404 或者被拦截。原因通常是生产环境多了 CDN、WAF、云锁、主机安全 Agent 之类的防护组件,它们可能在多个层面对上传做检测,包括文件名、文件内容、请求频率、UA 指纹等。
遇到这类情况,先把测试收敛到“验证防护是否存在”这一步:正常上传一张图片看是否成功,再上传一个无害的探测文件看是否被拦截。如果探针文件被拦,不要立刻怀疑自己手法不对,而是要考虑是不是安全产品生效了,这本身也是一个有效结论。另外 CDN 缓存会造成“上传成功但访问 404”的假象,遇到时先绕开 CDN,直接解析回源地址测试,或者等待缓存过期再确认。
5.4 一个排障顺序的速查表
测试过程中容易一头扎进细节,忘了全局排障。我总结了一个通用顺序,每次遇到上传点异常就按这个走,基本不会卡太久:
| 现象 | 优先怀疑 | 下一步动作 |
|---|---|---|
| 前端弹窗拦截 | 只做了客户端校验 | 直接抓包,检查请求是否发出 |
| 请求发出但提示格式错 | 服务端白名单 | 看响应内容,试大小写/双扩展名 |
| 请求发出且提示成功,但访问404 | 文件名被服务端重命名 | 查看响应中的路径字段和实际落盘目录 |
| 请求发出且提示成功,访问不解析 | 上传目录禁止脚本执行 | 确认响应头、目录权限、解析配置 |
| 上传成功但请求明显变慢 | 有安全软件在扫描文件内容 | 查看后端日志和杀软日志确认 |
这张表不是万能的,但它能帮你快速定位 80% 的卡壳场景。剩下的 20%,大概率出在业务逻辑的特殊分支,比如某些接口只接收 base64,某些平台会把上传文件同步到第三方存储,这些需要针对具体业务单独分析。
最后分享一个我自己的小习惯:无论页面上提示我上传什么类型、前端校验做得多么花哨,我第一件事永远是打开代理工具抓包看实际请求。因为页面给你的所有提示,都是 Client 在跟你聊天,而真正决定你能不能拿到结果的,是 Server 收到请求之后做了什么。牢记这一点,你在文件上传这条路上踩的坑,至少能少一半。