如果把浏览器比作一扇窗,那XSS攻击就是有人在这扇窗的玻璃上提前刻了一道肉眼看不见的划痕,平时一切正常,一旦你伸手去推,这道划痕就会顺着你的力道裂开,最终让整扇窗形同虚设。XSS(跨站脚本攻击)的核心就是攻击者把恶意脚本塞进了本该由用户信任的页面里,然后借你的浏览器之手,去干偷Cookie、记键盘、伪造会话这些脏活。这篇文章我会站在防御者的视角,把XSS攻击从原理、分类、攻击链路到防御落地全部拆开来讲,适合前端工程师、运维安全岗、独立开发者以及所有想弄明白“为什么一个脚本就能把账号拿走”的人。
1. 内容整体设计与思路拆解
1.1 为什么XSS能突破浏览器的信任边界
浏览器本身有一套安全模型,其中最核心的就是同源策略。简单说,不同源(协议、域名、端口任一不同)的页面之间不能随意读取对方的Cookie、DOM或发起带身份凭证的请求。这套模型假设了一个前提:用户访问的页面代码是可信的。但XSS攻击恰恰是在这个假设上撕开了口子——攻击者不直接攻击浏览器,而是攻击页面本身的输入输出逻辑,把自己写的脚本注入到页面中。一旦注入成功,恶意脚本就和正常脚本跑在同一个源下,同源策略这堵墙等于被从内部拆掉了。
理解这层关系很重要,因为很多人在配置Web应用防火墙或者做代码审计时,总把XSS当成一个“单纯的输入过滤问题”,这是不够的。XSS的本质是“不可信数据跨越了代码与数据之间的边界”,所以防御的核心不只是过滤,而是要让代码在每一次输出时都明确区分“这是数据”还是“这是代码”。这也是我在后面会反复强调输出编码比输入过滤更关键的原因。
1.2 恶意脚本在用户浏览器内能做什么
很多人以为XSS的危害就是弹个框、改个页面,那是十年前的理解了。脚本一旦在受害者的浏览器上下文中执行,等于攻击者获得了一个“同源特权代理”。它可以做以下几类事情,每一件都足以造成账号级别的损失:
- 读取并外传Cookie,直接造成会话劫持,攻击者不需要密码就能以受害者身份登录。
- 监听键盘事件,记录用户在页面上的每一次输入,包括密码、验证码、身份证号、银行卡号。
- 读取或篡改页面DOM,在登录页中插入伪造的表单,实时骗取用户提交的信息。
- 以用户身份发起跨源请求,利用已登录的会话去修改密码、转账或发布内容。
- 静默下载恶意文件,或者扫描内网地址,把浏览器变成跳板。
这其中的“读取Cookie”和“键盘记录”是两种最典型的利用方式,也是这篇文章标题里点到的两个关键词。很多人看到标题会问:Cookie不是有HttpOnly保护吗?为什么还能被拿走?键盘记录不是需要安装恶意软件吗?为什么一个网页脚本就能做到?这些疑问会在后文逐一展开。
1.3 这篇文章的攻防视角定位
关于XSS的文章很多,但大多数陷在两个极端:要么纯理论讲解,读完仍然不知道怎么防御;要么直接给出可利用的payload,这在合规和道德上都有问题。我的定位是中间路线——讲清攻击者为什么能成功,但不提供可直接复制的完整攻击代码;重点放在攻击链路的原理解析和防御体系的构建上。我会把攻击链路拆成“注入点分析、脚本执行、数据外传、会话利用”四个环节,并在每个环节对照说明防御方应该在哪里设卡。这样读者既能理解威胁,又能知道怎么应对,而不会被误导去拿这些技术做不该做的事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:XSS的分类、触发与危害机制
2.1 反射型XSS:一次性执行的“即骗即走”
反射型XSS也叫非持久型XSS,恶意脚本藏在一个URL参数里,服务端接收参数后未经安全处理就拼接到返回的HTML中。攻击者把这个恶意URL发给受害者,受害者点击后浏览器直接渲染出包含恶意脚本的页面。脚本执行一次,只影响点击链接的这一个用户。
举个具体的场景:一个搜索页面把用户输入的关键词直接回显到“您搜索的关键词是:xxx”的位置。攻击者构造一个URL,其中的关键词参数不是正常文字,而是一段带有事件属性的HTML片段。用户点击后,服务端把这个片段原样返回,浏览器解析HTML时触发了事件,脚本就执行了。
反射型XSS往往被低估,因为它的执行需要受害者主动点击链接。但攻击者通常会借助短链接、二维码、社区帖子等手段隐藏恶意URL,再配合社会工程学话术,比如“你的账号在异地登录,点击链接立即复查”,欺骗性很强。在实际攻击中,反射型XSS常被用来配合CSRF或钓鱼页面完成组合攻击。
2.2 存储型XSS:打到服务器上的“定时炸弹”
存储型XSS是危害最大的一类,也叫持久型XSS。恶意脚本先通过某个数据入口(评论区、昵称、个人简介、订单备注、工单标题等)提交到服务端数据库,服务端后续在页面中输出这段数据时没有做安全处理,导致所有访问该页面的用户都会执行这段脚本。
存储型XSS之所以可怕,是因为它不需要诱导受害者点击特定链接,只要受害者访问正常的业务页面就会中招。一个典型的例子是:攻击者把带有恶意脚本的昵称写入用户表,管理员在后台打开用户列表时,脚本在管理员的浏览器中执行,攻击者拿到了管理员会话,整个后台就失守了。很多数据泄露事件的最初入口就是这么一个小小、未过滤的昵称字段。
存储型XSS的检测比反射型更难,因为触发页面和注入入口是分离的。代码审计时需要追踪数据流:用户输入进入数据库后,有哪些页面会输出它?输出时有没有经过编码处理?这个追踪过程如果靠人工做会很累,所以在后面我会提到用自动化工具辅助审查。
2.3 DOM型XSS:不出网络的“前端内鬼”
DOM型XSS和前两类有个本质区别:脚本不经过服务端,数据不发送到服务器再返回,而是纯前端在处理。JavaScript代码读取URL中的参数或location.hash中的内容,然后通过innerHTML、document.write、eval等危险API把内容写入页面。攻击者构造的恶意代码在用户点击链接的那一刻,直接在浏览器DOM环境中执行,服务端毫不知情。
DOM型XSS最麻烦的地方在于:服务端返回的HTML本身就是干净的,WAF和服务端过滤都管不到它,传统的服务端检测手段几乎失效。比如一个页面通过location.hash实现前端路由,把hash值拼接到某个元素中,攻击者把恶意内容放在hash里,页面加载后脚本就执行了。
对于前端工程师来说,DOM型XSS是必须重点警惕的一类漏洞。使用.textContent而不是.innerHTML、使用text替代html、避免把不可信数据传给eval和new Function,这些基本习惯能挡掉大多数DOM型XSS。
2.4 三类XSS的关键差异对比
为了帮助理解,我把三类XSS做成了对比表:
| 维度 | 反射型 | 存储型 | DOM型 |
|---|---|---|---|
| 恶意代码存储位置 | URL中 | 服务端数据库 | 前端运行时 |
| 是否经过服务端 | 是 | 是 | 否 |
| 触发条件 | 受害者点击恶意链接 | 访问被注入数据的页面 | 访问含特定参数/hash的URL |
| 影响范围 | 单个用户 | 所有访问页面的人 | 单个用户 |
| 检测难度 | 中 | 中 | 高 |
| 典型案例 | 搜索框关键词回显 | 评论区昵称、个人签名 | 前端路由中的hash参数 |
从这张表可以看出,无论是哪种类型,最终的危害都落在“脚本在用户浏览器内执行”这一个共同点上。理解了这个共同点,就可以引出下面攻击链路的拆解了。
3. 攻击链路深度剖析:从注入到数据出站
3.1 攻击者如何定位注入点
在实际攻击中,攻击者不是随手试一串标签就能成功的。第一步是收集目标应用的参数接收点和输出点。常见的参数接收点包括URL查询参数、POST表单字段、请求头(Referer、User-Agent、X-Forwarded-For等)、Cookie值、文件上传时的文件名,以及WebSocket消息中的内容。输出点则包括HTML标签内部的文本内容、标签的属性值、script标签内部的变量赋值、style内的CSS表达式、事件处理属性中等。
每种输出位置的绕过难度不一样。比如输出位置在HTML标签文本中,用尖括号就能完成;但如果输出位置在标签属性值内,直接使尖括号可能无效,这时可以尝试提前闭合属性并引入新属性,比如通过注入onmouseover这样的时间事件属性。如果输出位置在JavaScript变量赋值中,则需要闭合引号,用引号和分号拼接出自己的代码。
这里我强调一点:攻击者定位注入点,本质上是在寻找“代码与数据边界”的漏洞,而防御者的核心工作就是把这个边界封死。边界封得越好,攻击者需要尝试的绕过姿势就越多,被WAF或日志系统发现的概率就越大。
3.2 Cookie是怎么被“拿走”的
Cookie本身并不神秘,它就是浏览器保存的一组键值对,每次向站点发起请求时会自动带上。攻击者要“拿走”Cookie,最直接的办法是让页面上的脚本执行document.cookie,读出当前域名的所有Cookie值,然后发送到自己的服务器。
这里有一个关键限制:document.cookie只能读取没有设置HttpOnly属性的Cookie。所以我就见过一种典型误区:有人认为设置了HttpOnly就万事大吉,但忽略了Session ID以外的其他敏感数据。如果一个Cookie里存了用户名、手机号、权限标志位这些信息,而且没设置HttpOnly,XSS脚本一样把它读走。但话说回来,HttpOnly确实能挡住攻击链中最关键的一环——会话令牌的窃取。会话令牌一旦被偷,攻击者可以直接把Cookie重放到自己的浏览器中,完成会话劫持。
攻击者拿到Cookie后,通常通过两种方式外传:一种是在脚本中直接创建一个图片元素,把数据拼接到图片的URL里,发送到攻击者控制的服务器;另一种是用fetch或XMLHttpRequest发起跨域请求,前提是目标服务器允许跨域。第二种方式一般受限,所以第一种更常见。这种外传行为通常看起来像一次普通的图片请求,流量日志上不容易引起注意。
3.3 键盘记录器在浏览器里是怎么实现的
很多非安全从业者听到“网页键盘记录”会觉得不可思议,键盘输入不是系统层面的东西吗?网页脚本怎么能截获?其实原理非常简单:恶意脚本在页面中注册一个事件监听器,监听keydown或keypress事件。每当用户在页面上按下键盘,事件对象里就携带了按键对应的字符信息,监听器把它收集起来,再定期发送到攻击者的服务器。
这种记录方式完全不需要任何终端权限,也不需要安装恶意软件,因为事件监听本来就是浏览器提供给开发者的合法能力,而恶意脚本只是借用了这个能力去做非法的事。在实际攻击中,攻击者甚至会优先监听那些不加载敏感内容但仍有输入发生的页面,比如支付页面的前置步骤、帮助中心的搜索框等。因为用户在这些页面上往往更放松,输入密码或账号时不会刻意观察地址栏。
作为防御方,普通网页无法直接阻止JavaScript注册键盘监听器,这也是为什么浏览器安全模型会配合CSRF Token、CSP、可信类型等手段来降低页面被注入脚本后的损失。杀毒软件很难识别这种发生在页面内部的键盘记录,因为它不是系统级钩子,只是一条普通的事件监听逻辑。
3.4 攻击链路的完整还原:从注入到会话劫持
我把一条完整的攻击链路拆成五个阶段,方便读者对照理解:
- 注入:攻击者通过搜索框、评论、URL参数等位置注入恶意脚本,脚本内容是一个引导加载外部脚本的短小片段。
- 执行:受害者的浏览器加载并执行了注入的代码,页面原始功能看起来一切正常。
- 收集:恶意代码读取document.cookie,同时注册键盘事件监听器,开始收集输入信息。
- 外传:通过图片请求或fetch调用,把收集到的数据发送到攻击者控制的服务器。这一步在流量日志中很难和正常请求区分。
- 利用:攻击者拿到Cookie后重放会话,或者用记录下来的账号密码直接登录;如果拿到的是管理员Cookie,相当于直接获得了后台控制权。
防御方可以在链路中任何一个环节设卡。比如HttpOnly卡住第3步的Cookie读取,CSP卡住第1步的外部脚本加载,输出编码直接让第1步不成立,SameSite属性卡住跨站请求带Cookie。这就是纵深防御的思路,单一措施有短板,多层叠加才能显著提高攻击成本。
4. 防御体系搭建:从代码层到浏览器策略
4.1 输出编码:防御XSS的第一道防线
前面反复强调了输出编码的重要性。所谓输出编码,就是在数据到达HTML、属性、JavaScript等不同上下文时,对特殊字符做对应格式的转义,让浏览器把它当作文本而不是代码来渲染。
需要区分的是,不同的上下文需要不同的编码方式。在HTML标签之间输出文本内容时,需要把&、<、>、"、'转义成对应的HTML实体;在标签属性值内输出时,除了HTML实体编码外,还要注意属性值必须用引号包裹,防止属性逃逸;在JavaScript字符串中输出时,需要对引号、反斜杠做JavaScript层面的转义;在URL中输出时,需要做URL编码。一个常见的坑是:在JavaScript上下文中只做HTML实体编码,结果攻击者用反斜杠和引号就绕过了。
以PHP为例,htmlspecialchars是一个标准的输出编码函数,可用于HTML上下文的转义:
php复制$safe_name = htmlspecialchars($user_name, ENT_QUOTES | ENT_HTML5, 'UTF-8');
echo '<div>欢迎,' . $safe_name . '</div>';
JavaScript中,如果是用前端框架或原生DOM操作,最稳妥的方式是使用textContent而不是innerHTML:
javascript复制const username = userInput; // 不可信数据
const container = document.getElementById('welcome');
container.textContent = '欢迎,' + username; // 自动按文本处理
这里我想强调一个容易被忽略的点:输出编码必须在“数据到达输出点的那一刻”做,而不是在输入阶段做。如果只在存储前统一转义一遍,数据从数据库取出来到页面输出时可能经过了多次拼接和解码,之前的转义早已失效。每次输出都编码,比一次性存储编码更可靠。
4.2 HttpOnly、Secure、SameSite:Cookie的三大护身符
很多开发者知道HttpOnly,但对Secure和SameSite的理解不够。HttpOnly让脚本无法通过document.cookie读取Cookie值,从根本上保护了会话令牌。Secure属性要求浏览器只在HTTPS连接中发送Cookie,防止明文HTTP传输时被中间人截获。
SameSite属性是近几年防御跨站请求的关键角色,它控制Cookie在跨站请求中是否携带。设置SameSite=Lax时,Cookie只在同站请求和顶级导航的GET请求中携带,很多CSRF和跨站数据发送场景会被直接阻断;SameSite=Strict更严格,跨站请求完全不携带Cookie;SameSite=None则必须配合Secure使用,表示Cookie在跨站请求中也会携带,这通常只用于特定的第三方接入场景。
需要补充说明的是,SameSite不是专门为XSS设计的防线,但它在“数据外传”这个环节能发挥作用。比如攻击者企图用fetch发一个跨站请求,把收集到的信息通过Cookie头带出去,如果目标Cookie设置了SameSite,这个请求可能不带Cookie,攻击者的恶意请求就失去了身份凭证,成功率会大幅下降。现代主流浏览器默认将未设置SameSite属性的Cookie视为Lax,但为了兼容各种旧浏览器,建议在服务端显式设置。
4.3 CSP内容安全策略:给浏览器装上“白名单”
CSP是浏览器提供的一层非常重要的安全策略,它可以告诉浏览器:当前页面只允许加载哪些来源的脚本、样式、图片和连接资源。启用CSP后,即使攻击者成功在页面中注入了一段脚本,如果这段脚本不在白名单内,浏览器会直接拦截,不执行并报错。
CSP的启用方式有两种:一是在HTTP响应头中添加Content-Security-Policy字段,二是在HTML页面中用meta标签声明。HTTP响应头是推荐方式,因为它的可管理性和覆盖范围更好。下面是一个常见的CSP配置示例:
http复制Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
这个配置的含义是:默认资源只能从同源加载;脚本只允许同源和指定的CDN域名;不允许加载插件对象;不允许修改base路径;页面不允许被其他页面嵌套。在实际配置中,script-src是最关键的一项,它决定了脚本从哪里来。如果业务必须使用内联脚本,可以给script-src添加'unsafe-inline',但这是万不得已的办法,会大幅削弱CSP效果。更稳妥的做法是把内联脚本迁移到独立文件中,或者使用nonce(随机数)和hash机制来允许指定脚本执行。
CSP还会带来一个关联红利:开启CSP后,即使页面被注入了恶意外链脚本,这些脚本也会被浏览器拦截,攻击者辛苦构造的payload大部分都会失效。这相当于在浏览器内部加了一道白名单关卡,加上HttpOnly和输出编码,三重防御让攻击成功率降到很低。
4.4 框架层防御:React、Vue与前端编码的免死金牌
现代前端框架在很大程度上帮开发者默认规避了XSS问题。以React为例,JSX中通过{expression}渲染的变量值会被自动转义,这意味着你在JSX里写下{userInput},框架会把它当作文本渲染,而不是当成HTML解析。Vue的模板插值{{ }}同样会做转义处理。这是框架提供的默认保护,开发者不需要额外操作就能获得基础防御能力。
但框架不是万能的。React和Vue都提供了“原生HTML注入”的逃生舱:React的dangerouslySetInnerHTML,Vue的v-html。一旦使用了这些接口并注入了不可信数据,框架的自动转义就会被绕过,等于亲手关掉了默认保护。我见过不少XSS漏洞就是这么来的:开发者图方便,把一段需要富文本展示的内容直接交给v-html,结果用户提交的数据里有恶意脚本,全体访问者遭殃。
使用这类HTML渲染接口的正确姿势是:如果数据确实需要富文本展示,必须先经过严格的富文本过滤白名单,只允许p、strong、a、ul、img这类基础标签,并且对a标签的href属性做协议白名单校验(禁止javascript:协议),对img的src做同样的校验。市面上有一些成熟的富文本过滤库,比自己去写正则可靠得多。自己写过滤正则是我最不建议的做法,因为HTML解析器的容错特性导致绕过姿势太多,正则很难覆盖全面。
4.5 输入过滤与WAF:做了但不能全信
输入过滤和WAF是大家最熟悉的防御手段,但它们的定位是“降低风险”而不是“消除风险”。输入过滤的思路是在数据进入系统之前,把敏感字符删除或转义。问题在于:业务场景对输入格式的要求千差万别,一个放之四海而皆准的过滤规则几乎不存在。比如博客系统需要允许用户提交
这样的标签,而昵称系统可能连尖括号都不允许。如果过滤规则过严,业务功能会受损;过滤规则过松,又等于没挡。WAF的作用是在网络层面拦截恶意请求特征。它对已知的、特征明显的攻击很有效,但攻击者可以通过编码混淆、大小写变换、拆分关键字、使用DOM型XSS等方式绕过WAF。尤其DOM型XSS的攻击代码根本不经过服务端,WAF完全看不到。所以我反复强调:WAF可以作为安全体系中的一环,但不能作为唯一的防线。真正可靠的防御,必须在代码层面把“输出编码、CSP、Cookie属性”这些基础打牢。
另外,我还想提一个实际操作中的建议:对于富文本输入和高风险接口,可以额外加上服务端的内容安全校验,使用白名单过滤库对HTML做规范化处理。这样即使前端被绕过,后端也能兜底。
5. 常见问题与排查技巧实录
5.1 为什么设置了HttpOnly,账号还是被盗
这是我在安全交流中经常被问到的问题。答案往往是:HttpOnly保护的只是Cookie不被脚本读取,但XSS的破坏力远不止偷Cookie。即使攻击者拿不到HttpOnly Cookie,他仍然可以:
- 在页面中插入伪造的登录表单,骗取用户输入账号密码,然后发送给攻击者服务器。
- 用已登录的用户身份调用业务API,比如修改邮箱、关闭双因子验证,这类操作可能不需要Cookie,而是依赖现有会话或Token。
- 注册键盘记录器,等待用户输入密码。
- 用脚本病毒式传播恶意内容,借用户身份发送有害消息。
所以正确的防御姿势是“多层防御”,而不是“设置一个HttpOnly就心安理得”。HttpOnly解决了会话令牌被盗的问题,但账号安全的最终保障还是要靠代码层防御、CSP和用户侧的警惕共同完成。
5.2 内联事件和javascript:协议为什么危险
XSS攻击中经常用到两个“帮凶”是内联事件属性和javascript:协议。内联事件属性是HTML标签中那些以on开头的属性,比如onclick、onmouseover、onload、onerror。攻击者如果能把内容注入到这些属性值中,就不需要构造script标签,也能触发脚本执行。举例来说,如果开发者把用户输入直接拼进一个标签的onclick属性里,攻击者只要输入一段引号闭合再拼接自己的代码,就能在用户点击时执行恶意脚本。
javascript:协议则是用在a标签的href属性或iframe的src属性中。用户点击链接时,浏览器会执行javascript:后面的代码。这就是为什么在富文本过滤白名单中,a标签的href必须校验协议白名单,只允许http、https、mailto等安全协议,其他协议一律阻止。同样的道理也适用于form的action、iframe的src等URL相关属性。
5.3 实战排查XSS漏洞的检查清单
我在做代码审计和漏洞排查时,会按照一套固定的检查清单来快速定位问题。这套清单在团队内部用过很多次,效果不错,现在分享出来:
- 找出所有接受外部输入的数据入口:URL参数、表单字段、请求头、Cookie、文件上传文件名、WebSocket消息。
- 追踪这些数据流向哪些服务端模板和前端渲染位置。
- 检查输出位置是否做了对应上下文的编码:HTML文本、属性值、JavaScript变量、CSS、URL五种上下文需要不同处理。
- 搜索代码中的危险API:innerHTML、outerHTML、document.write、eval、new Function、setTimeout的字符串形式、v-html、dangerouslySetInnerHTML。
- 排查富文本功能是否使用了白名单过滤库,而不是正则黑名单。
- 验证Cookie是否设置了HttpOnly、Secure、SameSite属性。
- 确认生产环境是否开启了CSP,以及CSP的script-src配置是否严格。
- 对识别出的风险点,用安全测试用例(如尖括号、引号、事件属性、javascript:协议)逐一验证是否经过正确编码。
这套清单最好固化成团队的安全发布流程,在每次上线前例行检查。如果项目历史遗留代码比较多,可以先用自动化扫描工具跑一遍,再利用清单人工复核高危项。
5.4 常见防御误区速查表
这里我整理了一些实际工作中常见的防御误区,方便读者对照自查:
误区 实际情况 只要过滤掉
