ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

XSS跨站脚本攻击深度解析:从实验预习到防御实战

XSS跨站脚本攻击深度解析:从实验预习到防御实战

1. 从一次课堂演示事故说起:XSS到底能造成多大破坏

很多同学第一次接触XSS,是在软件安全实验课上。老师打开一个留言板页面,在输入框里敲了一行看似人畜无害的代码,回车之后,页面弹出了一个对话框。教室里传来一阵笑声,大家觉得这不过是个“弹窗小把戏”。但我见过更真实的场景:某内部技术分享会上,一位讲师演示了一个模拟的在线协作平台,他在评论区注入了一段脚本,结果所有在线用户的登录态都被悄悄转发到了他的测试服务器上。那一刻,全场安静了。

这就是XSS(Cross-Site Scripting,跨站脚本攻击)的典型特征——看起来简单,危害却极其深远。它的本质是攻击者将恶意脚本注入到原本可信的网页中,当其他用户浏览该页面时,脚本在用户的浏览器环境中执行,从而窃取Cookie、篡改页面内容、劫持用户操作、甚至发起蠕虫式传播。

这篇预习笔记面向的是正在准备软件安全实验的同学们,也适合任何想系统理解XSS原理和防御的开发者。我会从实验预习的角度出发,把XSS的分类、原理、利用方式、防御手段以及实验中容易踩的坑,全部拆开揉碎讲清楚。你不需要有深厚的安全背景,只要会基本的HTML和JavaScript,就能跟上。

提示:本文所有实验环境均为本地搭建的模拟靶场,所有案例仅用于教学理解,请勿将技术用于任何未经授权的场景。

2. XSS的三种面孔:反射型、存储型与DOM型

在预习阶段,最先要搞清楚的就是XSS的分类。很多同学做完实验只知道“输入脚本会弹窗”,但说不清楚为什么有的弹窗是一次性的,有的却会反复出现。这个区别直接决定了漏洞的危害等级和修复优先级。

2.1 反射型XSS:一次性的“回音”

反射型XSS(Reflected XSS)是最常见也最容易理解的一种。它的工作流程是这样的:攻击者构造一个带有恶意脚本参数的URL,诱导受害者点击。服务器收到请求后,没有对参数做任何处理,直接把它“反射”回HTML页面中。浏览器解析这段HTML时,脚本就被执行了。

举个模拟例子。假设某个搜索页面这样处理查询词:

<p>您搜索的关键词是:<%= request.getParameter("keyword") %></p>

如果攻击者构造这样的URL:

http://localhost:8080/search?keyword=<script>alert('xss')</script>

服务器返回的页面就会变成:

<p>您搜索的关键词是:<script>alert('xss')</script></p>

浏览器渲染时,<script>标签被解析执行,弹窗出现。

反射型XSS的特点是不持久化。恶意脚本存在于URL中,不会存储在服务器数据库里。每个受害者都需要点击特制的链接才会触发。因此,它通常用于针对特定用户的定向攻击,比如通过钓鱼邮件发送链接。

注意:反射型XSS的触发前提是用户点击了恶意链接。在实验中,你可能会觉得“谁会点这种奇怪的链接”,但在真实场景中,攻击者会通过短链接、二维码、社交工程等手段伪装URL,诱导点击的成功率并不低。

2.2 存储型XSS:埋在地里的“地雷”

存储型XSS(Stored XSS)是我个人认为危害最大的一类。恶意脚本被提交到服务器后,存储在数据库或文件中。当其他用户访问包含这段数据的页面时,脚本自动执行。整个过程不需要受害者点击任何特殊链接,只要正常浏览页面就会中招。

典型的场景包括:留言板、评论区、用户昵称、个人签名、文章标题等。攻击者在这些位置提交带有脚本的内容,服务器存储后,所有查看该内容的用户都会触发脚本。

存储型XSS的可怕之处在于它的传播性。在一个社交平台中,如果个人签名存在存储型XSS,攻击者可以构造一段自动发送好友请求或自动发布内容的脚本,形成蠕虫式传播。2005年曾经出现过一个经典的XSS蠕虫案例,在短时间内感染了大量用户,就是利用了存储型XSS的自动传播特性。

在实验中,存储型XSS通常需要你搭建一个带有数据库的模拟留言板或评论系统。提交恶意内容后,刷新页面或换一个浏览器访问,就能看到脚本自动执行的效果。

2.3 DOM型XSS:不走服务器的那条路

DOM型XSS(DOM-based XSS)和前两种有一个本质区别:恶意脚本的执行完全不经过服务器。它是由于前端JavaScript代码在处理用户输入时,不安全地将其插入到DOM中导致的。

看一个模拟的代码片段:

var name = location.hash.substring(1); document.getElementById("greeting").innerHTML = "你好," + name;

如果URL是:

http://localhost:8080/page#<img src=x onerror=alert('xss')>

那么location.hash获取到的内容会被直接赋给innerHTML,浏览器解析时触发onerror事件,脚本执行。

DOM型XSS的排查难度相对较高,因为它不体现在服务器响应中。你查看页面源代码可能看不到任何异常,但浏览器实际渲染的DOM结构已经被篡改了。在实验中,这类漏洞通常出现在使用innerHTML、document.write、eval等危险函数的场景。

类型是否经过服务器是否持久化典型触发方式危害等级
反射型是否点击恶意链接中
存储型是是浏览正常页面高
DOM型否否点击恶意链接中高

3. 浏览器是怎么“帮凶”的:XSS的底层执行机制

理解XSS不能只停留在“输入脚本会弹窗”这个层面。你需要知道浏览器在什么情况下会把一段文本当作代码执行,这涉及到HTML解析、JavaScript执行上下文和同源策略这几个核心概念。

3.1 HTML解析器的工作方式

浏览器收到HTML文档后,会从上到下逐行解析。当解析器遇到<script>标签时,它会暂停HTML解析,立即执行其中的JavaScript代码,执行完毕后再继续解析。这就是为什么内联脚本能够直接运行。

但不仅仅是<script>标签能执行代码。很多HTML标签的属性也支持JavaScript协议或事件处理。比如:

<img src="x" onerror="alert('xss')"> <a href="javascript:alert('xss')">点击</a> <div onmouseover="alert('xss')">悬停试试</div>

这些标签和属性在特定条件下都会触发脚本执行。攻击者正是利用了浏览器“区分代码和文本”的机制——如果开发者没有正确地对用户输入进行转义,浏览器就会把用户输入当作代码来解析。

3.2 同源策略与XSS的关系

同源策略(Same-Origin Policy)是浏览器最核心的安全机制之一。它规定:来自不同源(协议、域名、端口任一不同)的页面不能互相读取数据。这个策略本意是防止恶意网站窃取其他网站的数据。

但XSS攻击巧妙地绕过了同源策略。因为恶意脚本是在目标网站的页面中执行的,对于浏览器来说,这段脚本和目标网站是同源的。所以脚本可以自由地读取该网站下的Cookie、LocalStorage、发起同源请求等。这就是为什么XSS能窃取登录态——脚本不是从外部入侵的,而是“从内部”获取了信任。

提示:理解这一点非常重要。很多同学会问“为什么不能直接跨域读Cookie”,答案就是同源策略。而XSS的本质是让恶意代码获得了目标源的执行权限,从而绕过了这道防线。

3.3 Cookie的HttpOnly属性:一道关键防线

在实验中,你可能会尝试用document.cookie来获取用户Cookie。但如果目标网站的Cookie设置了HttpOnly属性,JavaScript就无法读取它。这是防御XSS窃取登录态的重要手段。

// 如果Cookie设置了HttpOnly,这行代码返回空字符串 console.log(document.cookie);

在搭建实验靶场时,建议你分别测试有HttpOnly和无HttpOnly的情况,直观感受这道防线的作用。但要注意,HttpOnly只能防止Cookie被读取,不能阻止XSS执行其他恶意操作,比如篡改页面内容、发起CSRF请求等。

4. 实验环境搭建:从零构建一个可复现的靶场

预习阶段最怕的就是“看懂了但做不出来”。这一节我会给出一个完整的本地实验环境搭建方案,基于常见的Web技术栈,确保你能在自己的机器上复现所有XSS类型。

4.1 技术选型与理由

对于软件安全实验,我推荐使用Node.js + Express + 内存存储的方案。原因有三:第一,Node.js环境搭建简单,不需要配置复杂的数据库;第二,Express框架轻量,代码量少,便于聚焦安全逻辑本身;第三,内存存储可以避免数据库安装的麻烦,同时支持存储型XSS的演示。

如果你更熟悉Python,Flask也是不错的选择。核心思路是一样的:一个能够接收用户输入并原样输出到HTML页面的Web应用。

4.2 最小化靶场代码

下面是一个模拟的留言板应用,包含反射型和存储型XSS的漏洞点:

const express = require('express'); const app = express(); app.use(express.urlencoded({ extended: true })); // 模拟内存存储 let comments = []; // 首页:展示留言列表(存储型XSS漏洞点) app.get('/', (req, res) => { let html = '<h1>留言板</h1>'; html += '<form action="/comment" method="POST">'; html += '<input name="content" placeholder="输入留言">'; html += '<button type="submit">提交</button>'; html += '</form>'; html += '<h2>所有留言:</h2>'; comments.forEach(c => { html += `<div>${c}</div>`; // 未转义,存在存储型XSS }); res.send(html); }); // 提交留言 app.post('/comment', (req, res) => { comments.push(req.body.content); res.redirect('/'); }); // 搜索页面(反射型XSS漏洞点) app.get('/search', (req, res) => { const keyword = req.query.keyword || ''; res.send(`<h1>搜索结果</h1><p>您搜索的是:${keyword}</p>`); // 未转义 }); app.listen(3000, () => { console.log('靶场运行在 http://localhost:3000'); });

这段代码故意没有对用户输入做任何转义处理。你可以用它来测试反射型XSS(访问/search?keyword=<script>alert(1)</script>)和存储型XSS(提交带有脚本的留言后刷新页面)。

4.3 实验中的隔离与安全措施

在本地做XSS实验时,有几点必须注意。第一,使用独立的浏览器配置文件或虚拟机。因为XSS实验可能涉及Cookie操作,如果你用日常浏览器做实验,可能会影响到你真实网站的登录态。第二,不要将靶场部署到公网。本地localhost环境足够完成所有实验。第三,实验结束后清理浏览器数据,避免残留的测试Cookie或缓存影响后续操作。

注意:有些同学为了图方便,直接把靶场代码部署到云服务器上。这是非常危险的行为,一旦被扫描到,你的服务器可能成为攻击跳板。所有实验务必在本地隔离环境中完成。

5. 手工挖掘XSS:一套可复用的测试流程

知道了原理,接下来就是动手找漏洞。这一节我整理了一套在实际测试和实验中都能用的XSS挖掘流程,从输入点识别到Payload构造,再到绕过过滤,逐步推进。

5.1 第一步:识别所有用户可控的输入点

XSS的本质是“用户输入被当作代码执行”,所以第一步是找到所有用户能控制的数据入口。常见的输入点包括:

  • URL参数(?keyword=xxx)
  • 表单字段(输入框、文本域、隐藏域)
  • HTTP请求头(User-Agent、Referer、Cookie)
  • 文件上传的文件名
  • 数据库中的已有数据(二次注入)

在实验中,你可以用浏览器的开发者工具(F12)的Network面板,观察每个请求中哪些参数是你可以控制的。把这些参数记录下来,逐个测试。

5.2 第二步:用无害Payload探测输出位置

找到输入点后,不要一上来就丢<script>alert(1)</script>。更稳妥的做法是先输入一个独特的字符串,比如xss_test_12345,然后查看页面源代码,确认这个字符串出现在哪里。

如果它出现在HTML标签之间,比如<div>xss_test_12345</div>,那么你可以尝试注入标签。如果它出现在属性值中,比如<input value="xss_test_12345">,那么你需要先闭合属性。如果它出现在<script>标签内部,那么你需要闭合脚本标签或构造合法的JavaScript表达式。

这个步骤的目的是确定输出上下文,不同的上下文需要不同的Payload构造方式。

5.3 第三步:根据上下文构造Payload

下面是一个按输出上下文分类的Payload速查表:

输出位置示例测试Payload
HTML标签之间<div>输入</div><script>alert(1)</script>
HTML属性值内<input value="输入">"><script>alert(1)</script>
JavaScript字符串内var a = '输入';';alert(1);//
JavaScript代码块内<script>输入</script>alert(1)
URL属性内<a href="输入">javascript:alert(1)

在实际实验中,你可能会遇到各种过滤规则。比如服务器可能过滤了<script>标签,但可能没有过滤<img>标签的onerror事件。这时候就需要尝试替代Payload:

<img src=x onerror=alert(1)> <svg onload=alert(1)> <body onload=alert(1)> <input autofocus onfocus=alert(1)>

5.4 第四步:验证与记录

当Payload成功执行后,不要只满足于弹窗。你需要记录以下信息:漏洞的URL、注入点、Payload、输出上下文、浏览器版本。这些信息在撰写实验报告时非常关键。

另外,尝试思考这个漏洞能造成什么实际影响。比如,如果是一个存储型XSS,能否窃取其他用户的Cookie?能否发起CSRF请求?能否篡改页面内容进行钓鱼?这种“影响分析”是实验报告中体现深度的重要部分。

6. 绕过与对抗:当过滤规则出现时怎么办

真实的Web应用往往不会让你直接注入<script>。各种过滤、转义、白名单机制会阻碍你的Payload执行。这一节讨论常见的防御措施以及对应的绕过思路——当然,这些知识同样用于理解如何正确防御。

6.1 常见过滤规则及其绕过思路

大小写过滤:有些过滤器只匹配小写的script。可以尝试<Script>、<SCRIPT>等变体。但现代浏览器对标签名大小写不敏感,所以这种绕过在很多情况下有效。

标签黑名单:如果<script>被过滤,可以尝试其他标签。比如<img src=x onerror=alert(1)>、<svg onload=alert(1)>、<details open ontoggle=alert(1)>等。浏览器支持的HTML标签有上百个,黑名单很难覆盖完全。

属性黑名单:如果onerror被过滤,可以尝试onload、onfocus、onmouseover等。如果所有on开头的事件都被过滤,可以尝试javascript:协议(在href或src属性中)。

编码绕过:HTML实体编码、URL编码、JavaScript编码都可以用来绕过简单的字符串匹配。比如<script>可以写成&#60;script&#62;,在某些上下文中浏览器会自动解码。

6.2 为什么“过滤”不如“转义”

从防御的角度看,黑名单过滤是一种非常脆弱的策略。因为HTML和JavaScript的语法极其灵活,攻击者总能找到未被过滤的标签或属性组合。正确的做法是输出转义:根据输出上下文,对用户输入进行相应的编码。

  • HTML上下文:将<转义为&lt;,>转义为&gt;,&转义为&amp;,"转义为&quot;
  • JavaScript上下文:将特殊字符转义为Unicode序列
  • URL上下文:使用encodeURIComponent进行编码

在实验中,你可以尝试在靶场代码中加入转义逻辑,然后重新测试之前的Payload,观察是否还能执行。这种对比实验能让你深刻理解“转义”和“过滤”的区别。

6.3 CSP:最后一道防线

内容安全策略(Content Security Policy,CSP)是浏览器层面的防御机制。通过设置HTTP响应头,网站可以限制哪些来源的脚本可以执行。比如:

Content-Security-Policy: default-src 'self'; script-src 'self'

这表示只允许执行同源的脚本,内联脚本和外部脚本都会被阻止。CSP能有效缓解XSS的影响,但它不是万能的。如果CSP配置不当(比如使用了unsafe-inline),防护效果会大打折扣。

在实验中,你可以尝试给靶场添加CSP头,然后观察哪些Payload被阻止了,哪些仍然能执行。这能帮助你理解CSP的实际防护边界。

7. 防御XSS的工程实践:从代码层面堵住漏洞

做实验的目的是为了在真实开发中避免这些漏洞。这一节从工程实践的角度,总结防御XSS的核心原则和具体做法。

7.1 输入验证与输出转义的分工

一个常见的误区是“在输入时过滤掉所有特殊字符”。这种做法有两个问题:第一,它可能破坏正常的业务数据(比如用户昵称中确实需要包含<符号);第二,同一条数据可能在不同的上下文中输出,输入时的单一过滤无法适配所有场景。

正确的做法是:输入时做验证(确保数据符合预期格式),输出时做转义(根据上下文进行编码)。比如,用户年龄字段在输入时验证必须是数字,输出到HTML时直接输出即可;用户昵称在输入时允许任意字符,但在输出到HTML属性或JavaScript变量时,分别进行对应的转义。

7.2 使用成熟的模板引擎和框架

现代Web框架通常内置了自动转义机制。比如:

  • React:JSX中的{}默认对内容进行转义,只有使用dangerouslySetInnerHTML时才需要手动处理
  • Vue:模板中的{{ }}默认转义,v-html需要谨慎使用
  • Django:模板变量默认转义,|safe过滤器需要谨慎使用
  • Express + EJS:<%= %>默认转义,<%- %>不转义

在实验中,你可以对比使用<%= %>和<%- %>的输出差异,直观感受框架的转义机制。但要注意,框架的自动转义不是万能的,在href、src、onclick等属性中,仍然需要额外的处理。

7.3 富文本场景的特殊处理

很多应用需要允许用户输入富文本(比如博客编辑器、评论区的加粗和链接)。这种情况下不能简单地全部转义,否则富文本格式就丢失了。正确的做法是使用白名单过滤:只允许特定的标签和属性,其他一律转义或删除。

比如,可以使用成熟的HTML净化库(如DOMPurify),它会在客户端对HTML进行白名单过滤,移除所有危险的标签和属性。在服务端,也可以使用类似原理的库进行二次过滤。

提示:富文本过滤是一个复杂的话题,建议在实验中先理解基本的转义机制,再尝试引入白名单过滤库,观察过滤前后的差异。

8. 实验报告怎么写:从“弹窗成功”到“深度分析”

很多同学的实验报告只写了“输入Payload后弹窗成功”,这样的报告缺乏深度。一份高质量的XSS实验报告应该包含哪些内容?我结合自己的经验给出一个框架。

8.1 漏洞发现过程的完整记录

不要只写最终成功的Payload,要把测试过程记录下来。比如:首先输入了xss_test,发现输出在<div>标签中;然后尝试<script>alert(1)</script>,发现被过滤;接着尝试<img src=x onerror=alert(1)>,成功执行。这种“尝试-失败-调整-成功”的过程,比单纯的结果更有价值。

8.2 漏洞根因的代码级分析

找到漏洞后,要定位到具体的代码行,分析为什么会产生漏洞。比如:“在第23行,服务器将用户输入的content参数直接拼接到了HTML字符串中,没有进行任何转义处理,导致浏览器将用户输入解析为HTML标签。”

8.3 影响范围与危害评估

分析这个漏洞能被谁利用、影响哪些用户、能造成什么后果。比如:“该漏洞位于留言板页面,属于存储型XSS。任何访问该页面的用户都会执行恶意脚本。攻击者可以窃取用户的Cookie,进而劫持登录态。由于留言板对所有登录用户可见,该漏洞可能影响全部用户。”

8.4 修复方案与验证

给出具体的修复代码,并验证修复后Payload不再执行。比如:“将第23行的${c}改为${escapeHtml(c)},其中escapeHtml函数对<、>、&、"进行转义。修复后重新提交<script>alert(1)</script>,页面显示转义后的文本,脚本未执行。”

报告章节核心内容常见问题
漏洞发现测试步骤与Payload演进只写成功结果,缺少过程
根因分析定位到具体代码行只描述现象,不分析代码
影响评估受影响用户与危害泛泛而谈,缺少具体场景
修复验证修复代码与复测结果只给修复方案,不验证

9. 预习中容易踩的坑与我的实操心得

最后这部分,我想分享一些在XSS学习和实验中容易踩的坑,以及我个人总结的一些经验。这些内容在教材上通常不会写,但能帮你少走很多弯路。

9.1 浏览器版本和扩展的影响

不同浏览器对XSS的拦截策略不同。Chrome和Edge内置了XSS Auditor(虽然现在已弱化),可能会拦截某些反射型XSS。Firefox的防护策略又不一样。在实验中,如果发现Payload在某个浏览器中不执行,先换一个浏览器试试,不要急着怀疑自己的Payload有问题。

另外,浏览器扩展也可能干扰实验结果。比如某些广告拦截插件会阻止包含alert的脚本执行。建议在实验中启用一个干净的浏览器配置文件,禁用所有扩展。

9.2 编码问题导致的“假成功”

有时候你看到页面上显示了<script>alert(1)</script>,但并没有弹窗。这可能是因为服务器对输入进行了HTML实体编码,浏览器把它当作纯文本显示,而不是代码执行。这种情况下,你看到的“成功”其实是“失败”——漏洞并不存在。

判断方法是查看页面源代码(Ctrl+U),而不是查看渲染后的页面。如果源代码中显示的是&lt;script&gt;,说明输入被转义了;如果显示的是<script>,说明漏洞存在。

9.3 存储型XSS的“延迟触发”

存储型XSS的一个特点是,你提交Payload的页面和触发Payload的页面可能不是同一个。比如,你在个人资料页面提交了恶意昵称,但脚本可能在论坛帖子页面才被执行。在实验中,提交Payload后要遍历所有可能显示该数据的位置,确认触发点。

9.4 不要忽视DOM型XSS

很多同学做实验时只关注服务器端返回的HTML,忽略了前端JavaScript的动态操作。DOM型XSS的Payload可能完全不经过服务器,在URL的hash或search部分就能触发。测试时,要特别关注使用了innerHTML、document.write、eval、setTimeout等危险函数的代码。

提示:在实验中,你可以使用浏览器的开发者工具,在Sources面板中设置断点,跟踪JavaScript的执行流程,观察用户输入是如何被处理的。这是理解DOM型XSS的最直观方式。

9.5 我的个人体会

做了这么多年的安全实验和教学,我最大的体会是:XSS的防御不是靠某一个措施,而是靠多层防线。输出转义是第一道,CSP是第二道,HttpOnly Cookie是第三道。任何单一措施都可能被绕过,但多层叠加后,攻击成本会大幅提高。

另外,不要为了“弹窗成功”而做实验。弹窗只是证明脚本执行了,但真正的理解在于:为什么这段脚本能执行?浏览器在哪个环节把数据当成了代码?如果换一个上下文,Payload需要怎么调整?把这些想清楚了,你才算真正掌握了XSS。

最后分享一个小技巧:在实验中养成记录Payload和对应上下文的习惯。建一个表格,左边写输出位置,右边写可用的Payload。积累多了之后,你会发现自己对XSS的直觉越来越准,看到一个输入点就能大致判断出可能的注入方式和绕过思路。这种直觉,是任何教材都给不了你的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进