哈希
Hash,一般翻译做"散列",也有直接音译为"哈希"的,就是把任意长度的输入(又叫做预映射,pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值。
这种转换是不可逆的,因为散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。
简单来说哈希是一种雪崩效应非常明显的密码学算法,输入数据中任何一个比特的改动,都会导致最终输出的数据具有很大的差异性。
MD5算法
MD5算法的详细描述在RFC1321中有详细描述,感兴趣的可以自己去翻阅文档。
MD5常见的使用方法
根据哈希大概率唯一且不可逆的性质,一般来说,我们可以使用MD5进行数据唯一性标识。
比如,在服务设计中,我们为了避免存储用户名和密码带来的数据合规风险,通常后台服务只会存储 MD5(用户名+密码)的哈希值,当用户登录时,我们比较传过来的用户名密码的MD5哈希值与后端是否一致,就可以判断用户是否合法。
唯一性输出带来的问题
唯一性输出会带来一个很显著的问题,就是确定性明文带来的确定性哈希问题。
其实这类问题在AES-ECB中也提到过,这种分组密码算法,总是会不经意间引起这种问题。
这种确定性输出问题带来的一种最明显的风险就是彩虹表攻击。
虽然才能从哈希值反推明文很难,但是基于常见的明文,构造其对应的哈希表,进行暴力破解,也不是不可能!
加盐哈希
对抗确定性输出的问题也很简单,就是在对密码做哈希的时候做加盐操作,增加额外的随机内容,使得密码的盐都不一样。
保存密码加盐哈希的时候也一起把盐保存在一起,在需要验证用户明文的时候把明文密码和盐一起做哈希,把结果与保存的加盐哈希的结果做比对。
由于每个密码加盐哈希的盐值都不一样,就会导致哈希的输入的可能性非常非常多,就几乎不可能构造出彩虹表,似乎看起来无法有效的攻击。
加盐哈希真的可靠?
MD5数据填充过程
在分析加盐哈希是否有风险时,我们先科普下MD5的数据填充逻辑。
分组长度
首先说明下,MD5是以64字节长度作为分组长度进行分组运算的。
常见的加密算法的分组长度与输出长度可以参考下图:

填充规则
在MD5算法中,首先需要对输入信息进行填充,使其位长对512求余的结果等于448,并且填充必须进行,即使其位长对512求余的结果等于448。
对于一段明文,在其最后一个分组一定存在会被按照如下的方式进行填充:

当明文长度刚好为64 x N + 56字节时,其最后一个分组会被填充为:

总之,当我们任意长度的明文输入给MD5时,其填充后的数据会变成:

| |
MD5哈希运算流程
这里我们不关心数学层面的算法。我们只关注实现上的流程逻辑。
当对消息进行分组以及padding后,MD5算法开始依次对每组消息(填充好的消息按照64字节分组)进行压缩,经过64轮数学变换。
最重要的是,运算过程中,每个消息块都会和一个输入向量做一个运算,把这个计算结果当成下个消息块的输入向量。
在运算最开始,会有一个默认的初始化向量(默认幻数):
| |
对于每一个分组的运算逻辑可以简单抽象为这个样子:

到这里,我们基本了解了MD5运算的基本法则了。
哈希长度拓展攻击
基于加盐哈希的场景
假设现在有一个服务端在做校验运算,用户会输入的明文信息以及待验证的哈希值,服务端会根据后台存储的盐,计算出加盐哈希,并对比加盐哈希与输入的哈希值是否一致。

哈希长度扩展攻击的条件
- 攻击者具有某个特定的明文
- 攻击者获取了这个特定明文消息的加盐哈希值
- 攻击者获取了盐的长度,但是不知道盐的具体内容
举一个例子,假设服务端现在有一个长度为10字节的盐,其默认的合法的明文为:hello,world:
| |
此时攻击者得到了以下数据:
- 明文:
hello,world - 加盐哈希值:
95f96bd63ad51a2472b8304d4a9ffdac - 盐的长度:
10
攻击目的
在不知道盐的具体内容的前提下,攻击者期望在已知明文的基础上构造出一种添加了特定数据的明文信息,并提前计算出对应的验证哈希,将这种专门构造的明文与验证哈希传给服务端,并在服务端可以验证通过。
比如现在攻击者通过一系列阶段,构造出:
- 明文:
hello,worldxxxxxxxxx - 验证哈希:
a1b2c3d4f5g6h7j8k9l0a1b2c3d4f5g6
而服务端在收到发来的消息后,经过计算:MD5(key + data) == a1b2c3d4f5g6h7j8k9l0a1b2c3d4f5g6。
攻击分析
前面我们说过,MD5一定会对输入的明文进行填充,服务端在处理正常数据时,其计算最后一个分组时可以抽象为下图:

在padding全部结束之后进行消息摘要,经过64轮计算之后,把加密结果级联拼接起来作为密文输出。
在我们得到这段密文之后,如果下次加密还是用的上次加密相同的密文并且密文后接了可控数据,我们就可以利用可控量进行手动填充原始数据使其达到正常加密前两步padding后的结果(给出密文的那次加密):

又因为是分组加密,所以每组的加密结果互不干涉(这也就是ECB的诟诟病),所以我们可以手动添加(控制)最后一组明文,并根据上一次加密的结果逆向再和库中固定的加密轮密钥来演算出我们新添加的一组明文的加密结果,进而推出最后的新密文:

MD5实现
正常MD5算法的实现如下(代码参考自github):
| |
毫无疑问,在功能性上,其需要与hashlib库保持一致:
| |
攻击实现
在正常MD5运算逻辑的基础上,当我们获取了某个证书加盐哈希后,需要:
- 根据这个哈希还原下一次计算的幻数(A、B、C、D)
- 重新计算添加了攻击数据后的哈希值
- 计算传递给服务端的明文数据,使其满足:在添加了攻击数据后,正常的加盐哈希仍然能够计算出来,添加了攻击数据后,服务端计算出来的加盐哈希与攻击者期望的一致(这里其实必定是一致的)
| |
攻击效果
攻击模拟代码:
| |
运行效果:

更好的消息验证方式
可以将消息摘要的值再进行消息摘要,这样就可以避免用户控制message了。
也就是HMAC算法。该算法大概来说是这样:MAC = hash(secret + hash(secret + message)),而不是简单的对密钥连接message之后的值进行哈希摘要。
具体HMAC的工作原理有些复杂,但重点是,由于这种算法进行了双重摘要,密钥不再受本文中的长度扩展攻击影响。
将secret放置在消息末尾也能防止这种攻击。
比如 hash(m+secret),希望推导出 hash(m + secret||padding||m'),由于自动附加secret在末尾的关系,会变成hash(m||padding||m'||secret)。现在的附加值可以看作是m'||secret,secret值不知道,从而导致附加字符串不可控,hash值也就不可控,因而防止了这种攻击。
HMAC后面单独写一篇,敬请期待!