前置知识: 网络安全

文件上传安全

12 min中级

文件上传漏洞与安全上传实现:客户端校验绕过、MIME 与扩展名黑名单、双扩展与截断、解析漏洞、竞态上传,逐项给出白名单校验、随机重命名、存储与执行隔离、病毒扫描的安全实现(Python/Java/Nginx 配置),附头像接口 webshell 复现与修复。

文件上传安全

知识点地图

  • 知识类别:Web 漏洞专项——文件上传(file upload)这一独立漏洞类别的攻与防。OWASP Top 10 里它通常归入 A05 安全配置错误与 A03 注入的复合体:上传本身是正常功能,错的是「传上去的东西被当成代码执行」。
  • 解决什么问题:几乎所有现代应用都有上传(头像、附件、导入 Excel),而上传是「用户数据进入服务器文件系统」的直接通道——校验不严时,一个 shell.php 顶得上全套渗透工具。本篇把绕过手段(客户端校验、MIME、双扩展、截断、解析漏洞、竞态)与防御手段(白名单、随机重命名、存储隔离、病毒扫描)逐项配对讲清。
  • 什么时候用到:
    • 实现任何带上传的接口(头像、附件、素材库)时的安全设计评审;
    • 渗透测试中遇到上传点,按绕过清单逐项尝试(授权靶场/项目内);
    • 事后审计:一次 webshell 事件的上传入口溯源与修复验证。
  • 与相邻篇目的分工:Web 漏洞全景在 Web 安全导览 与 OWASP Top 10 详解;上传后若能执行命令,其原理与防御在 命令注入;输入校验的通用原则在 输入验证。本篇只管「文件进来」这一段。

1. 攻击现场:头像接口被传 webshell

一个只做了「前端限制只能选图片」的头像接口,攻击者的完整路径:

text
1. 攻击者把浏览器代理指向 Burp(拦截请求,见 [Burp Suite CLI](/cybersecurity/440-BurpSuiteCLI))
2. 前端选择一张正常图片,点击上传
3. 在 Burp 里把文件名 avatar.jpg 改为 avatar.php,正文替换为:
   <?php echo shell_exec($_GET['cmd']); ?>
4. 响应返回 200,{"filename": "avatar.php"}
5. 访问 /uploads/avatar.php?cmd=id
   返回 uid=33(www-data) —— 服务器执行了攻击者的命令

五个环节里只要服务器端有一处真正生效的防线,攻击就断:服务端校验扩展名(第 3 步被拒)、或上传目录不可执行(第 5 步拿不到回显)、或文件名随机化到猜不出路径(第 5 步找不到入口)。下面先看攻击者会绕哪些校验,再给逐项防御。

2. 常见绕过方式全景

绕过方式方法针对的错误防线
后缀名绕过.php5、.phtml、.php.jpg只拦 .php 精确匹配的黑名单
MIME 类型绕过修改 Content-Type: image/jpeg只信请求头声明的类型
大小写绕过.PhP、.pHp黑名单只匹配小写
双写绕过.pphphp(过滤 php 后剩余 php)黑名单「删除」而非「拒绝」
%00 截断shell.php%00.jpgC 语言字符串处理的老漏洞(新版运行时已基本免疫,旧系统仍需排查)
.htaccess 上传自定义解析规则允许上传 .htaccess 且 Apache 启用 AllowOverride

逐项拆解两道最有代表性的:

  • 双写绕过:服务器「把 php 从文件名里删掉」来净化——shell.pphphp 删掉中间的 php 剩下 shell.php。教训是净化(sanitize)不如拒绝(reject):检测到危险特征直接拒绝,永远不要试图「修理」用户输入;
  • .htaccess 上传:Apache 目录级配置文件本身不是脚本,黑名单自然没有它——但它能把 .jpg 重定义为 PHP 解析。防御提醒:配置类文件(.htaccess、.user.ini、web.config)必须出现在上传黑名单/白名单之外,这是最容易漏的一项。

3. 防御一:服务端白名单校验

黑名单永远追不完新后缀(php3/php5/phtml/phar/…),防御的正解是白名单——只允许明确的业务必需类型:

python
import os
import uuid
from pathlib import Path
from flask import request, jsonify

ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'pdf'}
MAX_FILE_SIZE = 5 * 1024 * 1024  # 5MB

def allowed_file(filename):
    ext = filename.rsplit('.', 1)[-1].lower()
    return ext in ALLOWED_EXTENSIONS

@app.route('/upload', methods=['POST'])
def upload_file():
    if 'file' not in request.files:
        return jsonify({'error': 'No file'}), 400

    file = request.files['file']

    # 1. 检查文件扩展名(白名单,不是黑名单)
    if not allowed_file(file.filename):
        return jsonify({'error': 'File type not allowed'}), 400

    # 2. 检查文件大小
    file.seek(0, os.SEEK_END)
    size = file.tell()
    file.seek(0)
    if size > MAX_FILE_SIZE:
        return jsonify({'error': 'File too large'}), 400

    # 3. 重命名文件(防止路径穿越与覆盖)
    ext = file.filename.rsplit('.', 1)[-1].lower()
    safe_name = f"{uuid.uuid4().hex}.{ext}"

    # 4. 保存到 Web 根目录外
    upload_dir = '/data/uploads'
    file.save(os.path.join(upload_dir, safe_name))

    return jsonify({'filename': safe_name}), 200

四步逐条讲:

  1. 白名单在前:ALLOWED_EXTENSIONS 列出「确定无害」的类型,不在名单一律拒绝——对比黑名单「列出已知有害」,白名单的失败模式(新类型被拒)只是功能限制,黑名单的失败模式(漏一种后缀)是服务器沦陷;
  2. 大小限制独立于类型:即使校验被绕过,5MB 上限也限制了攻击面(超大文件耗尽磁盘、解压炸弹);seek/tell 计数要 seek(0) 复位,否则后续 save 写出空文件;
  3. 随机重命名:UUID 文件名同时干掉三件事——路径穿越(../../ 被丢弃)、同名覆盖、猜路径执行;扩展名只保留白名单里的小写形式;
  4. 存到 Web 根之外:/data/uploads 不在 nginx 的静态目录里,用户不能直接用 URL 访问——要展示时经应用层鉴权后读出。这是第 5 节「存储与执行隔离」的一半,另一半在服务器配置。

4. 防御二:内容级校验(不信声明,信内容)

扩展名与 MIME 都是「声明」,攻击者全能改。真正的类型要看文件内容:

python
# 用魔数(magic number)判断真实类型,python-magic 库封装 libmagic
import magic

def real_mime(path):
    return magic.from_file(path, mime=True)

# png 的魔数是 89 50 4E 47(\x89PNG);把 shell.php 改名 shell.png,
# 魔数依然是文本脚本,与 image/png 不符,拦下

再往上一层是图片重编码:用 Pillow 把上传图片解码后重新保存,即使内容里藏了 PHP 代码(图片注释区、ELF 尾部),重编码后代码字节已被破坏:

python
from PIL import Image

def reencode_image(src, dst):
    with Image.open(src) as img:
        img.verify()          # 先验证是完整图片(结构性校验)
    with Image.open(src) as img:
        img.convert('RGB').save(dst, 'JPEG', quality=85)   # 再重编码落盘

注意 verify() 只验结构不加载全部数据,重编码才是「洗掉」内容的动作;对 PDF 等文档无法重编码,用内容检测(CDR/杀毒引擎)替代。

5. 防御三:存储与执行隔离

前两层拦在应用,这一层拦在基础设施——即使恶意文件真的存进来了,也执行不了:

nginx
# /etc/nginx/conf.d/uploads.conf —— 上传目录的隔离配置
server {
    listen 80;
    server_name static.example.com;

    # 静态资源目录:只读提供,明确禁止脚本解析
    location /uploads/ {
        alias /data/uploads/;
        # PHP/CGI 一律不在此 location 处理
        location ~* \.(php|phtml|php5|phar)$ {
            deny all;
        }
    }

    # 上传走专用域名 + POST-only(可选:加签名 URL 防盗链)
}

四条隔离手段按部署形态选用:

  1. 域名隔离(最彻底):上传文件放独立域名(static.example.com),与主站不同源——即使存进脚本,静态域名上没有 PHP-FPM 处理器,永远只是「下载文本」;Cookie 也因域不同不随请求发送;
  2. 目录禁解析:如上 nginx 配置, uploads location 内显式拒绝脚本后缀(配合 security.limit_extensions 或 Apache 的 php_admin_flag engine off);
  3. Web 根之外 + 应用层读出:第 3 节的 /data/uploads 方案,文件根本没有 URL;
  4. 只读挂载:容器化部署把上传目录设为独立卷,应用容器本身无写权限到代码目录——「能写的地方不可执行,可执行的地方不可写」是 W^X 原则在 Web 部署上的投影。

6. PHP/Java/Nginx 三形态配置清单

同一套防御思想在三个常见栈的落点:

PHP(php.ini / FPM pool 配置):

ini
; 关闭危险函数(按业务裁剪,而不是全禁 disable_functions 留空)
disable_functions = exec,passthru,shell_exec,system,proc_open,popen
; FPM 只处理约定后缀,改名的 .jpg 永远不进解析器
security.limit_extensions = .php
; 上传临时目录独立且无执行位
upload_tmp_dir = /data/upload_tmp

Java(Spring Boot 上传约束):

yaml
spring:
  servlet:
    multipart:
      max-file-size: 5MB        # 框架层先拦大小
      max-request-size: 6MB

配合第 3 节的白名单逻辑(Java 侧用 FileTypeDetector/Apache Tika 检测真实类型);Java 特有的一点:上传路径若参与 File/Paths.get() 拼接,必须 normalize() 后校验前缀,防 \..\..\ 穿越。

Nginx(反代层统一门卫):

nginx
# 请求体大小限制(所有上传必经之门)
client_max_body_size 6m;
# 拦截明显可疑的上传后缀(纵深防御的一层,不替代应用白名单)
if ($request_uri ~* "\.(php|phtml|phar)$") { return 403; }

顺序记住一句话:应用白名单是主门,内容检测验真身,服务器配置是最后的高墙——三层各拦各的,任何一层失守都不等于失陷。

7. 竞态上传与其他进阶攻击

  • 竞态上传(race condition):某些「先存盘、校验失败再删除」的实现存在窗口期——攻击者并发上传 + 高频访问 shell.php,抢在删除前命中执行。防御:先校验后落盘(校验在内存完成,绝不先保存);实在无法避免时,落盘到不可执行目录再原子 rename;
  • 解压炸弹与嵌套文件:允许 zip 上传的应用,要限制解压后总大小与条目数(zip bomb 能把 5MB 变成 TB 级);
  • SVG 里的脚本:SVG 是 XML,可内嵌 <script>——直接在主域提供 SVG 时等于开放存储型 XSS(XSS 攻击),防御是 SVG 也走独立域名或强制下载;
  • 云存储形态:用对象存储(S3/OSS)+ 签名 URL 直传时,应用服务器不碰文件流,但桶策略成为新的防线:桶禁公开列举、Content-Type 由服务端签发时锁定、取回走 CDN 时关闭脚本执行语义。

8. 动手实践

练习一:在 DVWA 复现上传漏洞并修复

任务:在 DVWA(安全基础与防御 练习二部署的靶场)File Upload 模块,Low 难度用 Burp 完成 webshell 上传与命令执行;然后自行给「假设的修复版」列出四层防线,并逐层说明它在攻击路径的哪一步断链。 提示:对照第 1 节的五个环节;修复不要求改 DVWA 源码,写出「如果是我实现会怎么做」的配置/代码即可。

参考思路(先自己列,再看):应用白名单拦第 3 步;随机重命名让第 5 步找不到路径;目录禁解析让第 5 步无回显;独立域名连 Cookie 都不泄漏。四层任意一层到位即断链,生产环境要求至少三层同时存在(纵深防御)。

练习二:审计一个真实项目的上传接口

任务:找一段开源项目(或你自己项目)的文件上传代码,按「白名单/大小/重命名/存储位置/内容校验/服务器配置」六项打分,输出三条最重要的修复建议。 提示:先找「保存到哪个目录」「扩展名怎么校验」两行代码——这两行决定了大部分风险等级;GitHub 上搜 file.filename 与 MultipartFile 的组合是快速定位法。

练习三(工程场景):FANDEX 这类静态站点的资源上传约束

任务:FANDEX 是纯静态站(构建产物直接发布),设想它要加「投稿配图上传」——写出完整方案:文件存哪、谁来校验、怎么展示,并说明静态站点形态让哪些攻击面天然消失、又新引入哪些。 提示:静态站点没有 PHP 执行器,「上传即执行」天然不成立;新的攻击面转向 CDN 回源与桶权限(第 7 节云存储形态)。

参考思路(先自己想,再看):走对象存储 + 签名 URL 直传,应用只签发凭证与登记元数据;校验在签发前(白名单 + 大小)与取回时(内容检测)两处;展示走独立 CDN 域名。天然消失:服务端代码执行、本地文件包含;新引入:桶公开读写配置错误、Content-Type 伪造导致的存储型 XSS(SVG 场景)。

9. 实际项目中的使用场景

  • 头像/附件上传:本文第 1-5 节的标准链路,是 Web 应用最常见的上传形态;
  • 内容平台素材库:图片重编码(第 4 节)+ CDN 独立域是标配;
  • 企业内部文件交换:病毒扫描(ClamAV daemon 或云厂商内容安全 API)加入流水线;
  • CI/CD 与制品库:上传制品要校验签名(checksum/sigstore)——「上传安全」思想同样适用于开发者工具链。

10. 与之前和之后的知识的关系

11. 官方文档

12. 自我检查

  • 能按顺序说出绕过六式各针对哪种错误防线,并解释「双写绕过」为什么证明「净化不如拒绝」;
  • 能写出服务端白名单上传的四步骨架代码,并说出随机重命名同时解决的三个问题;
  • 能画出「域名隔离」与「目录禁解析」两种隔离方案的请求路径,并说明前者为何更彻底;
  • 能解释竞态上传的窗口期成因,以及「先校验后落盘」如何消灭它;
  • 能为一次头像接口的 webshell 事件列出至少三层应存在的防线,指出哪层缺失。

本章总结

文件上传的攻防是一条清晰的对称轴:攻击者逐层绕过声明(后缀、MIME、大小写、双写、截断、配置文件),防御者逐层验证事实(白名单、魔数、重编码)并切断执行条件(随机重命名、Web 根外存储、域名隔离、目录禁解析)。核心三条:白名单代替黑名单、拒绝代替净化、隔离代替信任。三层防线(应用校验、内容检测、服务器隔离)任意一层失守都不等于失陷,生产环境要求至少三层同时存在。云原生形态下攻击面从「服务器执行」转向「桶策略与 CDN 语义」,但「不信声明、只信内容、隔离执行」三条原则原样适用。

参考与致谢

  • 本文绕过方式表格与安全上传四步代码整体承接自本仓库 Web 安全与渗透测试 原第 5 节文件上传漏洞(内容重组并扩写讲解);
  • 防御体系(白名单、内容校验、隔离、竞态、云存储)的条目化表述对照 OWASP File Upload Cheat Sheet(CC-BY 许可);
  • 其余内容为原创。