MD5浅析

285 阅读10分钟

MD5是什么,安全吗?

MD5 是 Message Digest Algorithm 的缩写,译为信息摘要算法,它是 Java 语言中使用很广泛的一种散列算法。

注意:很多人将MD5误解为加密算法。 在密码学中,加密(英语:Encryption)是将明文信息改变为难以读取的密文内容,使之不可读的过程。密文经由解密方法解密后,又能还原为正常可读的内容。 所以严格来说MD5(也包括SHA256之类的)不叫“加密”,加密是对原数据进行某种转换,使其与原文不同,最重要的是能还原原始数据

MD5 可以将任意字符串,通过不可逆的字符串变换算法,生成一个唯一的 MD5 信息摘要,这个信息摘要也就是我们通常所说的 MD5 字符串。那么问题来了,MD5算法安全吗?

这道题看似简单,其实是一道送命题,很多人尤其是一些新入门的同学会觉得,安全啊,MD5 首先是加密的字符串,其次是不可逆的,所以它一定是安全的。如果你这样回答,那么就彻底掉进面试官给你挖好的坑了。

为什么呢?因为答案是“不安全”,而不是“安全”。

1. MD5的特点

MD5 算法具有以下特点:

  1. 压缩性:任意长度的数据,算出的 MD5 值长度都是固定的(128 bit)。

  2. 易计算:从原数据计算出 MD5 值很容易。

  3. 抗修改:对原数据进行任何改动,哪怕只修改 1 个字节,所得到的 MD5 值都有很大区别。

  4. 抗碰撞:已知原数据和其 MD5 值,想找到一个具有相同 MD5 值的数据(即伪造数据)是非常困难的(极小碰撞概率)。

2.破解MD5散列的三种方法

  1. 暴力破解法:时间成本太高。

  2. 字典法:提前构建一个 “明文 ⇨ 密文” 对应关系的一个大型数据库,破解时通过密文直接反查明文。但存储一个这样的数据库,空间成本是惊人的。

  3. 构建彩虹表:在字典法的基础上改进,以时间换空间。是现在破解哈希函数常用的办法。


2.1 穷举(暴力破解)

MD5 之所以说它是不安全的,是因为每一个原始密码都会生成一个对应的固定密码,也就是说一个字符串生成的 MD5 值是永远不变的。这样的话,虽然它是不可逆的,但可以被穷举。

2.2 字典法

提前构建一个 “明文 ⇨ 密文” 对应关系的一个大型数据库,破解时通过密文直接反查明文。但存储一个这样的数据库,空间成本是惊人的。

简单来说,就是一个很大的,用于存放穷举对应值的数据表数据库。 以 MD5 为例,“1”的 MD5 值是“C4CA4238A0B923820DCC509A6F75849B”,而“2”的 MD5 值是“C81E728D9D4C2F636F067F89CC14862C”,那么就会有一个 MD5 的数据字典是这样的:

原始值加密值
1C4CA4238A0B923820DCC509A6F75849B
2C81E728D9D4C2F636F067F89CC14862C

注意 这张表不叫‘彩虹表’彩虹表会涉及到加密,解密,再加密,再解密一个链式的流程,这只能叫做存储明文密文的数据字典

2.3 彩虹表

2.3.1 彩虹表的前身

既然存储所有的明文密码对需要的空间太大,密码学家们想出了一种以计算时间降低存储空间的办法,被称为 “预计算的哈希链集”。

假设这是一条 k = 2 的哈希链: image

其中 H 函数 就是要破解的哈希函数。

约简函数(reduction function)R 函数 是构建这条链的时候定义的一个函数:它的值域和定义域与 H 函数相反。通过该函数可以将哈希值约简为一个与原文相同格式的值。

这条链是这样生成的: image

存储的时候,不需要存储所有的节点,只需要存储每条链的头尾节点(这里是 aaa 和ccc)。

以大量的随机明文作为起节点,通过上述步骤计算出哈希链并将终节点进行储存,可得到一张哈希链集。

2.3.2 预计算的哈希链集的使用

假设密文刚好是 4D5E6F ,首先对其进行一次 R 运算,得到 ccc,然后发现刚好命中了哈希链集中的(aaa, ccc)链条。可以确定其极大概率在这个链条中。于是从 aaa 开始重复哈希链的计算过程,发现 bbb 的哈希结果刚好是 4D5E6F,于是破解成功。

假设密文不是 4D5E6F 而是 1A2B3C ,第一次 R 运算后的结果并未在末节点中找到,则再重复一次 H 运算 + R 运算,这时又得到了末节点中的值 ccc。于是再从头开始运算,可知 aaa 的哈希结果刚好是 1A2B3C,破解成功。

如过密文重复了 k(=2)次之后,仍然没有在末节点中找到对应的值,则破解失败。

2.4.3 预计算的哈希链集的意义

对于一个长度为 k 的预计算的哈希链集,每次破解计算次数不超过 k,因此比暴力破解大大节约时间。同时每条链只保存起节点和末节点,储存空间只需约 1 / k,因而大大节约了空间。

2.4.4 R函数存在的问题

要发挥预计算的哈希链集的作用,需要一个分布均匀的 R 函数。当出现碰撞时,就会出现下面这种情况

image

由于两条链出现了重复。这两条哈希链能解密的明文数量就远小于理论上的明文数(2 * k)。由于集合只保存链条的首末节点,因此这样的重复链条并不能被迅速地发现。

2.4.5 彩虹表

彩虹表的出现,针对性的解决了 R 函数导致的链重复问题:它在各步的运算中,并不使用统一的 R 函数,而是分别使用 R1…Rk 一共 k 个不同的 R 函数。

image

这样一来,即使发生碰撞,通常会是下面的情况:

image

即使在极端情况下,两个链条在同一序列位置上发生碰撞,导致后续链条完全一致,这样的链条也会因为末节点相同而检测出来,可以丢弃其中一条而不浪费存储空间。

2.4.6 彩虹表的使用

首先,假设要破解的密文位于某一链条的 k - 1 位置处,对其进行 Rk 运算,看是否能够在末节点中找到对应的值。如果找到,则可以如前所述,使用起节点验证其正确性。

否则,继续假设密文位于 k - 2 位置处,这时就需要进行 Rk - 1、H、Rk 两步运算,然后在末节点中查找结果。

如此反复,在最不利条件下需要将密文进行完整的 R1、H、…Rk 运算后,才能得知密文是否存在于彩虹表之中。

2.4.6 彩虹表中时间、空间的平衡

哈希链集的最大计算次数为 k,平均计算次数为 k / 2;彩虹表的最大计算次数为 1 + 2 + …k = k(k - 1) / 2,平均计算次数为 [(k + 2) * (k + 1)] / 6。

可见,要解相同个数的明文,彩虹表的代价会高于哈希链集。

无论哈希链集还是彩虹表,当 k 越大时,破解时间就越长,但彩虹表所占用的空间就越小;相反,当 k 越小时,彩虹表本身就越大,相应的破解时间就越短。

3.加盐解决方案

3.1 为什么加盐可以抵御彩虹表

盐(Salt):在密码学中,是指通过在密码任意固定位置插入特定的字符串,让散列后的结果和使用原始密码的散列结果不相符,这种过程称之为“加盐”。

说的通俗一点“加盐”就像炒菜一样,放不同的盐,炒出菜的味道就是不同的,咱们之前使用 MD5 不安全的原因是,每个原始密码所对应的 MD5 值都是固定的,那我们只需要让密码每次通过加盐之后,生成的最终密码都不同,这样就能解决加密不安全的问题了。

彩虹表在生成的过程中,针对的是特定的 H 函数,H 函数如果发生了改变,则已有的彩虹表数据就完全无法使用。

如果每个用户都用一个不同的盐值,那么每个用户的 H 函数都不同,则必须要为每个用户都生成一个不同的彩虹表。大大提高了破解难度。

3.2 加盐代码示例

3.2.1 通过随机值加盐

加盐是一种手段、是一种解决密码安全问题的思路,而它的实现手段有很多种,我们可以使用框架如 Spring Security 提供的 BCrypt 进行加盐和验证,当然,我们也可以自己实现加盐的功能。

本文为了让大家更好的理解加盐的机制,所以我们自己来动手来实现一下加盐的功能。 实现加盐机制的关键是在加密的过程中,生成一个随机的盐值,而且随机盐值尽量不要重复,这时,我们就可以使用 Java 语言提供的 UUID(Universally Unique Identifier,通用唯一识别码)来作为盐值,这样每次都会生成一个不同的随机盐值,且永不重复。 加盐的实现代码如下:

import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
import java.util.UUID;

public class PasswordUtil {
    /**
     * 加密(加盐处理)
     * @param password 待加密密码(需要加密的密码)
     * @return 加密后的密码
     */
    public static String encrypt(String password) {
        // 随机盐值 UUID
        String salt = UUID.randomUUID().toString().replaceAll("-", "");
        // 密码=md5(随机盐值+密码)
        String finalPassword = DigestUtils.md5DigestAsHex((salt + password).getBytes());
        return salt + "$" + finalPassword;
    }
}

从上述代码我们可以看出,加盐的实现具体步骤是:

使用 UUID 产生一个随机盐值; 将随机盐值 + 原始密码一起 MD5,产生一个新密码(相同的原始密码,每次都会生成一个不同的新密码); 将随机盐值 + "$"+上一步生成的新密码加在一起,就是最终生成的密码。 那么,问题来了,既然每次生成的密码都不同,那么怎么验证密码是否正确呢? 要验证密码是否正确的关键是需要先获取盐值,然后再使用相同的加密方式和步骤,生成一个最终密码和和数据库中保存的加密密码进行对比,具体实现代码如下:

import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
import java.util.UUID;

public class PasswordUtil {
    /**
     * 加密(加盐处理)
     * @param password 待加密密码(需要加密的密码)
     * @return 加密后的密码
     */
    public static String encrypt(String password) {
        // 随机盐值 UUID
        String salt = UUID.randomUUID().toString().replaceAll("-", "");
        // 密码=md5(随机盐值+密码)
        String finalPassword = DigestUtils.md5DigestAsHex((salt + password).getBytes());
        return salt + "$" + finalPassword;
    }

    /**
     * 解密
     * @param password       要验证的密码(未加密)
     * @param securePassword 数据库中的加了盐值的密码
     * @return 对比结果 true OR false
     */
    public static boolean decrypt(String password, String securePassword) {
        boolean result = false;
        if (StringUtils.hasLength(password) && StringUtils.hasLength(securePassword)) {
            if (securePassword.length() == 65 && securePassword.contains("$")) {
                String[] securePasswordArr = securePassword.split("\\$");
                // 盐值
                String slat = securePasswordArr[0];
                String finalPassword = securePasswordArr[1];
                // 使用同样的加密算法和随机盐值生成最终加密的密码
                password = DigestUtils.md5DigestAsHex((slat + password).getBytes());
                if (finalPassword.equals(password)) {
                    result = true;
                }
            }
        }
        return result;
    }
}

3.2.2 其他盐值

上述使用UUID生成的盐值散列后的密文,通常我们需要在数据库中存储密文的同时还要存储随机生成的盐值。

所以建议使用另一种方式: 如果是用于密码加盐,完全可以把用户名变换一下作为盐值,不变换其实也可以,随便加两个别人不知道的符号作为连接就可以了,譬如:用户名#$#密码 去做MD5 ;

总结

  1. MD5是散列算法,而散列不是加密

  2. 彩虹表不是存储明文密文这样的简单格式,而是涉及到加密,解密,再加密,再解密一个链式的流程

如果将 MD5 哈希后的密文比作一把锁,暴力破解的方法就是现场制作各种各样不同齿形的钥匙,再来尝试能否开锁,这样耗时无疑很长很长。。。

我以前错误理解的 “彩虹表”,是事先制作好所有齿形的钥匙,全部拿过来尝试开锁,这样虽然省去了制作钥匙的时间,但是后来发现这些钥匙实在是太多了,没法全部带在身上。这种方案其实就是第二种数据字典。

而真正的彩虹表,是将钥匙按照某种规律进行分组,每组钥匙中只需要带最有特点的一个,当发现某个 “特征钥匙” 差一点就能开锁了,则当场对该钥匙进行简单的打磨,直到能开锁为止。这种方法是既省力又省时的。

借鉴文章链接

  1. 深入理解 MD5 加密、彩虹表算法原理
  2. 面试突击91:MD5 加密安全吗?