搞Web开发这么多年,要说哪一类漏洞最容易让人栽跟头,XSS(跨站脚本)绝对排得上前三。它不是那种需要高深利用技巧才能打通的漏洞,反而经常因为一行没留神的代码、一个忘掉的编码函数就出现了。更麻烦的是,XSS的攻击结果不是服务器宕机那种“响”,而是悄无声息地把用户数据拿走,受害者自己完全没感觉。今天我就把这几年的踩坑经验、修复思路和测试方法整理一遍,聊透XSS的攻击原理、三大类型、实战防御以及问题排查,希望对正在写前端、维护旧系统、或者刚接触安全测试的朋友有点帮助。
1. XSS攻击到底是什么,核心问题出在哪
1.1 一句话理解XSS:让浏览器替你执行恶意脚本
XSS的全称是Cross-Site Scripting,中文叫跨站脚本攻击。名字里带“跨站”,核心思想却非常朴素:攻击者把一段恶意脚本,或者说一段包含了恶意脚本的数据,想尽办法塞进目标网站页面里,然后让正常访客的浏览器把它当作页面的合法脚本执行掉。
这里有个关键认知要先纠正一下。很多新手觉得XSS是“网站被攻击了”,听起来像服务器被黑。实际上大多数XSS攻击中,服务器没被攻破,网站的漏洞只是允许了不可信的数据进入了页面输出。真正的受害者是访问这个页面的用户。攻击者的脚本在用户的浏览器里运行,能拿到用户在站点上的Cookie、登录态、页面内容,甚至代替用户发起交易、发消息、改资料。用个生活化的类比:好比有人往餐厅的菜里掺了东西,你吃下去之后,钱包被顺走了,餐厅本身倒没什么事。
浏览器为什么会老老实实执行攻击者的脚本?因为浏览器对“页面里的脚本”天然是信任的。一个页面里出现<script>alert(1)</script>,浏览器不会问这段代码是你自己写的还是混进来的,它只看执行结果。XSS的根源就在这里——开发者没能明确告诉浏览器:哪些内容是可信任的页面代码,哪些内容只是需要显示的数据。
1.2 从浏览器视角看XSS的触发链条
要理解XSS怎么发生,得站在浏览器的渲染机制上去想。浏览器拿到服务器返回的HTML时,会解析标签、属性、文本节点。如果服务端在处理用户输入的时候没做过滤或编码,直接把用户输入原样拼进HTML里,攻击者就可以通过输入构造出“新的标签”或“新的事件属性”,让原本只是字符串的内容变成可执行的代码。
举个最简单的例子,一个搜索页面,处理逻辑大概是:
$keyword = $_GET['keyword']; echo "<p>你搜索的是:$keyword</p>";用户正常输入“手机”没问题。但如果在浏览器地址栏里输入:
/search.php?keyword=<script>alert('x')</script>服务端会把这段内容原样输进<p>标签里,浏览器解析后,就多了一个<script>标签。此时,页面加载出的<script>就会被执行。这就是最原始的反射型XSS。
问题往往不是“有没有漏洞”,而是“哪个入口没管住”。URL参数、表单提交、JSON接口返回值、富文本内容、上传文件的文件名、搜索引擎关键词回显,这些全是XSS的高发地带。只要数据流经过了“用户输入 → 后端处理 → 页面输出”这条路,而输出时没有做对应的安全处理,就有可能出现。
1.3 为什么XSS被归为“高危害”漏洞
说XSS危害高,并不只是因为它能弹个框(Alert式验证)。真正的风险在于,攻击者可以借助XSS做很多越权的事情:
- 窃取会话Cookie,然后冒充用户登录,这在没有HttpOnly保护的传统Cookie体系下非常常见。
- 读取页面源码、 localStorage、sessionStorage里的敏感信息。
- 劫持用户在页面上的操作,比如篡改转账页面、伪造支付提示。
- 配合CSRF或者钓鱼页面,形成更复杂的攻击链。
- 向内网发起请求探测,因为浏览器会带上用户在当前域下的身份。
在漏洞定级里,能直接影响用户数据、账号安全的XSS一般会被评为“高危”。如果目标系统是后台管理系统、电商、金融类应用,存储型XSS(能把恶意脚本永久保存在服务器上)很有可能直接升级为“严重”。
2. 三种XSS类型拆解,攻击链路逐段梳理
XSS虽然机制都类似,但根据恶意脚本的注入和持久化方式,业内通常把它分成三类:反射型、存储型、DOM型。搞懂这三者的区别,才知道在做防御和排查时各自该盯哪里。
2.1 反射型XSS:攻击脚本“过一手”就消失
反射型XSS的特征是:恶意脚本作为请求的一部分发送给服务端,服务端把脚本拼入页面返回,浏览器执行一次。脚本不会持久化存储,只在攻击者精心构造的链接被受害者打开时生效。一旦受害者关闭页面,脚本也就无从再触发。
反射型攻击的典型场景就是站内搜索、错误提示、或者在登录页面里展示“用户名输入有误”这类反馈。通常攻击者会把恶意链接缩短处理,或者伪装成正常连接发给目标(比如“你有一份文件待查收 http://victim.com/search.php?kw=%3Cscript%3E...”)。如果目标点击了这个链接,在返回的页面里,脚本就运行了。
防御反射型的关键就是“输出编码”,见后面的防御章节。只要在将用户输入回显到HTML时进行正确的转义,反射型就基本能被堵死。
2.2 存储型XSS:危险程度更高,污染源头在数据库
存储型XSS指的是攻击者把恶意脚本提交到服务端,并被持久化保存,比如写入数据库、文件、缓存。之后任何用户访问包含了这段数据的页面,脚本都会被加载执行。论坛发帖、商品评论、用户资料昵称栏、留言板,全是存储型XSS最爱的地方。
存储型的攻击场景往往是:攻击者在评论框里提交一段包含<script>的内容,管理员在后台审核时点开评论列表,脚本直接在管理员的浏览器里执行。如果管理员权限高,攻击者就能拿到后台的管理会话,整个后台沦陷。
我之前排查过一个模拟项目X:某内部系统的用户昵称字段,昵称到处都会展示。攻击者在昵称里塞了<img src=x onerror=alert(document.cookie)>,管理员只要一打开用户列表页面,就去请求一个不存在的图片,触发onerror事件执行脚本。这个脚本虽然是“反射”性质的展示,但问题源头在存储——所有人都能触发,所以危害远大于反射型。后续我们把所有展示点位都统一走了前端编码函数,并且对提交入参做了白名单校验,才彻底处理掉。
防御存储型XSS比反射型多了一层工作:数据入库前要校验,数据展示时也要编码。双保险必须同时做,缺一个都会漏。
2.3 DOM型XSS:纯前端出问题,服务端完全不知情
DOM型XSS和前两种有个本质区别——它的攻击过程可以完全不经过服务器。恶意脚本通过改变页面DOM结构来执行,漏洞形成在前端的JavaScript逻辑里。比如代码里直接用了innerHTML去渲染一段从URL参数、location.hash里取到的内容,而这段内容没有做任何处理。
比较常见的DOM型XSS写法有:
var params = new URLSearchParams(window.location.search); var content = params.get('content'); document.getElementById('box').innerHTML = content; var hash = location.hash; document.body.innerHTML = "<div>" + hash + "</div>"; document.write(location.href);这些代码把外部输入直接当HTML来解析,实际上等于告诉攻击者“你可以往页面里塞任何标签”。DOM型XSS的特点是比较隐蔽,因为服务端返回的HTML里看不到恶意payload,它是在用户本地浏览器动态插入的。常规的WAF(Web应用防火墙)往往拦不到这种攻击,因为攻击payload可能根本不会出现在HTTP请求的响应里。
排查DOM型XSS要重点检查所有操作innerHTML、outerHTML、document.write()、insertAdjacentHTML()、eval()、setTimeout的第一个参数、location相关的赋值等位置。如果这些位置的输入来源不可控,就必须做编码或者改用textContent。
2.4 三种XSS横向对比
| 类型 | 脚本存放位置 | 触发方式 | 是否需要服务端参与 | 危险程度评估 |
|---|---|---|---|---|
| 反射型 | 请求参数/URL中 | 用户点击恶意链接 | 需要(服务端拼接返回) | 中 |
| 存储型 | 数据库/服务端存储 | 普通用户访问对应页面 | 需要(服务端存储和输出) | 高 |
| DOM型 | 浏览器本地DOM操作 | 页面运行时被注入 | 不需要参与也能发生 | 中-高 |
做安全测试的时候,见到任何一处输入点都要去思考一下:这个输入会不会拼接到HTML里?拼进去后,外层的上下文是什么?只有弄清楚上下文,才知道用什么编码策略才是对的。
3. 攻击原理深入:为什么浏览器会“配合”
3.1 浏览器对HTML解析的信任模型
理解XSS的本质,核心要理解浏览器对来自服务器的内容是无条件信任的。用户通过URL传过去的数据,经过服务端拼接后返回,浏览器看到的是一堆HTML字符串。它不会去区分“这行字符串是开发者写的模板”还是“这是用户输入插入进来的”。一切按标签结构来解析。
举个例子,假设模板是这样的:
<span><?php echo $username; ?></span>攻击者把username传成:
</span><script>steal()</script><span>那么浏览器拼接后的HTML就变成了:
<span></span><script>steal()</script><span></span>看到了吗?攻击者利用了一个闭合标签的技巧,把自己包装成合法的HTML结构。</span>把原来的标签闭合,<script>随之生效。最终页面多了个执行脚本。这种技巧在真实攻击里非常常见,比单纯输入<script>要实用得多,因为很多过滤逻辑只盯着<script>,忘了闭合标签和事件属性同样能致命。
3.2 事件属性与伪协议是绕过过滤的利器
很多时候开发者知道过滤<script>标签,但攻击者根本不需要<script>。HTML里能执行JavaScript的地方太多了:
<img src=x onerror=alert(1)>,加载图片失败时执行onerror事件。<a href="javascript:alert(1)">点我</a>,点击链接时执行伪协议。<svg onload=alert(1)>,SVG加载时执行。<details ontoggle=alert(1)>,打开details元素时执行。<video src=x onerror=alert(1)>,视频加载失败同样触发。<iframe src="javascript:alert(1)">,iframe加载伪协议。
这些都是一行就能弹窗的方式。如果还做了大小写/编码混淆,比如<SCRipt>、javascript:,过滤逻辑再弱一点,攻击面会更大。所以,“过滤某个具体标签”的方案极其脆弱。真正稳的方案,是不允许不可信内容变成HTML结构。
3.3 上下文决定攻击方式,也决定编码方式
XSS防御必须区分上下文,同一份数据放在不同位置,编码规则完全不同:
- 放在HTML标签内文本中:需要HTML实体编码,把
<转成<,>转成>,&转成&,"转成"等。 - 放在HTML标签的属性值中:需要对单双引号做编码。
- 放在JavaScript代码块里:需要对反斜杠、引号、换行符等做JS编码。
- 放在URL属性里:不能直接用用户输入,要对URL Scheme做白名单校验,否则
javascript:协议就钻了空子。
一个很常见的防御错误:前端只对<和>做了替换,但用户输入出现在带引号的属性值里时,攻击者输入一个" onmouseover="alert(1),就能直接注入新属性。所以“先确认数据出现在哪个上下文,再选择对应的编码规则”这句话,要刻在脑子里。
4. 防御与修复,分层布阵的四道防线
防御XSS不能指望某一行“魔法代码”,它是一套组合拳。业内共识的分层防护逻辑是:
- 输入校验(不要信任任何用户数据)。
- 输出编码(数据写到HTML不同位置时做对应的转义)。
- 浏览器内置防线(CSP、HttpOnly、SameSite Cookie)。
- 框架层/平台层的安全配置(模板引擎自动转义、CSP响应头、现代框架的安全插槽机制)。
4.1 输入校验:白名单永远比黑名单靠谱
输入校验唯一推荐的做法是“白名单校验”。明确这个字段允许什么格式,其他一律拒绝。昵称就允许中文、字母、数字和少量常见符号;邮箱就允许邮箱格式;URL就只允许http/https协议。白名单模式不会跟攻击者的各种编码技巧硬碰硬,因为不合规的输入直接拦在前面,根本到不了输出环节。
黑名单模式的问题在于,攻击者永远可以尝试新的绕过方式。今天你拦了<script>,明天他换<svg onload>,后天他用十六进制HTML实体<script>。除非你能穷举所有攻击载荷,否则黑名单迟早被绕过去。
在服务端做校验时,还需要统一对数据做归一化。比如某种字符串因为Unicode兼容性问题,看起来一样,但编码不同,如果不过归一化,攻击者可以用特殊编码绕过长度限制和格式判断。这里不展开太深,记住“数据校验要在服务端做,并且要考虑编码归一化”就够了。
4.2 输出编码:不同上下文用不同规则
输出编码是防御存储型和反射型XSS的最重要手段。前端模板引擎普遍自带HTML转义,很多框架在模板里默认启用了转义,比如某常用前端框架把数据绑定到插值表达式时,默认会转义HTML。这是默认安全的,但“v-html”这类放开HTML解析的接口一旦使用,安全责任就转移给了开发者。
后端也一样。在使用服务端模板引擎时,要么用自带转义,要么确保拼接时调用编码函数。以下是一个典型的服务端PHP拼接场景:
echo "<p>用户昵称:" . htmlspecialchars($user['nickname'], ENT_QUOTES, 'UTF-8') . "</p>";htmlspecialchars会把<、>、&、"、'转成对应的HTML实体,这样攻击者输入的<script>或者" onmouseover=都被当作纯文本显示,而不会变成HTML结构。务必注意第三个参数编码要指定UTF-8,否则中文环境下可能产生乱码或绕过风险。
属性上下文是另一个重灾区,假设这段代码:
echo "<input value='{$searchKey}'>";攻击者输入:
' onfocus='alert(1) autofocus=''拼出来的结果是:
<input value='' onfocus='alert(1) autofocus='''>表单里的输入框一聚焦就弹窗。如果用了ENT_QUOTES把单引号也转义,'变成',攻击者的输入就变成了无害的文本。
4.3 CSP与Cookie保护,让浏览器自己再拦一道
CSP(Content Security Policy,内容安全策略)是一种浏览器层面的安全机制。你可以通过响应头或<meta>标签告诉浏览器“这个页面只允许加载哪些来源的脚本、样式、图片”。CSP的作用是,即使攻击者往页面里注入了脚本,浏览器因为该脚本的来源不在白名单里,也会拒绝执行。
一个比较基础的CSP配置是:
Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' https://img.example.com; style-src 'self' 'unsafe-inline'上面配置的意思是:脚本只能从本站加载,图片只能从本站和指定图片域名加载,样式可以接受内联样式。这样攻击者即使把<script src="http://evil.com/x.js">注入页面,浏览器也会阻止这个外部脚本的加载。
不过要注意,一旦页面里用了内联事件处理器(比如onclick="xxx"),或者eval()这类动态执行,CSP会比较难配。现代开发推荐尽量不写内联脚本,把逻辑放在外部JS文件里,这样CSP可以配得更严格。
Cookie安全也同样重要。给敏感Cookie加上HttpOnly标记,浏览器收到带有HttpOnly属性的Cookie后,JavaScript将无法通过document.cookie读取到它。虽然攻击者可以借助XSS做其他事情,但Cookie被直接取走的这条路算断了。Cookie还建议加上Secure属性(只允许HTTPS环境发送),以及设置合理的SameSite属性来控制跨站请求时的携带策略。
4.4 现代框架的默认安全与“危险接口”清单
使用现代前端框架开发时,很多基础XSS问题已经被框架层解决了。例如某现代框架的模板插值{{ }}默认执行HTML转义;另一个流行框架用{{ }}绑定文本也是安全行为。但每个框架都留了几个“危险接口”给开发者用,一定要去框架文档里把这些危险接口记下来:
- innerHTML与v-html:直接把字符串当HTML解析,这是最需要警惕的。
- dangerouslySetInnerHTML:名字里就写着“危险”,它是某常用React框架里用来设置HTML内容的接口。
- 模板字符串直接拼接后插值,如果插值内容本身包含HTML,同样有风险。
- 在生成富文本内容时,必须用专门的富文本XSS过滤库做清洗。
框架只保护“默认用法”。一旦你调用了上面这类“放开HTML解析”的接口,框架会把安全责任完全交还给你,这时候就必须对数据来源做严格的信任判断。
5. 检测与排查,怎么从代码里找出潜在的XSS
5.1 代码审计的排查路径
拿到一个项目做XSS排查,我习惯按下面这条线走:
- 先看前端代码里所有
innerHTML、outerHTML、document.write()、insertAdjacentHTML()、eval()、new Function()这类动态执行入口。 - 再逆向追踪这些入口的数据来源,看它是来自
location、document.referrer、window.name、postMessage,还是来自服务端接口。 - 服务端接口返回的数据来源,如果是用户可控的(参数、Body、文件、数据库等),就要继续追到模板输出层。
- 查看模板里数据绑定是不是默认转义的,有没有人关闭了转义。
- 检查所有富文本编辑器的提交内容,有没有对应的白名单过滤和输出端清洗策略。
这样一条链追下来,大部分漏洞点都会浮出水面。
5.2 自动化工具辅助验证
代码审计虽然可靠,但大型项目光靠肉眼看很容易漏。工具的价值在于辅助,不能完全替代人工判断。
在本地搭建环境,或者用浏览器的开发者工具调试时,我常用的流程是:
- 在输入点输入一些简单payload,比如
<script>alert(1)</script>、<img src=x onerror=alert(1)>、"><svg onload=alert(1)>,看返回页面是否触发了执行。 - 观察浏览器开发者工具的“网络”面板,确认服务端返回的HTML里,payload是以什么形式出现的。
- 用浏览器自带的“检查器”查看最终渲染的DOM,确认输入是否被拼成了标签或属性。
- 对于模糊测试,可以在输入点批量提交各种变形payload,观察异常和显示差异。比如把
<编码成%3C,看服务端是否解码后再输出。
这里也提醒一句:在别人未授权的系统上做这类测试,在国内属于违法行为,务必只在本地或已授权的测试环境里操作。安全从业者的底线是授权。
5.3 从浏览器控制台快速验证思路
实际开发中可以开着浏览器控制台验证某个位置是否会被XSS。比如怀疑某段代码有漏洞,直接在控制台里输入:
document.body.innerHTML += '<img src=x onerror=alert(document.domain)>';如果弹出当前域名,说明该页面没做防护,类似输入出现在真实接口里时就会出问题。这种验证方式在调试自己代码时非常方便,既不要额外装工具,又能快速判断当前渲染上下文。
6. 踩坑实录:我在实战中遇到的4个常见问题
6.1 过滤了script标签,却被onerror轻松打穿
之前某团队很自信地认为已经把<script>关键字过滤掉了,结果别人提交了个<img src=x onerror=alert(1)>直接打穿。这个问题的本质就是黑名单思路的局限:攻击脚本不一定非要写在<script>标签里。事件属性、伪协议、SVG标签,都是替代方案。
修正方案:把“过滤标签”的思路彻底换掉,改成“在输出到HTML时对所有特殊字符做HTML实体编码”,同时配合白名单输入校验。编码之后,<img>会变成<img>,浏览器只会把它当成普通文本显示。这个思路执行到位后,大多数基础的XSS都会被堵死。
6.2 富文本场景的防御不能只靠输出编码
业务需求经常是:“用户要能发带格式的内容,所以提交内容必须是HTML”。这时候输出编码会把所有HTML标签转义掉,富文本就废了。正确做法是“白名单HTML净化”:允许的标签和属性白名单之外的内容一律剥离。比如允许<b>、<i>、<a>、<img>,但<a>的href只能允许http/https协议,<img>的src同样只允许http/https,不允许javascript:或者data:协议。所有事件属性(onerror、onclick、onload)一律禁止,<iframe>、<object>、<embed>、<style>这类标签也一律禁止。
在服务端接收富文本时,要用成熟的富文本过滤库做清洗。所有流程要记住一个原则:入库前清洗一遍,输出前再清洗一遍。即使前一道有效果,也不能省掉后一道,因为未来可能有其他入口把脏数据直接写入数据库。
6.3 编码函数用错上下文,依然被绕过
有团队用htmlspecialchars函数做防御,但没注意把用户输入放进了JavaScript代码块里。比如:
echo "<script>var name = '{$username}';</script>";这时username里如果包含';alert(1)//,闭合单引号后就能注入JS代码。htmlspecialchars转化的是HTML实体,但对JS上下文没用。攻击者在这里不需要考虑HTML上下文,字符串直接就被JS解释执行了。
正确方式是区分上下文。如果数据要进JavaScript里,用JS编码处理;如果数据要进HTML里,用HTML实体编码。如果要用一个函数兼顾多场景,那就得搭配更复杂的上下文感知编码策略。这也是为什么很多现代模板引擎里,数据进JS代码时还要额外做JSON编码。
6.4 前端校验被绕过,服务端没兜底
现在SPA应用很流行,前端会在表单提交前做校验,比如把输入里的<script>替换掉。问题是:前端校验只对通过浏览器正常操作的用户有效。攻击者不需要通过你的页面,他可以绕过前端直接向接口提交数据。
之前处理过一次:某系统在表单输入框做了校验,但服务端接口只做了必填和长度校验,没有做内容过滤。攻击者直接用调试工具把请求的body改掉,提交了带payload的内容。保存成功后,其他人一打开页面就中招。这提醒我们:前端校验是体验优化,服务端校验才是最后一道门。永远假设客户端发来的数据是不可信的,所有校验必须在服务端再做一遍。
7. 实操经验:一套简单可复用的XSS自测排查小清单
根据我自己的实践,整理了一份自查清单,你在自己负责的系统上做安全测试时可以直接参考:
- 找一张典型的列表页,对所有用户可控字段(URL参数、搜索关键字、分页参数)逐一输入
<img src=x onerror=alert(document.domain)>,观察页面是否弹出当前域。 - 找一个能保存数据的表单(比如评论、留言板、个人资料),提交
<script>alert(1)</script>,再以另一个账号打开展示页,看是否弹窗。 - 抓包修改请求体,绕过前端校验,提交构造的payload。
- 检查所有外部链接跳转的位置,尝试
javascript:alert(1)和data:text/html,...等协议。 - 用浏览器开发者工具的“请求头”看响应头里有没有
Content-Security-Policy,如果没有,需要评估风险是否可接受。 - 查Cookie里有没有
HttpOnly标记,运维登录态相关的Cookie如果没设置,要重点盯。
这六步走完,基础XSS漏洞基本都能暴露出来。遇到弹窗后,不要急着只把它当作一个“弹窗bug”,要顺着漏洞点去看它的数据源头和输出位置,这样才能确定是反射型、存储型还是DOM型,才能决定修复方案。
8. 结尾一点私人体会
做了这么多年的XSS排查和修复,我最深的感受其实是:XSS不只是一个“安全问题”,它更像一个“工程问题”。团队只要在开发规范里约定清楚哪些接口可控、哪些上下文必须编码、哪些框架功能是危险接口,XSS的存活空间就会被压得很小。最怕的是“今天补一个函数,明天因为赶需求又随手拼接一遍”。安全防御要在流程和文化里生根,光靠一两个安全工程师盯是远不够的。
最后分享一个小习惯:我在写完任何一段把用户输入拼到页面里的代码之后,都会先停下来问自己一句——如果这个输入是"><script>alert(1)</script>,我的代码会怎么样?多问这么一句,往往就能在测试工具发现之前,先把问题消灭在萌芽里。希望这篇整理对你排查和修复XSS有实际帮助。