真实场景:一次线上数据泄露
2023年,我接手一个遗留PHP项目(PHP 7.4,未使用框架)。上线第三天,用户反馈“修改密码后,他人能登录”。排查发现:登录接口存在SQL注入,攻击者通过构造username=admin' OR '1'='1绕过验证,直接获取所有用户哈希密码。更糟的是,评论功能未过滤XSS,攻击者注入脚本窃取Cookie。CSRF防护缺失,导致攻击者可伪造请求修改用户邮箱。
这次事故让我意识到:安全防护不是“锦上添花”,而是“生死线”。本文从这三个漏洞出发,给出可落地的解决方案。
问题:三个漏洞的根源
SQL注入
根本原因:用户输入直接拼接到SQL语句中。例如:
$sql = "SELECT * FROM users WHERE username = '{$_POST['username']}' AND password = '{$_POST['password']}'";
攻击者输入admin' OR '1'='1,SQL变为:SELECT * FROM users WHERE username = 'admin' OR '1'='1' AND password = '',恒真条件返回所有用户。
XSS(跨站脚本)
根本原因:用户输入直接输出到HTML页面,未转义。例如:
echo "评论内容:" . $_POST['comment'];
攻击者输入<script>alert('XSS')</script>,浏览器执行脚本。
CSRF(跨站请求伪造)
根本原因:请求未验证来源。攻击者构造恶意链接或表单,诱导已登录用户点击,执行非预期操作(如修改密码、转账)。
方案对比:三种主流防护方式
我测试了三种方案:
- 方案A:手动转义(使用
mysqli_real_escape_string、htmlspecialchars、Referer验证) - 方案B:框架内置(Laravel 11的Eloquent ORM、Blade模板、CSRF Token)
- 方案C:自定义中间件(PDO预处理语句、HTML Purifier、Token验证)
测试环境:PHP 8.3.2,MySQL 8.0.35,Nginx 1.24,4核8G云服务器,压测工具wrk 4.2.0。
| 方案 | SQL注入防护 | XSS防护 | CSRF防护 | 性能(RPS) | 代码复杂度 |
|---|---|---|---|---|---|
| 方案A | 低(易遗漏) | 中(需手动转义) | 低(Referer可伪造) | 1200 | 高 |
| 方案B | 高(ORM自动处理) | 高(Blade自动转义) | 高(内置Token) | 980 | 低 |
| 方案C | 高(PDO预处理) | 高(HTML Purifier) | 高(Token验证) | 1050 | 中 |
方案A性能最高但安全风险大,方案B最省力但依赖框架,方案C平衡了安全与性能。我选择方案C作为通用方案,下文给出完整实现。
完整代码实现
1. SQL注入防护:PDO预处理语句
使用PDO的预处理语句,参数绑定自动转义特殊字符。
// db.php - 数据库连接
$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=test;charset=utf8mb4';
$username = 'root';
$password = 'secret';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 关闭模拟预处理,使用真实预处理
];
$pdo = new PDO($dsn, $username, $password, $options);
// login.php - 安全登录
function login(string $username, string $password): ?array {
global $pdo;
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username AND password = :password');
$stmt->execute([':username' => $username, ':password' => hash('sha256', $password)]);
return $stmt->fetch() ?: null;
}
// 使用示例
$user = login($_POST['username'], $_POST['password']);
if ($user) {
$_SESSION['user_id'] = $user['id'];
}
关键点:PDO::ATTR_EMULATE_PREPARES => false确保使用MySQL原生预处理,避免模拟预处理的安全漏洞。
2. XSS防护:HTML实体编码 + HTML Purifier
输出时使用htmlspecialchars转义,富文本内容使用HTML Purifier过滤。
// 安装HTML Purifier: composer require ezyang/htmlpurifier
require_once 'vendor/autoload.php';
// 普通文本输出
function escapeHtml(string $input): string {
return htmlspecialchars($input, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// 富文本过滤(允许部分HTML标签)
function sanitizeHtml(string $input): string {
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,b,i,u,a[href],ul,ol,li,br');
$config->set('HTML.TargetBlank', true);
$config->set('Cache.SerializerPath', '/tmp/htmlpurifier');
$purifier = new HTMLPurifier($config);
return $purifier->purify($input);
}
// 评论输出示例
echo '<p>评论内容:' . escapeHtml($comment['content']) . '</p>';
// 富文本评论
echo '<div>' . sanitizeHtml($comment['rich_content']) . '</div>';
注意:htmlspecialchars默认只转义双引号,需设置ENT_QUOTES转义单引号。
3. CSRF防护:Token验证中间件
生成Token存入Session,表单提交时验证。
// csrf.php - CSRF工具类
class Csrf {
public static function generateToken(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
public static function validateToken(string $token): bool {
if (empty($_SESSION['csrf_token']) || empty($token)) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
}
public static function getHiddenInput(): string {
return '<input type="hidden" name="csrf_token" value="' . self::generateToken() . '">';
}
}
// 表单渲染
echo '<form method="POST" action="/update_email">';
echo Csrf::getHiddenInput();
echo '<input type="email" name="email">';
echo '<button type="submit">更新邮箱</button>';
echo '</form>';
// 验证中间件
function csrfMiddleware(): void {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST['csrf_token'] ?? '';
if (!Csrf::validateToken($token)) {
http_response_code(403);
die('CSRF验证失败');
}
}
}
// 在路由中调用
csrfMiddleware();
使用hash_equals进行时间安全的字符串比较,防止时序攻击。
4. 完整中间件集成
将三个防护整合到一个中间件类中。
// SecurityMiddleware.php
class SecurityMiddleware {
private PDO $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function handle(): void {
// SQL注入防护:通过PDO预处理,已在数据库层处理
// XSS防护:输出时转义
// CSRF防护:验证Token
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$this->validateCsrf();
}
// 设置安全头
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
}
private function validateCsrf(): void {
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!Csrf::validateToken($token)) {
http_response_code(403);
echo json_encode(['error' => 'CSRF验证失败']);
exit;
}
}
}
// 使用
$middleware = new SecurityMiddleware($pdo);
$middleware->handle();
5. 安全配置示例
Nginx配置增加安全头。
# nginx.conf
server {
listen 443 ssl;
server_name example.com;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy strict-origin-when-cross-origin;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'";
# ... 其他配置
}
效果数据
使用wrk进行压测,模拟100并发,持续30秒,测试登录接口。
| 场景 | 无防护 | 方案C(本文) | 性能损耗 |
|---|---|---|---|
| 平均延迟(ms) | 12.3 | 14.1 | +14.6% |
| RPS(请求/秒) | 1250 | 1050 | -16% |
| 错误率 | 0% | 0% | 0% |
安全防护引入约16%的性能损耗,但换来了三个漏洞的完全防护。在真实项目中,这个损耗可以接受。
安全测试结果:
- SQL注入:使用sqlmap测试,所有注入点被拦截,无漏洞。
- XSS:使用OWASP ZAP扫描,反射型XSS和存储型XSS均被拦截。
- CSRF:使用Burp Suite构造跨站请求,Token验证失败,请求被拒绝。
避坑指南
以下是我踩过的坑,每个都付出了代价。
坑1:PDO模拟预处理
默认PDO::ATTR_EMULATE_PREPARES为true,PDO在本地模拟预处理,仍可能被注入。必须显式设置为false。
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
坑2:htmlspecialchars编码问题
如果数据库连接未设置UTF-8,输出时转义可能产生乱码。确保数据库、页面、PHP文件全部使用UTF-8。
header('Content-Type: text/html; charset=utf-8');
坑3:CSRF Token泄露
Token通过GET参数传递时,可能被Referer泄露。始终使用POST或自定义Header传递Token。
// 使用Header传递
$_SERVER['HTTP_X_CSRF_TOKEN']
坑4:富文本XSS过滤不足
使用strip_tags过滤富文本会破坏内容,且无法处理<img onerror>等事件。必须使用HTML Purifier这类白名单工具。
坑5:CSRF Token过期未处理
用户长时间停留页面,Token过期后提交表单失败。解决方案:Token不设过期,或使用双Token机制(一个长期、一个短期)。
// 双Token示例
$_SESSION['csrf_token_long'] = bin2hex(random_bytes(32));
$_SESSION['csrf_token_short'] = bin2hex(random_bytes(32));
// 验证时检查两个Token
坑6:忽略文件上传安全
文件上传可能绕过MIME类型检查。必须验证文件内容,使用finfo函数。
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['file']['tmp_name']);
if (!in_array($mime, ['image/jpeg', 'image/png'])) {
die('不允许的文件类型');
}
总结
安全防护不是一次性工作,需要持续更新。本文方案已在生产环境运行6个月,未发生安全事件。记住:永远不要信任用户输入,永远使用参数化查询,永远转义输出,永远验证来源。
<<>>