前两天有个做企业官网的朋友找我,说他的站被人报了一个问题:页面里有个用户评论区域,攻击者提交一条带 <script> 的留言,前台一显示就弹窗。他用的是一套自己写的 PHP 后台,评论内容直接 echo 进 HTML。他问我:「我加个 htmlspecialchars 是不是就完了?」
回答这个问题之前,得先把一个常见的混淆点拆开:这个站同时存在两类安全问题,但它们不是一回事。一类是浏览器信任了页面里的内容(XSS),另一类是服务器信任了浏览器发来的请求(CSRF)。前者是「别人往你的页面里塞代码」,后者是「别人借用户的浏览器,用用户的身份给你发请求」。输入过滤对这两者都防不住,因为它们的防线位置完全不同。
这篇按「一个真实站点从排查到加固」的顺序讲:先分清两类攻击,再讲输出编码的具体做法与常见误用,然后是 token 校验的落地细节,最后给出验证方法和几条容易踩歪的边界。
一、XSS:三种形态,修法只有一条
XSS 的教科书分类是存储型、反射型、DOM 型。很多人背得出名词但不会用,其实这三种分类的价值在于告诉你漏洞在哪一层:
存储型是最常见也最危险的。用户提交的内容存进数据库,之后被其他用户看到时执行。留言、昵称、个人签名、商品评价、上传文件的文件名,都是高发点。它的特点是「一次注入,影响所有访问这个页面的人」。
反射型是攻击者构造一个带 payload 的 URL,诱导受害者点击,参数被服务端原样输出到页面。比如 search.php?q=<script>...,页面显示「您搜索的是 <script>...」。它需要诱导点击,单次影响一个人,危害低于存储型,但仍然是实打实的漏洞。
DOM 型比较特殊:服务端完全看不见这个漏洞。前端 JavaScript 从 location.hash、location.search 或者 document.referrer 取值,然后塞进 innerHTML。整个过程里,服务端返回的 HTML 是干净的,你的 PHP 里 htmlspecialchars 写得再对也没用。这种漏洞只有在审计前端代码时才能发现。
但无论哪种形态,正确的修法只有一条:在输出到 HTML 的那一刻,按上下文做编码。不是在入库的时候转义,不是在全局做一个 clean() 函数,也不是靠正则删 <script> 标签。
二、输出编码:按上下文选函数
「上下文」指的是这段数据最终落在 HTML 的什么位置。不同位置需要的编码方式不同,用错了轻则显示乱码,重则等于没编。
落在 HTML 文本节点里(<p>这里是数据</p>),用 HTML 实体编码。PHP 里是 htmlspecialchars,但有三个参数不能省:
htmlspecialchars($str, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')ENT_QUOTES 同时转义单引号和双引号。如果不写,默认只转双引号,那么在 <a title='...'> 这种用单引号包裹的属性里,攻击者可以用单引号闭合属性然后注入新属性(比如 onmouseover)。这是一个非常容易被忽略的细节。
ENT_SUBSTITUTE 处理非法 UTF-8 序列。默认情况下,如果输入里有非法字节,htmlspecialchars 会返回空字符串,整个字段直接消失——这看着像「防御成功」,实际上让用户数据丢了,而且在某些拼接场景下可能生成截断的 HTML。ENT_SUBSTITUTE 会用 U+FFFD 替换非法字符,保证输出永远完整。
第三个参数显式指定 UTF-8。不要省略,也不要依赖 default_charset。早年有个经典绕过就是利用字符集不一致做的宽字节注入,显式写死能少一类隐患。
落在 JavaScript 里,就不能用 HTML 实体编码了,因为浏览器在解析 <script> 内部时不会解码 HTML 实体——你把 < 变成 <,JS 字符串里拿到的就是字面的 <,语义完全变了。正确做法是:
var data = <?php echo json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>;这四个 flag 分别把 <、&、'、" 转成 \u003C 这种 Unicode 转义。为什么要这样做?因为 JSON 里如果直接出现 </script> 这个字符串,浏览器在解析 HTML 时会在 </script> 处提前闭合脚本块,后面的内容就变成了 HTML,直接造成注入。这四个 flag 是防这个的具体手段,不是可选项。
落在 URL 参数里,用 rawurlencode。注意是 rawurlencode 不是 urlencode——后者会把空格编成 +,在 query string 里没问题,但在 path 部分语义不同。
落在 CSS、注释、style 属性里,最安全的做法是不要往里放用户数据。如果业务上非放不可(比如用户自定义主题色),必须严格校验格式(比如只允许 #[0-9a-fA-F]{6}),而不是做转义。CSS 的转义规则很复杂,很难写对。
三、为什么不能做一个全局 clean() 函数
很多老项目里会有一个 clean($str) 函数,在入口处对所有输入跑一遍,比如 strip_tags 加上一堆正则替换。这种做法有三个问题。
第一,它会破坏数据本身。用户在文章里写「a < b」,这个函数可能把它删掉或者转义成别的东西,存进库里就已经不是原文了。之后导出 CSV、发邮件通知、给 API 返回 JSON,拿到的都是错的。
第二,入库时转义会导致显示层的问题。你在入库时把 & 转成 &,模板输出时再转一次,用户看到的就是 & 这串奇怪的东西。这时候开发者往往会去模板里加 html_entity_decode 想修好,一解码等于把防线拆了。
第三,同一个数据在不同上下文需要的编码不同。文章标题既要显示在 HTML 里,又要放进 <meta content="..."> 里,还要塞进 JSON-LD 里。存一份「已经编码好的」数据,在这三个位置里至少有两个是错的。
正确原则是:数据库里存原始数据,输出到哪一层就在哪一层编码。这个原则听起来麻烦,但它是唯一能保证「显示正确」和「安全」同时成立的做法。实际落地时用模板引擎的自动转义(Twig 的 {{ }} 默认转义、Blade 的 {{ }} 也是),或者自己封一个 e($str) 函数,在模板里强制全用它,代码审查时看到裸 echo $var 就打回。
四、富文本怎么办:白名单解析,别用正则
评论区可以纯文本,但文章正文需要富文本(加粗、链接、图片)。这时候不能整体转义,得允许一部分 HTML 标签通过。做法是解析成 DOM,按白名单保留标签和属性,再重新序列化。
PHP 里成熟的选择是 HTMLPurifier:
require_once '/path/to/HTMLPurifier.auto.php';
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,b,strong,i,em,a[href|title],img[src|alt],ul,ol,li,br,blockquote,code,pre');
$config->set('URI.AllowedSchemes', ['http' => true, 'https' => true]);
$config->set('Cache.DefinitionImpl', null);
$purifier = new HTMLPurifier($config);
$safe = $purifier->purify($dirty);这里有一行特别关键:URI.AllowedSchemes。如果不设,即使你允许了 a 标签的 href 属性,攻击者依然可以写 <a href="javascript:alert(1)">,点击就执行。这属于「协议型 XSS」,标签白名单拦不住,必须限制协议。同时也要挡掉 data: 和 vbscript:,前者可以用来构造 data:text/html;base64,... 的页面。
Cache.DefinitionImpl 设为 null 是为了避免 HTMLPurifier 往磁盘写缓存目录,那个目录路径在报错时可能泄漏服务器信息。
绝对不要用正则去删 <script> 标签。原因有两条:一是畸形标签很难被正则覆盖,比如 <scr<script>ipt> 这种嵌套写法,某些老浏览器会把它解析成 script;二是带换行、带注释的属性写法(<img src=x onerror=...> 的各种变形)浏览器都能解析,但你的正则未必匹配。正则做 HTML 过滤是一个无底洞,别跳进去。
五、CSRF:攻击的是「服务器对浏览器的信任」
再回到开头那个站。即使所有输出都编码好了,仍然有另一个问题。用户登录后有一个 /api/delete_post.php?id=123 的接口,只校验了 session,任何已登录用户的浏览器发请求都会被执行。攻击者在自己的站上放一个自动提交的表单:
<form action="https://victim.com/api/delete_post.php?id=123" method="POST" id="f"></form>
<script>document.getElementById('f').submit();</script>用户只要访问攻击者的页面,浏览器就会带着用户的 session cookie 向 victim.com 发 POST,文章就被删了。这就是 CSRF。防它的办法只有一个方向:让请求带一个攻击者猜不到的凭证。
标准做法是 CSRF token。登录时生成一个随机串存进 session:
$_SESSION['csrf'] = bin2hex(random_bytes(32));random_bytes 是密码学安全的随机源。不要用 rand()、mt_rand()、uniqid()——前两个是可预测的伪随机,第三个基于微秒时间戳,都能被推算出来。这个细节很多老教程写错了,照抄就是一个假防线。
表单里带上隐藏字段,提交时用 hash_equals 比对:
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit('Invalid request');
}为什么用 hash_equals 而不是 ==?因为 == 是逐字节比较,遇到第一个不同就返回,比较耗时会随「猜对的前缀长度」变化。理论上攻击者可以通过大量测量响应时间一点点猜出 token(时序攻击)。hash_equals 做的是恒定时间比较,不受内容影响。实际利用门槛不低,但改一个函数名的成本是零,没有理由不写对。
第二层防线是 Cookie 的 SameSite 属性。设置成 Lax,浏览器在跨站 POST 请求时不会带上这个 cookie,绝大多数 CSRF 攻击直接就废了。
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'httponly' => true,
'secure' => true,
'samesite' => 'Lax',
]);
session_start();注意顺序:这段必须在 session_start() 之前调用。写在后面是完全无效的——session 已经启动了,cookie 早已发出,参数改不了。而且它不会报错,你会以为配上了,实际一点没生效。这个坑我踩过,排查了很久。
为什么不设成 Strict?因为 Strict 在「从外部链接点进你的站」时也不发 cookie,用户会看到自己是未登录状态,需要再刷新一次或者点一次才算登录。对体验有影响。Lax 是平衡点:跨站 POST 不带,站内导航和顶级 GET 跳转带。这也是 Chrome 现在的默认值,但显式写出来更稳妥,因为老浏览器和某些框架会覆盖默认值。
如果站点有对外 API 供第三方调用,CSRF token 就不适用了(第三方拿不到用户的 session token)。这时候需要单独的 API 鉴权机制(签名、OAuth),并且这些接口必须和浏览器会话鉴权彻底分开,不能共用 cookie。第三方的回调接口(支付通知、消息推送)更是必须验签——一个不带签名验证的回调地址,等于一个公开的写接口。
六、统一收口比处处修更重要
上面讲的方法如果散落在几十个文件里,迟早会漏。真正有效的做法是在框架层面收口:
路由分发的地方统一做 CSRF 校验,只对 POST/PUT/DELETE/PATCH 生效,GET 跳过(GET 本就不该有副作用)。AJAX 请求支持从 X-CSRF-Token 头读,前端统一在请求拦截器里塞进去。第三方回调路径走白名单,但白名单里的路径必须自己验签,不能因为「在名单里」就跳过。
登录、登出、修改密码这几个动作要重新生成 session id 和 token。否则攻击者可以在用户登录前先给用户种一个 session id(session fixation),用户登录后这个 id 仍然有效,攻击者就拿到了登录态。
输出编码也是一样,收口在模板层。写一个新函数只在模板里用,代码审查时把「模板里出现裸变量输出」作为一条明确的规则。
七、怎么验证防线是真的
光看代码看不出问题,必须实际打。
对 XSS:准备一组 payload,分别提交到评论、昵称、签名、搜索框这几个入口,然后看页面源码,不看渲染后的页面。在源码里找 <script>(被正确编码的样子)和 <script>(说明真的注入进去了)。DOM 型漏洞要单独测:在 URL 的 # 后面拼 payload,看页面有没有反应。
对 CSRF:必须用浏览器测,不能用 curl。curl 不带浏览器自动附加的 cookie,也复现不了跨站 POST 的场景。正确做法是在本地建一个 file:// 的 HTML 页面,里面放自动提交的表单,指向你的站点,用浏览器打开看请求是否被 403 拒绝。
再补一条检查:把 SameSite 设上之后,从搜索引擎点进来的体验如何。如果站点在 iframe 里被嵌入(比如某些统计工具、预览功能),Lax 会导致 iframe 内的请求不带 cookie,表现为「嵌入的页面显示未登录」。这种情况要么改用 SameSite=None; Secure,要么把功能改成不依赖 cookie。
最后说三条边界,避免加固过程走偏:
htmlspecialchars 不是万能钥匙,它只适用于 HTML 文本节点和属性值。JSON 输出、JS 字符串、URL、CSS 各自有各自的处理方式。
CSP(内容安全策略)是纵深防御,不是替代方案。它可以拦住一部分漏网的注入,但正确姿势是先修好编码,再加 CSP,并且从 Content-Security-Policy-Report-Only 起步——直接上强制模式很容易把站点的某个第三方脚本、内联样式搞挂,而且报错在用户浏览器里,你第一时间看不到。
token 不防越权。有一种漏洞叫 IDOR(不安全直接对象引用):用户 A 改一下 id 就能删用户 B 的文章。CSRF token 校验通过了,但对象归属没校验,照样出事。所以每一个涉及具体资源的操作,都要单独确认「这个资源属于当前用户」。
写到最后想起一句话:安全加固的重点不在「每一处都修好」,而在「所有入口都从同一个地方经过」。一处修好,其他地方漏掉,等于没修。把编码收口到模板、把 token 收口到路由分发,这两件事做了,剩下的就是细节校准。