头像上传接口,直接传 shell.php,返回「仅支持 jpg/png 格式」。Burp Repeater 里把 multipart 表单中自报的 MIME 从 application/octet-stream 改成 image/png,文件名不动:

1
2
3
4
5
------x
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/png

<?php eval($_REQUEST[1]);?>

返回上传成功,路径 /uploads/shell.php。浏览器访问,脚本直接执行。服务端校验的是 multipart 里自报的 MIME 类型,这个字段完全由客户端控制——只看它的校验等于没有校验。

同类的站还会拦后缀。把文件名改成 shell.jpg、内容换成 PHP 代码,后缀校验放行,存进去的仍是一段服务器可解析的代码——只要上传目录有执行权限,这就是一个 RCE。

文件上传的问题从来不止「传没传上去」:真正要问的是文件被存成了什么、会不会被当资源执行。OWASP Unrestricted File Upload 把这类问题的危害整理成完整清单:代码执行、客户端攻击(XSS)、文件系统或数据库过载、向后端转发攻击、页面篡改,评级时可以对照。

漏洞原理:存进去,还要被执行

漏洞成立需要两条同时满足:

  1. 恶意内容能落盘——类型、后缀、内容三层校验至少一层可绕过
  2. 落盘的文件会被解析执行——存储目录有执行权限、后缀被中间件绑定到脚本 handler,或站点存在文件包含点

第 1 条决定能不能传上去,第 2 条决定传上去有没有用。二者攻击面完全不同:前者攻应用代码的校验逻辑,后者攻部署配置与架构。不少测试卡在第一条打通后没有下文,通常就是第 2 条不成立——目录不给执行权限,或文件被转存到对象存储,这时要转向组合链而不是继续调 payload。

PHP 的上传处理集中在 $_FILES 超全局数组与 move_uploaded_file:前者接收文件名、MIME、大小、临时路径,后者把临时文件移动到指定位置。一个值得记的实现细节:目标文件已存在会被直接覆盖。

校验逻辑几乎都写在「移动到哪里」这一步,漏洞也集中在这——路径参数受 open_basedir 约束的只有目标一侧,源路径不受限,而路径末尾的特殊写法、数组形态的参数都会改变校验与落盘的对应关系,后面几节的多个手法正源于此。

服务端校验按实现层次分五类,测试与绕过都围绕这个分层展开:

层 校验内容 常见实现
前端 JS 后缀、大小 表单事件里正则匹配
HTTP 层 MIME 类型 读 $_FILES['type']
文件名层 后缀黑白名单 pathinfo 取后缀 + 数组比对
内容层 文件头、图像合法性 getimagesize、GD 重采样
架构层 存储与执行分离 目录禁执行、独立存储域名、OSS

测试流程:先定位校验在哪一层

黑盒测试四步,顺序不能反:

  1. 基线——传一个正常图片,记录成功响应、文件返回的 URL 与命名规律(是否保留原名、是否加随机前缀)
  2. 改后缀——同一请求把 filename 改为 shell.php 重放。数据包还没发出浏览器就提示类型错误,是前端校验;请求正常发出、响应里报类型错误,是服务端校验
  3. 改 MIME——后缀拦了就单独改 Content-Type 为 image/gif,定位校验是否只看 MIME
  4. 改内容——MIME 也过了就换文件头,逐层下探到内容校验

前端拦截永远可以绕——禁用 JS 或直接改包即过,它拦的是普通用户,不是测试者。判断它的价值只在于排除一层干扰,省下后面几次无效重放。

每一步都在缩小「校验在哪」的范围。定位之后剩下的事按层展开:文件名层攻校验实现,内容层攻图片处理链,架构层的坑落在配置里。

后缀与类型校验的绕过

绕过方向由校验实现决定,三条主线:换后缀、换写法、改路径归属。

黑名单类校验,攻覆盖面。 黑名单列不全、比较不严、替换不彻底都算覆盖面问题:

  • 大小写混写——shell.pHp,部分实现用不区分大小写的正则拦、却拿原串落盘
  • 替换只跑一次——删除关键字的代码只执行一轮替换,提交 pphphp,删完后恰好剩 php。测法是看落盘的文件名:提交 1.pphphp 存成 1.php 即命中
  • 名单漏项——PHP 除 .php 外还有 .php3、.php5、.phtml、.pht、.phar 等后缀,是否被解析取决于中间件映射:Debian 系发行版默认只认 .php、.phar、.phtml,其余需额外 AddHandler;IIS 6 默认把 .asa、.cer、.cdx 映射到 asp.dll,IIS 7 起默认只剩 .asp 与 .cer。测试时按目标环境的 handler 映射确认。PayloadsAllTheThings 的 Upload Insecure Files 按 PHP/ASP/JSP 分语言列了默认可执行后缀全集,测黑名单时对着它跑字典
  • 路径归一化——黑名单只判扩展名是否在列表里,保存名提交 shell.php/. 时 pathinfo 取到的扩展名为空、不在列表,放行;Windows 落盘时末尾的 /. 被归一化掉,文件写成 shell.php。同一原理的变体是末尾带点与末尾带空格的写法

白名单类校验,攻比较逻辑本身。 白名单封住了后缀面,突破口转向「拿什么参与比较、拿什么落盘」:

  • %00 截断——文件名里插一个真实的 0x00 字节再接 .jpg(multipart 的 filename 里打百分号形式的 %00 不会被解码成空字节,要用 Burp 的十六进制编辑插入)。比较发生在 PHP 层、二进制安全,取到 .jpg 过白名单;落盘发生在 C 层、遇空字节截断,写成 shell.php。老版本 PHP 的路径函数接受路径中的空字节:fopen、include 等的空字节检查自 PHP 5.3.4 引入(CVE-2006-7243),而 move_uploaded_file 的同类保护 2015 年才补齐(CVE-2015-2348,PHP 5.6.7 修复)——同一手法在不同函数上的存活版本区间并不一致,遇到老系统值得分开测
  • 数组参数——保存名参数提交成数组,比较与拼接用了不同的下标。upload-labs 最后一关的源码里,白名单用 end() 取最后一项校验,落盘名却用 reset($file) . '.' . $file[count($file) - 1] 拼接:

    1
    2
    save_name[0] = shell.php
    save_name[2] = gif

    end() 取到 gif 过白名单,落盘名却按数组最后一项的下标拼接,指向不存在的 [1]——拼出末尾带点的 shell.php.,Windows 归一化后落成 shell.php。校验和落盘用了两套取值逻辑,是这类问题的共同特征

配置文件当目标。 Apache 启用 AllowOverride 时,.htaccess 本身就是可上传目标——上传一行 AddType application/x-httpd-php .png,此后目录里所有 png 都按 PHP 执行,后缀校验从此形同虚设。IIS 的对应目标是 web.config,uWSGI 是 uwsgi.ini(其 @() 语法可直接执行命令)。这类手法不绕校验,直接改写解析规则。

Windows 系统特性补充

Windows 对文件名末尾字符的处理是多个绕过的根源:

  • move_uploaded_file 与 fopen 遇到 foo.php 后跟 \x2E(.)、\x2F(/)、\x5C(\)的组合,会归一化回 foo.php
  • include/require 容忍的是另一组尾部字符:\x20(空格)、\x22(”)、\x2E(.)、\x3C(<)、\x3E(>)——文件包含绕过后缀限制时用这组
  • 末尾空格与点在创建文件时被系统去除
  • NTFS 交替数据流:shell.php::$DATA 落盘为 shell.php 主流;file.asax:.jpg 先创建空文件,再借短文件名改写内容
  • PHP on IIS 保存文件时 > 与 < 会分别被转成 ? 与 *,web<< 可顶替 web.config

来源:PayloadsAllTheThings Upload Tricks,其中 IIS 字符转换出自 Soroush Dalili 的研究。

内容校验与执行条件

内容层校验看的是文件本体,绕过思路从「伪装成图片」到「真的当图片」递进。

文件头伪装。 getimagesize 一类函数只读头部字节,文件开头加上 GIF89a(或 JPEG 的 \xff\xd8\xff 起始字节),后面跟 PHP 代码,即通过校验——存成的文件是「带 PHP 代码的图片」,要执行还需解析或包含配合。

二次渲染对抗。 用 GD 库重采样后再存储(上传头像最常见),图片数据被重新编码,简单嵌在正文的代码会被抹掉。对抗方式是先上传正常图片、下载渲染后的成品、对比出未被重写的区域(如 PNG 的 PLTE 块、JPG 的注释段),把代码塞进保留区再传。构造这类文件用现成脚本:PayloadsAllTheThings 的 Picture Compression 目录里放着能在 JPEG 重压缩后存活的 payload 图生成器(createBulletproofJPG.py 等)。二次渲染的文件同样要靠文件包含执行。

图片马 + 包含链。 上面两条产出的图片马本身不执行,常见的执行通道是站点自身的文件包含点(include($_GET['file']))。上传与包含两个弱点单独看都是低危,组合成链就是 RCE——这也是上传漏洞评级时必须查包含点的原因。

条件竞争。 校验逻辑是「先落盘、再检查后缀、不合格就删除」时,落盘到删除之间存在窗口。上传侧用 Burp Intruder 不间断重放上传包,访问侧同时高频请求猜测的文件路径,任一次请求落在窗口内即写入稳定 webshell:

1
<?php fputs(fopen('x.php','w'),'<?php eval($_REQUEST[1]);?>');?>
1
2
上传线程:POST /upload  ×  N
访问线程:GET /uploads/<可预测路径> × M

文件名可预测是前提——保留原名或时间戳命名的场景才有竞争窗口,随机重命名直接封死这类利用。

文件名本身是输入。 后缀之外,原始文件名经常原样进数据库或页面:进 SQL 语句是注入点,回显在文件列表是存储型 XSS,拼进下载路径是路径穿越。测试时把文件名换成对应 payload,落点在上传记录页、日志页、后台审核页的尤其值得看。SVG 与 HTML 文件上传后能直链访问的,等于站内可执行 JS,利用面见XSS 漏洞详解——原理、类型、测试与绕过。

中间件与解析漏洞

应用代码校验无懈可击,中间件配置照样能送出执行权。三个经典场景:

中间件 触发配置 形态
Apache AddHandler application/x-httpd-php .php shell.php.abc 中段含 .php 即被绑定 PHP handler
Nginx + FPM cgi.fix_pathinfo=1 且未启用 FPM 限制 /shell.jpg/.php 回退解析成 shell.jpg 执行
IIS 6 默认 shell.asp;.jpg 分号截断;*.asp 目录内任意后缀按 ASP 执行

Apache 的多扩展名机制是第一种场景的根源。mod_mime 官方文档写明:多扩展名文件中最右侧扩展名决定 media type,但 handler 优先于 media type——配置用 Add* 指令绑定 PHP 时,文件名中段出现 .php 就足以绑定 handler。官方同时给出修复方向:用 <FilesMatch "\.php$"> 配 SetHandler,并明确「依赖这类合成类型的配置应当避免」。测试时传一个 shell.php.abc,访问后返回 200 且执行即命中。

Nginx 的场景在 FPM 侧。 Nginx 自身不解析 PHP,/shell.jpg/.php 这类路径被原样交给 FastCGI 后,PHP-FPM 在 cgi.fix_pathinfo=1(历史默认值)时把不存在的脚本回退解析成 shell.jpg 执行。官方在 FPM 池配置里提供了兜底项 security.limit_extensions:限制 FPM 允许解析的主脚本扩展名,当前默认 .php 与 .phar——这条链成立的前提是该值被显式置空(置空才允许全部扩展名),或运行在 5.3.9 之前还没有该指令的 FPM 上,默认配置下 /shell.jpg/.php 会被 FPM 拒绝。vulhub 上有这套配置的现成复现环境。

CMS 与第三方编辑器
  • 第三方编辑器——UEditor、KindEditor 等自带的上传接口独立于业务代码,UEditor .NET 版的远程抓图接口(controller.ashx)曾长期存在任意文件上传,只需要能访问 controller.ashx 且未做鉴权
  • 已知 CMS——通达 OA v11.x 的上传利用链(任意文件上传与文件包含组合)、各类建站系统的主题/插件上传点;vulhub 有 Apache/Nginx 解析漏洞与 PHP 包含环境的现成复现(httpd/apache_parsing_vulnerability、php/inclusion),通达 OA 这类 CMS 需自备安装包
  • 判别思路——页面引用了编辑器资源目录(/ueditor/、/kindeditor/)或指纹识别出 CMS,先查对应版本已公开的上传漏洞再手工测,效率高于黑盒

危害评估与边界

「传上去了」不等于 RCE。评估一个上传点,按三问定级:存到哪、能否直接访问、会不会被解析。

场景 危害 说明
直链可访问且后缀被解析 严重 直接 RCE
SVG/HTML 直链同源可访问 高 存储型 XSS,打后台价值最高
仅存储,目录无执行权限 低 文件是死的,需组合链利用
文件名进数据库或页面 视落点 SQL 注入、XSS,单独评级

架构层的存储方案直接掐断执行链:文件转存到独立域名(上传走 upload.、访问走 images.)或 OSS 对象存储后,上传目录下没有脚本执行的概念,第 2 条成立条件不存在。这类目标上传点的期望值应下调为「存储型利用」——XSS、文件名注入、或寻找下载/包含侧的二次漏洞。

条件竞争与后缀字典爆破在真实目标上的代价很直接:高频请求触发 WAF 告警、IP 封禁,上传行为在日志中全部留痕,后续测试同步受影响。竞争窗口类手法只在确认靶场或授权目标的场景使用。

测试写入的 webshell 与图片马是持久化痕迹,测试结束前全部删除并记录清单——它们留在服务器上既是审计问题,也可能被第三方扫描利用。

防御方案

第一层,白名单 + 服务端重命名。 后缀白名单比对放在服务端,比较与落盘用同一份值(数组绕过的教训);落盘名随机生成、扩展名由白名单映射,原始文件名只作展示用——这一条同时封掉条件竞争、路径归一化与文件名注入。

第二层,存储与执行分离。 上传目录整体不接 PHP 解析——前缀匹配 ^~ 优先于 \.php$ 这类正则,PHP 请求根本到不了 FPM,枚举后缀写 deny 反而容易漏(.phar、.pht):

1
2
3
location ^~ /uploads/ {
try_files $uri =404;
}

PHP-FPM 侧保持 security.limit_extensions 默认值不动,Apache 侧用 <FilesMatch "\.php$"> 配 SetHandler 替代 Add* 指令,并关闭上传目录的 AllowOverride。条件允许直接把文件转存 OSS 或独立存储域名,执行链从架构上不存在。

第三层,内容与架构校验。 白名单之外再过一遍文件头与图像合法性(注意 getimagesize 可被文件头伪装绕过,只作辅助);图像处理管道及时升级——ImageMagick 的历史漏洞(ImageTragick)证明处理组件本身也是攻击面。

完整的工程清单见 OWASP Unrestricted File Upload 的防护建议与 PortSwigger 的文件上传专题(覆盖校验缺陷、路径穿越、服务端配置缺陷三类场景,附实验室)。

测试速查表

场景 先看什么 优先尝试
只拦后缀 黑名单还是白名单 phtml/php3、大小写、双写、/. 归一化
拦 MIME 是否只读 Content-Type 改包 image/gif
拦文件头 只查头还是重采样 GIF89a 图片马;重采样用保留区构造
白名单严格 保存路径是否可控 数组参数、老版本 %00
传上去不执行 目录权限、解析配置 中间件解析、文件包含链
先存后删 文件名是否可预测 条件竞争(仅授权目标)
名单/环境未知 是否部署在 Nginx/Apache/IIS 对应中间件解析特征探测

定位校验层占测试工作量的大头,绕过手法是定位之后的执行问题;防御侧真正起作用的是白名单重命名与存储执行分离,内容校验只作辅助。