保护上传目录
文件上传是 Web 应用程序中最危险的功能之一,因为这意味着用户被允许主动向服务器发送文件。如果验证逻辑薄弱,攻击者可能会尝试将可执行代码伪装成图像或文档。
我遵循的第一条规则很简单:上传的文件不应具有可执行权限。
对于基于 Apache 的上传目录,可以通过类似以下的配置来阻止 PHP 执行:
<FilesMatch "\.(php|phtml|phar|php[0-9]*)$">
Require all denied
</FilesMatch>
根据托管环境的不同,采用其他 PHP 处理程序限制也可能是合适的。目的是确保即使恶意脚本以某种方式进入上传目录,服务器也会拒绝执行它。
我也不会信任用户提供的文件名。浏览器可能声称文件名为 vacation.jpg,但应用程序应独立验证文件类型,并创建自己的存储名称。
基本的 PHP 上传流程可能如下所示:
<?php
$allowedTypes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp'
];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['upload']['tmp_name']);
if (!isset($allowedTypes[$mime])) {
throw new RuntimeException('Unsupported file type.');
}
$filename = bin2hex(random_bytes(16)) . '.' . $allowedTypes[$mime];
?>
重要的一点是,服务器会确定扩展名,而不是信任用户提供的任何扩展名。
永远不要信任浏览器发送的 MIME 类型
HTTP 上传中包含的 MIME 类型可能被操纵。如果 PHP 接收到一个声称为 image/jpeg 的上传文件,那么仅凭这一数值不应决定该文件是否安全。
使用 finfo 可以让 PHP 检查文件的实际内容,并确定更可靠的 MIME 类型。即便如此,我仍会谨慎处理上传的文件。
对于图像,另一项额外防御措施是实际解码并重新编码图像。应用程序可以将图片加载到图像处理库中,并生成全新的 JPEG 或 WebP 文件,而不是保留原始上传的二进制数据。这样可以去除原始上传文件中可能嵌入的大量意外数据。
这种方法还有另一个好处。它可以统一图像尺寸、压缩方式和格式,同时提升安全性。
使用最小权限数据库账户
另一个常见错误是赋予网站数据库用户超出应用实际需求的权限。公共网站通常不需要创建新数据库用户、授予权限或管理整个 MySQL 服务器的能力。
应用应使用专用的数据库账户,并且该账户仅拥有应用所需的权限。
一个简化的示例可能是:
CREATE USER 'website_app'@'localhost'
IDENTIFIED BY 'strong-random-password';
GRANT SELECT, INSERT, UPDATE, DELETE
ON production_database.*
TO 'website_app'@'localhost';
应用是否需要额外权限取决于其运行方式,但除非有特定理由,否则我会避免赋予面向公众的应用完整的管理权限。
最小权限可以限制应用遭到入侵后的影响范围。如果网站数据库账户被窃取,攻击者只能获得分配给该账户的权限,而无法控制服务器上的每个数据库。
在所有地方使用预处理语句
SQL 注入仍然是最重要的 Web 应用漏洞之一,因为它可以将用户控制的输入转变为可执行的 SQL。解决方案不是尝试手动转义可疑字符,而是将 SQL 结构与用户数据分离。
使用 PDO 时,我会使用预处理语句。
<?php
$stmt = $pdo->prepare(
'SELECT id, username, email
FROM users
WHERE email = :email'
);
$stmt->execute([
':email' => $email
]);
$user = $stmt->fetch();
?>
重要的一点是,$email 会被视为数据,而不是拼接到 SQL 命令中。
我会避免编写这样的代码:
<?php
$sql = "SELECT * FROM users WHERE email = '$email'";
?>
即使开发人员认为自己能够控制输入,应用程序也会随着时间推移而变化。预处理语句应当成为默认模式,而不是只用于被认为危险的字段。
安全的管理员身份验证
管理员界面需要比网站面向公众的一侧更强的保护。如果可能,我会为管理账户使用多因素身份验证。至少,管理员密码应足够长、具有唯一性,并使用 PHP 的密码哈希函数存储。
密码存储应类似于这样:
<?php
$hash = password_hash($password, PASSWORD_DEFAULT);
?>
身份验证随后使用:
<?php
if (password_verify($password, $storedHash)) {
// Successful authentication.
}
?>
绝不应以明文存储密码,也不应使用可逆密钥加密密码,或使用 MD5 等过时算法进行哈希处理。
我还会避免透露某个特定用户名或电子邮件地址是否存在。应用程序可以显示通用的身份验证失败消息,而不是说“没有与该电子邮件地址对应的账户”。
这样可以防止登录页面变成账户探测工具。
强化 PHP 会话
密码被接受并不意味着身份验证安全就此结束。用户登录后,会话 Cookie 实际上就成了该用户的临时凭证。
只要网站通过 HTTPS 运行,我就会配置会话 Cookie,使其使用安全属性。
<?php
session_set_cookie_params([
'httponly' => true,
'secure' => true,
'samesite' => 'Lax'
]);
?>
HttpOnly 属性会增加客户端 JavaScript 访问 Cookie 的难度。Secure 属性会告知浏览器仅通过 HTTPS 发送该 Cookie。SameSite 属性有助于减少某些跨站请求攻击。
成功登录后,我会重新生成会话标识符。
<?php
session_regenerate_id(true);
?>
这会降低会话固定攻击的风险。
管理会话还可以使用不活动超时。如果管理员离开已登录的浏览器数小时,会话不应必然无限期保持活动状态。
使用 CSRF 令牌保护每个会更改状态的请求
跨站请求伪造攻击会诱骗已通过身份验证的用户浏览器提交不需要的请求。设想管理员在登录 CMS 的同时访问另一个恶意网站。如果 CMS 不验证敏感请求的来源,恶意页面可能会利用管理员已通过身份验证的会话触发相关操作。
对于创建、更新或删除信息的表单,我会使用 CSRF 令牌。
可以生成令牌并将其存储在会话中:
<?php
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
然后将令牌放置在表单中:
<input type="hidden"
name="csrf_token"
value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
提交表单时,应用程序会对其进行验证。
<?php
if (
empty($_POST['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
) {
http_response_code(403);
exit('Invalid request.');
}
?>
令牌并不能替代身份验证。它增加了一个额外的验证步骤,用于证明请求源自应用程序生成的表单。
转义输出,而不仅仅是输入
开发人员有时会将全部注意力放在清理传入数据上,但输出处理同样重要。存储在数据库中的值之后可能会显示在 HTML、JavaScript、属性或 URL 中。每种上下文都有不同的转义要求。
对于普通的 HTML 输出,我使用:
<?php
echo htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
?>
对于评论、用户名、文章标题、表单提交内容,以及任何源自应用程序外部的其他数据,这一点尤其重要。
存储型跨站脚本攻击可能特别危险,因为恶意载荷会保留在数据库中。如果该载荷出现在管理员控制面板中,那么每当管理员查看受影响的记录时,它都可能执行。
不要向访问者公开详细的 PHP 错误
开发错误在编程期间很有用,但生产环境中的用户不应接收到堆栈跟踪、数据库错误、文件系统路径或配置详细信息。
生产环境中的 PHP 通常应避免直接向浏览器显示内部错误。
display_errors = Off
log_errors = On
错误应改为写入受保护的日志文件。
例如这样的数据库异常:
SQLSTATE[HY000] [1045] Access denied for user...
可能会泄露数据库用户名、软件版本、内部路径和应用程序结构。这些信息都不需要展示给匿名访客。
用户可以收到一条通用消息,而真实错误则私下记录下来,以便调试。
创建真正有用的安全日志
标准访问日志很有价值,但我也喜欢应用程序级别的安全日志记录。应用程序可以记录可疑事件,例如反复登录失败、被阻止的上传类型、访问受保护端点的尝试、无效的 CSRF 令牌,以及请求本不应公开存在的文件。
一条基本的安全日志记录可能包含时间戳、IP 地址、请求 URI、用户代理、身份验证用户 ID(如果可用),以及触发警报的规则。
例如:
<?php
$entry = sprintf(
"[%s] IP=%s URI=%s EVENT=%s\n",
date('c'),
$_SERVER['REMOTE_ADDR'] ?? 'unknown',
$_SERVER['REQUEST_URI'] ?? '',
'Blocked sensitive-file probe'
);
file_put_contents(
'/home/account/logs/security.log',
$entry,
FILE_APPEND | LOCK_EX
);
?>
安全日志不应存储在可公开下载的目录中。同时还应避免记录密码、完整的会话 Cookie、身份验证标头或私有 API 密钥等机密信息。
检测常见的侦察请求
攻击者经常探测网站中是否存在常见文件和应用,而不管这些应用实际上是否存在。自定义 PHP 网站仍可能收到针对 /wp-login.php、/wp-admin、/.env、/.git/config 和 /phpmyadmin 的请求。
这些请求并不一定意味着攻击者了解该网站的任何信息。其中很大一部分扫描是自动进行的。不过,记录这些请求仍然很有价值,因为它们可以揭示可疑的行为模式。
Apache 可以立即拒绝某些敏感路径。
RedirectMatch 404 (?i)/\.git
RedirectMatch 404 (?i)/\.env
我通常更倾向于返回普通的错误响应,而不是暴露有关该文件是否曾经存在的不必要信息。
限制身份验证尝试的频率
登录页面不应允许以机器速度进行无限次身份验证尝试。即使是强密码,也能从速率限制中受益,因为这可以减少自动化猜测和凭据填充攻击。
基本的应用程序可以根据账户标识符和 IP 地址跟踪失败的登录尝试。多次尝试失败后,可以引入一段临时冷却期。
实现方式可以使用 MySQL、Redis、本地缓存或其他存储机制。具体系统没有底层原则重要:身份验证尝试应当付出代价。
我避免仅因尝试失败就触发永久账户锁定,因为攻击者可能会故意将合法用户锁在自己的账户之外。临时限流通常是更好的设计。
不要将 User-Agent 或 IP 地址视为身份验证依据
IP 地址和浏览器 User-Agent 字符串对于记录日志和检测异常可能很有用,但二者都不应作为主要的身份识别机制。用户在一天之内可能会在蜂窝网络、Wi-Fi 网络、VPN 以及不断变化的 IP 地址之间切换。
安全系统应将这些信号作为上下文,而不是绝对的证明。
例如,来自新国家的管理员突然登录可能需要进行额外身份验证,但仅 IP 地址发生变化不应必然导致合法账户被终止。
分离公共路由和管理路由
我更倾向于将面向公众的应用路由与管理功能清晰分离。管理界面不应意外复用暴露出超出预期功能的公共控制器。
例如,CMS 可能使用:
/admin/posts
/admin/users
/admin/settings
每个管理路由都应在服务器端执行授权检查。
隐藏导航链接不是授权。通过 JavaScript 移除管理按钮也不是授权。如果用户未经许可手动输入管理 URL,则应收到服务器端拒绝响应。
这一点极其重要。任何仅在浏览器内部强制执行的限制都有可能被绕过。
使用基于角色的授权
身份验证回答的是“这个用户是谁?”授权回答的是“这个用户被允许做什么?”
拥有管理员、编辑、贡献者、客户或企业账户的系统,应明确规定这些权限。
编辑可能被允许修改文章,但不能创建管理员账户。支持人员可能能够查看客户会话,但不能更改计费配置。普通用户不应仅仅因为发现了某个 API 端点,就获得内部管理功能的访问权限。
每项敏感操作都应独立验证授权。
备份也需要安全保护
遭到入侵后,备份至关重要,但备份也可能成为安全风险。开发人员有时会创建以下名称的文件:
database-backup.sql
website-old.zip
public_html-backup.tar.gz
config.php.bak
并将它们留在公共 Web 目录中。
这些文件可能包含源代码、数据库记录、密码哈希、电子邮件地址、API 凭据以及其他机密信息。
应尽可能将备份存储在文档根目录之外。理想情况下,备份还应进行加密、实施访问控制,并复制到独立的存储位置。
最重要的是,应实际测试备份。一个从未被恢复过的备份,只是一种假设。
检查计划任务
在事件响应期间,人们很容易忘记 Cron 任务。获得足够访问权限的攻击者可能会创建一个计划进程,用于重新创建已删除的恶意软件,或稍后下载有效载荷。
因此,在发生严重入侵后,我会检查计划任务。
在 Linux 上,这可能包括:
crontab -l
根据服务器配置,系统级 Cron 目录可能也需要由服务器管理员进行检查。
网站目录干净,并不一定意味着系统是干净的,因为如果另一个进程能够自动重新感染它,系统仍可能存在问题。
检查是否存在异常的管理员账户
如果 CMS 包含用户表,我会手动检查特权账户。我希望了解哪些人拥有管理员权限、这些账户何时创建,以及其电子邮件地址和用户名是否可以识别。
同样的原则也适用于托管账户、数据库管理系统、SSH 用户、FTP 账户和控制面板用户。
攻击者通常更倾向于创建看似合法的访问方式,而不是完全依赖恶意软件。额外的管理员账户可以在代码清理后继续存在,并持续为攻击者提供访问权限。
检查数据库中是否存在注入内容
并非所有网站恶意软件都存在于文件中。攻击者可能会直接将垃圾链接、脚本、重定向、虚假文章或恶意 HTML 插入数据库记录。
我会在数据库内容中搜索可疑域名、意外的 <script> 标签、iframe 元素、编码后的 JavaScript,以及大段陌生的 HTML。
对于页面内容主要存储在 MySQL 中的 CMS 系统而言,这一点尤其重要。只清理文件系统而忽略数据库,可能会使入侵状态部分保留。
在适用情况下使用内容安全策略
内容安全策略(CSP)可以通过限制浏览器允许加载的脚本、样式、框架及其他资源,减轻某些跨站脚本攻击的影响。
非常严格的 CSP 需要经过仔细测试,因为现代应用程序通常依赖第三方服务、分析工具、支付服务商、CDN 和内联脚本。不过,即使是经过谨慎设计的策略,也能显著减少可接受可执行内容的来源数量。
一个基本的起点可能如下所示:
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; frame-ancestors 'self';"
这个示例有意保持简洁。实际应用可能需要为 Stripe、分析工具、字体、图像、API 或外部资源添加其他指令。
重要的是,应将 CSP 视为额外的浏览器安全边界,而不是安全编程的替代方案。
添加其他有用的安全标头
一些 HTTP 响应标头可以减少不必要的浏览器行为,或加强应用程序安全性。
例如:
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set X-Frame-Options "SAMEORIGIN"
这些标头针对不同的问题。X-Content-Type-Options 可防止浏览器猜测内容类型。Referrer-Policy 控制共享多少引用 URL 信息。对于支持该标头的浏览器,X-Frame-Options 可以减少框架嵌入攻击。
现代应用可以使用 CSP 的 frame-ancestors 指令来替代 X-Frame-Options,或与其结合使用。
强制使用 HTTPS
登录系统不应通过未加密的 HTTP 运行。HTTPS 可以保护凭据、会话 Cookie、表单提交内容以及其他敏感信息在浏览器与服务器之间传输时的安全。
我会将公共 HTTP 流量重定向到 HTTPS,并确保应用本身生成 HTTPS URL。
基本的 Apache 重定向可能如下所示:
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
在使用反向代理或负载均衡器的系统中,HTTPS 检测可能需要不同的配置,因此需要考虑托管架构。
控制依赖项
依赖项实际上是在应用程序内部运行的第三方代码。通过 Composer、npm 或其他包管理器安装的每个软件包,都会成为应用程序攻击面的一部分。
我会定期审查依赖项,并移除不再需要的软件包。依赖项越少,需要更新的组件就越少,易受攻击的代码长期未被发现的机会也越少。
对于基于 Composer 的 PHP 项目,依赖项审计可以成为维护工作流程的一部分。
composer audit
重要的运维习惯并不只是盲目安装更新。你应该了解应用程序依赖哪些软件包、它们的用途,以及生产环境中是否仍然存在已被弃用的软件包。
保护 API 密钥,避免暴露在前端
秘密 API 凭据绝不应嵌入公开的 JavaScript 中。任何发送到浏览器的内容最终都可能被用户检查。
如果某个 API 需要服务器端的秘密凭据,通常应由服务器发起请求。
例如,OpenAI、Stripe、电子邮件服务或内部服务的秘密凭据,应放在服务器端配置中,而不是放在 JavaScript 捆绑包内。
公开密钥则有所不同。一些服务提供商有意使用可在浏览器中安全使用的可发布密钥。开发者应了解可发布凭据与秘密凭据之间的区别,而不是想当然地认为每个 API 密钥都可以安全地出现在前端代码中。
验证 Stripe Webhook
支付系统需要特别关注,因为恶意用户不应仅通过向 webhook URL 发送自己的 HTTP 请求,就能告诉你的应用程序某笔支付已经成功。
使用 Stripe webhook 时,应用程序应在信任有效载荷之前,使用 webhook 签名密钥验证事件签名。
总体思路如下:
<?php
$event = \Stripe\Webhook::constructEvent(
$payload,
$signature,
$endpointSecret
);
?>
只有在验证成功后,应用才应更新订单、解锁付费内容、将发票标记为已支付或授予服务。
应用数据库绝不能将浏览器重定向到“支付成功”页面视为款项实际到账的证明。
在服务器上保护付费内容
这对于会员网站和付费墙内容尤为重要。使用 CSS 或 JavaScript 隐藏付费内容并不是真正的访问控制。
如果完整文章已发送到浏览器,而 JavaScript 只是将其隐藏,有人就可以打开开发者工具,读取所谓受保护的内容。
服务器应在返回受限材料之前确定访问者是否已获授权。
概念上:
<?php
if (!$userHasAccess) {
echo $preview;
exit;
}
echo $fullArticle;
?>
重要区别在于,未经授权的访问者永远不会收到文章中受保护的部分。
同样的规则也适用于高级下载内容。不要将 PDF 放在公开且可预测的 URL 上,然后假定人们只能通过购买页面访问它。应用应在发送文件之前验证访问权限。
使用安全的下载处理程序
受保护的文件最好存储在公共 Web 目录之外。当经过授权的客户请求文件时,PHP 可以验证权限,并将文件流式传输到浏览器。
存储位置可能如下所示:
/home/account/private-downloads/security-checklist.pdf
而不是:
/public_html/downloads/security-checklist.pdf
这样可以防止有人通过发现直接 URL,绕过应用的授权机制。
监控 404 流量
观察自动化扫描最简单的方法之一,是监控对不存在文件的重复请求。正常访客偶尔会因链接失效而产生 404 错误。扫描器在搜索 WordPress 插件、配置文件、备份归档、管理界面、环境文件和已知存在漏洞的脚本时,可能会产生数百个 404 请求。
我不会自动封禁每个触发 404 的访客,但模式很重要。
单个针对 /wp-login.php 的请求并不能说明什么。几秒钟内针对 .env、phpmyadmin、xmlrpc.php、.git/config、数据库备份和旧插件发起五十次请求,则说明的是另一种情况。
安全决策应基于行为,而不是某一个孤立的请求。
谨慎使用自动 IP 封禁
自动封禁可能很有用,但过于激进的系统也会带来自身的问题。搜索引擎、正常运行时间监控器、安全扫描器、企业代理、VPN 服务和合法用户都可能产生异常的请求模式。
我不喜欢仅因为某个可疑 URL 就永久封禁一个 IP,而更倾向于使用阈值和临时封禁。
例如,应用程序可以在短时间内多次尝试访问敏感文件后,暂时封禁客户端。
这样既能减少自动化扫描,又不会把每个异常请求都视为永久敌人。
记录管理变更
优秀的 CMS 应该为重要操作维护审计记录。我希望知道用户何时登录、创建页面、删除文章、更改其他用户的权限、更新支付设置、修改网站配置,或执行其他影响重大的操作。
审计日志可能会记录管理员 ID、操作、受影响的记录、时间戳和源 IP。
例如:
2026-08-29T21:44:12
USER=14
ACTION=delete_post
POST_ID=381
IP=203.0.113.10
当出现问题时,审计日志会变得极其有价值,因为它们能让你重建事情的经过,而不是完全依赖记忆。
进行重大更改前先备份
如果操作不慎,安全加固本身也可能导致网站故障。限制性较强的 Apache 规则可能会阻止合法路由。新的 CSP 可能会阻止所需的 JavaScript 加载。文件权限更改可能会导致上传功能失效。会话配置可能会干扰身份验证。
在进行重大安全更改之前,我会备份应用程序和数据库。
区别在于,之后我不会把该备份留在公开目录中。
像不信任网站的人一样进行测试
完成基本加固后,我会从试图让应用程序做出异常行为的人的角度来测试它。
如果我更改请求中的记录 ID,会发生什么?一个客户能访问另一个客户的文件吗?普通账户能否手动请求管理员 URL?如果我上传一个扩展名具有误导性的文件,会发生什么?如果我在每个表单中都提交 HTML,会发生什么?如果我发送不带 CSRF 令牌的请求,会发生什么?我能否在未实际付款的情况下触发付款完成操作?
这种思维方式很有价值,因为许多严重漏洞并不在于代码语法,而在于开发人员对用户行为方式所作的假设。
普通用户会按照界面操作。攻击者不会。
我的遭入侵后检查清单
在安全事件发生后评估 PHP 网站时,我会检查整个环境,而不只是检查入侵变得明显的页面。我会检查最近修改的文件、上传目录、管理员账户、数据库内容、cron 作业、服务器日志、API 凭据、配置文件、备份、文件权限以及身份验证行为。
我会轮换已暴露的凭据,在可能的情况下将机密移到文档根目录之外,禁用不必要的功能,阻止上传目录内的脚本执行,检查数据库权限,确认使用了预处理语句,加强会话安全,实施 CSRF 防护,并确保受保护的内容确实受到服务器保护。
我还会确认备份存储在独立于生产网站的其他位置,并且这些备份确实能够恢复应用程序。
目标并不是让网站在理论上不可能遭到入侵。这不是现实可行的安全模型。目标是消除明显的弱点,缩小攻击面,限制攻击者在某个组件失效时能够造成的影响,并建立足够的日志记录,使可疑行为能够被发现。
安全防护应当分层
单一的安全控制永远不够。防火墙可能失效。应用程序可能包含编程错误。密码可能被窃取。依赖项可能出现漏洞。服务器可能配置错误。
解决方案是纵深防御。
如果攻击者设法上传了恶意文件,上传目录应拒绝执行该文件。如果攻击者发现了数据库密码,数据库账户应仅拥有受限权限。如果管理员密码被窃取,多因素身份验证应当构成另一道屏障。如果恶意 JavaScript 进入数据库,输出转义和内容安全策略(Content Security Policy)应当降低其执行能力。如果仍然出现问题,日志记录和备份应使检测与恢复更加容易。
这就是本文几乎所有安全措施背后的理念。
安全并不是安装一次后就可以忘记的产品。它是一种架构、一种开发习惯,也是一套运营流程。
当你开始基于这样的假设来设计系统:用户会篡改请求,扫描器会探测不存在的文件,机器人会攻击登录页面,而错误最终总会发生,你的应用就会变得难以被攻破得多。
而且,在你真正清理过一次被黑的网站后,通常就不会再认为这些预防措施过于谨慎了。