文件上传安全
文件上传漏洞与安全上传实现:客户端校验绕过、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
一个只做了「前端限制只能选图片」的头像接口,攻击者的完整路径:
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.jpg | C 语言字符串处理的老漏洞(新版运行时已基本免疫,旧系统仍需排查) |
| .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/…),防御的正解是白名单——只允许明确的业务必需类型:
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四步逐条讲:
- 白名单在前:
ALLOWED_EXTENSIONS列出「确定无害」的类型,不在名单一律拒绝——对比黑名单「列出已知有害」,白名单的失败模式(新类型被拒)只是功能限制,黑名单的失败模式(漏一种后缀)是服务器沦陷; - 大小限制独立于类型:即使校验被绕过,5MB 上限也限制了攻击面(超大文件耗尽磁盘、解压炸弹);
seek/tell计数要seek(0)复位,否则后续save写出空文件; - 随机重命名:UUID 文件名同时干掉三件事——路径穿越(
../../被丢弃)、同名覆盖、猜路径执行;扩展名只保留白名单里的小写形式; - 存到 Web 根之外:
/data/uploads不在 nginx 的静态目录里,用户不能直接用 URL 访问——要展示时经应用层鉴权后读出。这是第 5 节「存储与执行隔离」的一半,另一半在服务器配置。
4. 防御二:内容级校验(不信声明,信内容)
扩展名与 MIME 都是「声明」,攻击者全能改。真正的类型要看文件内容:
# 用魔数(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 尾部),重编码后代码字节已被破坏:
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. 防御三:存储与执行隔离
前两层拦在应用,这一层拦在基础设施——即使恶意文件真的存进来了,也执行不了:
# /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 防盗链)
}四条隔离手段按部署形态选用:
- 域名隔离(最彻底):上传文件放独立域名(
static.example.com),与主站不同源——即使存进脚本,静态域名上没有 PHP-FPM 处理器,永远只是「下载文本」;Cookie 也因域不同不随请求发送; - 目录禁解析:如上 nginx 配置, uploads location 内显式拒绝脚本后缀(配合
security.limit_extensions或 Apache 的php_admin_flag engine off); - Web 根之外 + 应用层读出:第 3 节的
/data/uploads方案,文件根本没有 URL; - 只读挂载:容器化部署把上传目录设为独立卷,应用容器本身无写权限到代码目录——「能写的地方不可执行,可执行的地方不可写」是 W^X 原则在 Web 部署上的投影。
6. PHP/Java/Nginx 三形态配置清单
同一套防御思想在三个常见栈的落点:
PHP(php.ini / FPM pool 配置):
; 关闭危险函数(按业务裁剪,而不是全禁 disable_functions 留空)
disable_functions = exec,passthru,shell_exec,system,proc_open,popen
; FPM 只处理约定后缀,改名的 .jpg 永远不进解析器
security.limit_extensions = .php
; 上传临时目录独立且无执行位
upload_tmp_dir = /data/upload_tmpJava(Spring Boot 上传约束):
spring:
servlet:
multipart:
max-file-size: 5MB # 框架层先拦大小
max-request-size: 6MB配合第 3 节的白名单逻辑(Java 侧用 FileTypeDetector/Apache Tika 检测真实类型);Java 特有的一点:上传路径若参与 File/Paths.get() 拼接,必须 normalize() 后校验前缀,防 \..\..\ 穿越。
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. 与之前和之后的知识的关系
- 往前:Web 安全导览 给出本篇在 Web 漏洞族谱中的位置;输入验证 的白名单原则是第 3 节的方法论来源;
- 旁支:上传的 webshell 执行命令后,命令注入的攻防在 命令注入;SVG 内脚本本质是存储型 XSS,见 XSS 攻击 与 XSS 防御;
- 往后:webshell 事件发生后的检测与处置(日志取证、样本分析)在 应急响应 与 恶意软件分析。
11. 官方文档
- OWASP File Upload Cheat Sheet(CC-BY 许可,本篇防御清单的权威对照):https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- OWASP Unrestricted File Entry(漏洞条目定义):https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload
- nginx location 与 deny 指令文档:https://nginx.org/en/docs/http/ngx_http_core_module.html#location
- PHP security.limit_extensions:https://www.php.net/manual/en/install.fpm.configuration.php
12. 自我检查
- 能按顺序说出绕过六式各针对哪种错误防线,并解释「双写绕过」为什么证明「净化不如拒绝」;
- 能写出服务端白名单上传的四步骨架代码,并说出随机重命名同时解决的三个问题;
- 能画出「域名隔离」与「目录禁解析」两种隔离方案的请求路径,并说明前者为何更彻底;
- 能解释竞态上传的窗口期成因,以及「先校验后落盘」如何消灭它;
- 能为一次头像接口的 webshell 事件列出至少三层应存在的防线,指出哪层缺失。
本章总结
文件上传的攻防是一条清晰的对称轴:攻击者逐层绕过声明(后缀、MIME、大小写、双写、截断、配置文件),防御者逐层验证事实(白名单、魔数、重编码)并切断执行条件(随机重命名、Web 根外存储、域名隔离、目录禁解析)。核心三条:白名单代替黑名单、拒绝代替净化、隔离代替信任。三层防线(应用校验、内容检测、服务器隔离)任意一层失守都不等于失陷,生产环境要求至少三层同时存在。云原生形态下攻击面从「服务器执行」转向「桶策略与 CDN 语义」,但「不信声明、只信内容、隔离执行」三条原则原样适用。
参考与致谢
- 本文绕过方式表格与安全上传四步代码整体承接自本仓库 Web 安全与渗透测试 原第 5 节文件上传漏洞(内容重组并扩写讲解);
- 防御体系(白名单、内容校验、隔离、竞态、云存储)的条目化表述对照 OWASP File Upload Cheat Sheet(CC-BY 许可);
- 其余内容为原创。