PHP 安全开发指南:SQL注入 / XSS / CSRF / 文件上传漏洞防护
照着这篇做完,你能把 PHP 项目里最常见的四类漏洞——SQL 注入、XSS、CSRF、文件上传——用可落地的代码一次性堵住。
这四类漏洞占了 PHP 老项目安全问题的绝大多数,而且防护手段都不复杂,难的是每次都记得做。下面就按「先看怎么错、再看怎么改」的顺序一个个过。
第一步:SQL 注入——永远不要拼字符串
错误写法,看一眼就知道问题在哪:
$id = $_GET['id'];
$sql = "SELECT * FROM posts WHERE id = $id"; // 注入点
正确做法是用 PDO 预处理,把数据和语句彻底分开:
$stmt = $pdo->prepare('SELECT * FROM posts WHERE id = ?');
$stmt->execute([$_GET['id']]);
$row = $stmt->fetch();
两个容易忽略的细节:
- 关闭模拟预处理,否则预处理只是字符串转义的伪装。连接时加上
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); - LIKE 查询要自己转义通配符。用户输入的
%和_在 LIKE 里是元字符,得先addcslashes($kw, '%_\\')再拼进参数。 - 表名、字段名、ORDER BY 的字段无法参数化,这类只能用白名单:
$allow = ['id', 'created_at', 'views'];
$order = in_array($_GET['order'] ?? '', $allow, true) ? $_GET['order'] : 'id';
注意:
mysql_real_escape_string、addslashes这类转义函数不能替代预处理,字符集设错时照样被绕过。新项目直接用 PDO 或 mysqli 的预处理,别再考虑转义方案。
第二步:XSS——所有输出都要转义
XSS 的本质是「用户输入被当成 HTML 执行了」。核心原则一句话:入库不管,出库必转。
echo htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8');
ENT_QUOTES 会把单引号和双引号一起转义,这样放进 value="..." 这类属性里也安全。第三个参数 'UTF-8' 不能省,编码不匹配时中文可能被截断成半个字符,反而制造新问题。
不同上下文要求不同:
| 输出位置 | 处理方式 |
|---|---|
| HTML 正文/属性 | htmlspecialchars |
<script> 里的 JS 变量 | `json_encode($v, JSON_HEX_TAG\ |
| URL 参数 | urlencode() |
如果站点要支持富文本(发帖带格式),就不能整体转义,得走标签白名单过滤,只放行 p / br / strong / a / img / code 等,其余一律剥掉,并强制过滤 on* 事件属性和 javascript: 协议。
注意:过滤和转义是两件事,别只做其中一件。存入数据库时保留原文(方便编辑),渲染时再按上下文转义或过滤。
第三步:CSRF——表单必须带 token
CSRF 是「借你的登录态发请求」,所以防护靠的是一个攻击者猜不到的随机值。
生成(放在 session 里):
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
表单里带上:
<input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
提交时校验,用 hash_equals 做定时安全比较:
if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) {
exit('页面已过期,请刷新后重试');
}
再加一层 Cookie 属性:session_set_cookie_params(['samesite' => 'Lax', 'httponly' => true, 'secure' => true]);,让浏览器在跨站请求时就不带 Cookie。
注意:token 校验失败最常见的原因不是被攻击,而是页面停留太久或登录态变了。解决方法是在提交层做一次自动重试——拉一个新 token 再发一遍,同时后台把站点地址(含 https:// 和主域名)配对,避免 www 和裸域混用导致会话丢失。
第四步:文件上传——白名单 + 二次校验
上传是拿 shell 的重灾区,四道关卡缺一不可:
1. 扩展名白名单,不是黑名单:
$allow = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allow, true)) { exit('文件类型不允许'); }
2. 不信任 $_FILES['type'],那是客户端传来的,随手就能改。
3. 图片二次校验,确认它真的是图片而不是改名后的 PHP 文件:
if (!@getimagesize($_FILES['file']['tmp_name'])) { exit('不是有效图片'); }
4. 重命名 + 目录不可执行。用 bin2hex(random_bytes(8)) . '.' . $ext 做新文件名,去掉原始文件名里的一切可疑字符;同时确保上传目录没有 PHP 执行权限(Nginx 里对该目录 location 禁止 \.php$)。
注意:上传失败的排查顺序是——① 扩展名白名单是否包含该格式;② PHP 的
upload_max_filesize和post_max_size是否大于文件体积(面板默认常是 2M,手机照片轻松超过,建议调到 30M 以上);③ 上传目录是否可写。很多"上传失败"根本不是代码问题,而是这三处配置。
小结
- SQL:一律预处理,关掉模拟模式;表名字段名走白名单。
- XSS:输出即转义,按上下文选函数;富文本用标签白名单过滤。
- CSRF:session 存随机 token + 表单隐藏字段 +
hash_equals校验 + SameSite Cookie,并给用户留一条自动重试的路。 - 上传:扩展名白名单、忽略
type、getimagesize二次校验、随机重命名、目录禁执行。 - 密码用
password_hash()(bcrypt),别再用 md5/sha1;报错信息不要回显给用户,写日志就行。
四条规则都不长,把它们做成公共函数或封装层,比每次靠记性靠谱得多。
转载请注明出处,版权归原作者所有。
正式会员
认证极客






