PHP上传功能是Web应用中最常见的攻击入口之一。攻击者一旦将恶意脚本上传到可解析的Web目录,就能直接通过URL访问执行,拿下服务器权限。目录分离的核心思路很简单:把上传的文件存放在Web根目录之外,让用户无法直接通过浏览器访问,所有文件读取都必须经过PHP脚本中转。这能从根本上阻断脚本被解析的可能。

为什么单纯限制后缀名不够

很多开发者只做前端JS校验和后端白名单后缀检查,这远远不够。攻击者可以绕过前端校验,而后端只判断文件后缀名存在极大风险。比如上传一个名为shell.php.jpg的文件,如果服务器解析配置不当,Apache可能因为不认识.jpg而向前递推解析.php。更隐蔽的是图片马,在合法图片末尾附加PHP代码,再配合文件包含漏洞执行。还有.htaccess、.user.ini等配置文件上传,能直接修改目录解析规则。所以必须从存储位置这个根源上解决问题。

目录分离的基本架构

标准做法是将上传目录设置在Web根目录之外。假设网站根目录是/var/www/html,上传目录就放在/var/www/uploads。这个uploads目录对Nginx或Apache来说完全不可达,用户无法通过http://example.com/uploads/xxx.jpg直接访问。所有文件展示都通过一个PHP脚本完成,比如file.php?id=123,脚本根据ID从数据库查到真实文件路径,再读取输出。这样即使上传了PHP文件,也永远不会被PHP-FPM或mod_php解析。

Nginx配置防跨站访问

在Nginx中,需要确保上传目录不被直接访问。如果上传目录必须在Web根目录内,可以用internal指令保护中转脚本引用的文件,同时禁止直接URL访问。配置示例如下:

# 禁止直接访问上传目录
location /uploads/ {
    internal;
}

# 中转脚本
location /file.php {
    fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
}

internal指令意味着只有Nginx内部的rewrite、error_page等才能访问这个location,外部请求直接返回404。这样用户只能通过file.php来获取文件。如果上传目录完全在Web根目录外,则不需要这个配置,只需确保PHP脚本有读取该目录的权限即可。

Apache配置防跨站访问

Apache环境下,如果上传目录必须在Web根目录内,需要在该目录下创建.htaccess文件,彻底禁用脚本执行:


    Require all denied


# 或者更彻底的方式
php_flag engine off
RemoveHandler .php .phtml .php3 .php4 .php5 .php7
RemoveType .php .phtml .php3 .php4 .php5 .php7

但.htaccess本身也可能被上传覆盖,所以更好的做法是在Apache主配置中针对上传目录做设置,并禁止该目录下的.htaccess覆盖:


    AllowOverride None
    php_admin_flag engine off
    
        Require all denied
    

最稳妥的还是将上传目录移到Web根目录外,Apache根本无法访问到,彻底杜绝解析风险。

PHP中转脚本的安全实现

中转脚本是文件访问的唯一入口,必须写得足够安全。核心原则是:永远不要直接使用用户输入的路径参数。应该用文件ID或哈希值作为参数,从数据库查询真实路径。示例如下:

prepare("SELECT file_path, file_name, mime_type FROM files WHERE id = ? AND status = 1");
$stmt->execute([$id]);
$file = $stmt->fetch();

if (!$file) {
    http_response_code(404);
    exit('File Not Found');
}

$base_dir = '/var/www/uploads/';
$full_path = $base_dir . $file['file_path'];

// 路径遍历防护
$real_path = realpath($full_path);
if ($real_path === false || strpos($real_path, realpath($base_dir)) !== 0) {
    http_response_code(403);
    exit('Path Traversal Detected');
}

if (!file_exists($real_path)) {
    http_response_code(404);
    exit('File Not Found');
}

// 输出文件
header('Content-Type: ' . $file['mime_type']);
header('Content-Disposition: inline; filename="' . $file['file_name'] . '"');
header('Content-Length: ' . filesize($real_path));
header('X-Content-Type-Options: nosniff');
readfile($real_path);

这段代码做了多层防护:用ID而非路径做参数、数据库白名单校验、realpath解析后检查路径前缀防止目录穿越、禁止内容嗅探。注意Content-Disposition用inline而非attachment,这样图片和PDF可以直接在浏览器展示,需要强制下载的文件类型可以单独处理。

文件上传时的安全处理

除了存储位置分离,上传环节本身也要做足安全措施。文件重命名是必须的,绝对不能保留用户原始文件名。推荐用UUID或时间戳加随机字符串生成唯一文件名,后缀从白名单中取。完整的上传处理流程如下:

 false, 'msg' => 'Upload error'];
    }
    
    // 检查文件大小,比如限制10MB
    $max_size = 10 * 1024 * 1024;
    if ($file['size'] > $max_size) {
        return ['success' => false, 'msg' => 'File too large'];
    }
    
    // MIME白名单
    $allowed_mimes = [
        'image/jpeg',
        'image/png',
        'image/gif',
        'image/webp',
        'application/pdf',
        'text/plain',
        'application/zip'
    ];
    
    // 用finfo检测真实MIME,不能用$_FILES里的
    $finfo = finfo_open(FILEINFO_MIME_TYPE);
    $real_mime = finfo_file($finfo, $file['tmp_name']);
    finfo_close($finfo);
    
    if (!in_array($real_mime, $allowed_mimes)) {
        return ['success' => false, 'msg' => 'Invalid file type'];
    }
    
    // 后缀白名单
    $ext_map = [
        'image/jpeg' => 'jpg',
        'image/png' => 'png',
        'image/gif' => 'gif',
        'image/webp' => 'webp',
        'application/pdf' => 'pdf',
        'text/plain' => 'txt',
        'application/zip' => 'zip'
    ];
    $ext = $ext_map[$real_mime];
    
    // 生成安全文件名
    $new_name = bin2hex(random_bytes(16)) . '.' . $ext;
    
    // 按日期分子目录,避免单目录文件过多
    $sub_dir = date('Y/m/d');
    $upload_base = '/var/www/uploads/';
    $target_dir = $upload_base . $sub_dir;
    
    if (!is_dir($target_dir)) {
        mkdir($target_dir, 0755, true);
    }
    
    $target_path = $target_dir . '/' . $new_name;
    
    if (!move_uploaded_file($file['tmp_name'], $target_path)) {
        return ['success' => false, 'msg' => 'Move failed'];
    }
    
    // 如果是图片,可选做二次处理去除恶意代码
    // 存入数据库的相对路径
    $db_path = $sub_dir . '/' . $new_name;
    
    return [
        'success' => true,
        'path' => $db_path,
        'name' => $file['name'],
        'mime' => $real_mime,
        'size' => $file['size']
    ];
}

这里的关键点是用finfo检测真实MIME类型,而不是信任HTTP头或文件后缀。random_bytes生成加密安全的随机文件名,按日期分子目录防止单目录文件数过多影响性能。文件存入数据库的是相对路径,配合中转脚本的base_dir拼接使用。

图片文件的二次清洗

即使MIME类型正确,图片文件也可能包含恶意代码。图片马通常利用EXIF元数据区域或文件末尾附加PHP代码。上传图片后建议用GD库或Imagick重新生成,这会清除所有元数据和附加数据。以GD库为例:



这个过程会彻底重建图片像素数据,任何嵌入的恶意代码都会被丢弃。代价是轻微的质量损失和服务器CPU消耗,但安全性大幅提升。对于用户头像等必须展示的图片,这个步骤强烈建议执行。

访问控制和频率限制

目录分离后,中转脚本成为文件访问的唯一通道,可以方便地加入权限控制。比如私有文件只允许上传者本人查看,付费内容需要验证购买状态,敏感文件可以加访问日志记录。还可以对单IP做频率限制,防止爬虫批量抓取:

incr($key);
if ($count === 1) {
    $redis->expire($key, 60);
}
if ($count > 100) { // 每分钟最多100次
    http_response_code(429);
    exit('Too Many Requests');
}

对于高流量站点,还可以在中转脚本前加CDN缓存层,但要注意私有文件不能缓存。可以通过设置Cache-Control头来控制CDN行为,公开文件设置较长的max-age,私有文件设置private或no-store。

分布式存储的目录分离方案

当网站规模扩大,单机存储不够用时,通常会接入对象存储服务。这时目录分离的概念演变为:上传的文件不经过Web服务器,而是直接上传到对象存储。有两种实现方式:一是客户端直传,服务端只返回预签名上传URL;二是服务端中转上传。无论哪种,最终文件URL都是对象存储的域名,与Web服务器完全隔离。如果对象存储支持自定义域名并绑定到主站子域名,需要确保该子域名不解析PHP,只做静态文件服务。在对象存储控制台设置该Bucket为静态网站模式,关闭所有脚本执行能力。

对于自建分布式存储,比如MinIO或FastDFS集群,同样要保证存储节点不运行PHP,只提供HTTP静态文件服务或通过专用API访问。Web服务器通过内网API获取文件,再中转输出给用户。这样即使存储节点被上传恶意文件,也没有执行环境。

常见配置误区和排查方法

很多开发者以为把上传目录放在Web根目录外就万事大吉,但忽略了PHP的open_basedir配置。如果open_basedir包含了上传目录,PHP脚本依然可以include该目录的文件,存在文件包含风险。应该将open_basedir严格限制在Web根目录内,上传目录只给读写权限,不给执行权限。排查时可以写一个测试脚本,用file_get_contents尝试读取上传目录外的文件,确认open_basedir生效。

另一个常见问题是备份文件泄露。编辑器生成的.bak、.swp、~结尾的备份文件如果留在上传目录,可能被猜解访问。上传目录应该定期清理非白名单后缀的文件,或者在Nginx/Apache中禁止访问这些后缀。Nginx配置:

location ~* \.(bak|swp|old|save|~)$ {
    deny all;
    return 404;
}

最后要检查的是PHP-FPM的security.limit_extensions配置,确保只允许.php等必要后缀被解析。默认情况下这个配置是.php .phar,如果不需要phar可以去掉。

总结实施清单

实施PHP上传目录分离防跨站,可以按以下清单逐项落实:上传目录设置在Web根目录外;所有文件访问通过PHP中转脚本;中转脚本用ID而非路径参数;数据库存储文件相对路径;上传时用finfo检测真实MIME;文件名用随机字符串重命名;图片文件做GD库二次清洗;中转脚本做realpath路径穿越防护;配置open_basedir限制PHP访问范围;Nginx用internal或Apache用主配置禁用上传目录脚本解析;禁止上传目录下的配置文件覆盖;添加访问频率限制;定期清理异常后缀文件。这套组合方案能有效防御文件上传相关的跨站攻击,即使单点出现疏漏,多层防护也能把风险降到最低。