消息摘要算法是密码学算法中非常重要的一个分支,它通过对所有数据提取指纹信息以实现数据签名、数据完整性校验等功能,由于其不可逆性,有时候会被用做敏感信息的加密。消息摘要算法也被称为哈希(Hash)算法或散列算法。
任何消息经过散列函数处理后,都会获得唯一的散列值,这一过程称为 “消息摘要”,其散列值称为 “数字指纹”,其算法自然就是 “消息摘要算法”了。换句话说,如果其数字指纹一致,就说明其消息是一致的。
(图片来源 —— https://zh.wikipedia.org/wiki/散列函數)
消息摘要算法的主要特征是加密过程不需要密钥,并且经过加密的数据无法被解密,目前可以解密逆向的只有 CRC32 算法,只有输入相同的明文数据经过相同的消息摘要算法才能得到相同的密文。消息摘要算法不存在密钥的管理与分发问题,适合于分布式网络上使用。消息摘要算法主要应用在 “数字签名” 领域,作为对明文的摘要算法。著名的摘要算法有 RSA 公司的 MD5 算法和 SHA-1 算法及其大量的变体。
MD5(Message Digest Algorithm 5,消息摘要算法版本5),它由 MD2、MD3、MD4 发展而来,由 Ron Rivest(RSA 公司)在 1992 年提出,目前被广泛应用于数据完整性校验、数据(消息)摘要、数据签名等。 MD2、MD4、MD5 都产生 16 字节(128 位)的校验值,一般用 32 位十六进制数表示。MD2 的算法较慢但相对安全,MD4 速度很快,但安全性下降,MD5 比 MD4 更安全、速度更快。
随着计算机技术的发展和计算水平的不断提高,MD5 算法暴露出来的漏洞也越来越多。1996 年后被证实存在弱点,可以被加以破解,对于需要高度安全性的数据,专家一般建议改用其他算法,如 SHA-2。2004 年,证实 MD5 算法无法防止碰撞(collision),因此不适用于安全性认证,如 SSL 公开密钥认证或是数字签名等用途。
128 位的 MD5 散列在大多数情况下会被表示为 32 位十六进制数字。以下是一个 43 位长的仅 ASCII 字母列的MD5 散列:
MD5("The quick brown fox jumps over the lazy dog")= 9e107d9d372bb6826bd81d3542a419d6
即使在原文中作一个小变化(比如把 dog 改为 cog,只改变一个字符)其散列也会发生巨大的变化:
MD5("The quick brown fox jumps over the lazy cog")= 1055d3e698d289f2af8663725127bd4b
接着我们再来举几个 MD5 散列的例子:
MD5("") -> d41d8cd98f00b204e9800998ecf8427e MD5("semlinker") -> 688881f1c8aa6ffd3fcec471e0391e4d MD5("kakuqo") -> e18c3c4dd05aef020946e6afbf9e04ef
在互联网上分发软件安装包时,出于安全性考虑,为了防止软件被篡改,比如在软件安装程序中添加木马程序。软件开发者通常会使用消息摘要算法,比如 MD5 算法产生一个与文件匹配的数字指纹,这样接收者在接收到文件后,就可以利用一些现成的工具来检查文件完整性。
(图片来源 —— https://en.wikipedia.org/wiki/MD5)
这里我们来举一个实际的例子,下图是 MySQL Community Server 8.0.19 版本的下载页,该下载页通过 MD5 算法分别计算出不同软件包的数字指纹,具体如下图所示:
(图片来源 —— https://dev.mysql.com/downloads/mysql/)
当用户从官网上下载到对应的安装包之后,可以利用一些 MD5 校验工具对已下载的文件进行校验,然后比对最终的 MD5 数字指纹,若结果与官网公布的数字指纹一致,则表示该安装包未经过任何修改是安全的,基本可以放心安装。
假设在网络上你需要发送电子文档给你的朋友,在文件发送前,先对文档的内容进行 MD5 运算,得出该电子文档的 “数字指纹”,并把该 “数字指纹” 随电子文档一同发送给对方。当对方接收到电子文档之后,也使用 MD5 算法对文档的内容进行哈希运算,在运算完成后也会得到一个对应 “数字指纹”,当该指纹与你所发送文档的 “数字指纹” 一致时,表示文档在传输过程中未被篡改。
在互联网初期很多网站在数据库中以明文的形式存储用户的密码,这存在很大的安全隐患,比如数据库被黑客入侵,从而导致网站用户信息的泄露。针对这个问题,一种解决方案是在保存用户密码时,不再使用明文,而是使用消息摘要算法,比如 MD5 算法对明文密码进行哈希运算,然后把运算的结果保存到数据库中。使用上述方案,避免了在数据库中以明文方式保存密码,提高了系统的安全性,不过这种方案并不安全,后面我们会详细分析。
当用户登录时,登录系统对用户输入的密码执行 MD5 哈希运算,然后再使用用户 ID 和密码对应的 MD5 “数字指纹” 进行用户认证。若认证通过,则当前的用户可以正常登录系统。用户密码经过 MD5 哈希运算后存储的方案至少有两个好处:
在 Java 中使用 MD5 算法很方便,可以直接使用 JDK 自带的 MD5 实现,也可以使用第三方库提供的 MD5 实现。下面我们将介绍 JDK、Bouncy Castle 和 Guava 的 MD5 使用示例。为了保证以下示例的正常运行,首先我们需要在 pom.xml 文件中添加 Bouncy Castle 和 Guava 的坐标:
<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.64</version></dependency><dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>27.1-jre</version></dependency>
public static void jdkMD5(String src) throws NoSuchAlgorithmException { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] md5Bytes = md.digest(src.getBytes()); System.out.println("JDK MD5:" + src + " -> " + bytesToHexString(md5Bytes));}
public static void bcMD5(String src) { MD5Digest digest = new MD5Digest(); digest.update(src.getBytes(), 0, src.getBytes().length); byte[] md5Bytes = new byte[digest.getDigestSize()]; digest.doFinal(md5Bytes, 0); System.out.println("Bouncy Castle MD5:" + src + " -> " + bytesToHexString(md5Bytes));}
public static void guavaMD5(String src) { HashFunction hf = Hashing.md5(); HashCode hc = hf.newHasher().putString(src, Charset.defaultCharset()).hash(); System.out.println("Guava MD5:" + src + " -> " + hc);}
在 JDK 实现和 Bouncy Castle 实现的示例中使用了 bytesToHexString 方法,该方法用于把字节数组转换成十六进制,它的具体实现如下:
private static String bytesToHexString(byte[] src) { StringBuilder stringBuilder = new StringBuilder(); if (src == null || src.length <= 0) { return null; } for (int i = 0; i < src.length; i++) { int v = src[i] & 0xFF; String hv = Integer.toHexString(v); if (hv.length() < 2) { stringBuilder.Append(0); } stringBuilder.append(hv); } return stringBuilder.toString();}
介绍完 MD5 算法不同的实现,下面我们来测试一下上述的方法:
public static void main(String[] args) throws NoSuchAlgorithmException { jdkMD5("123"); bcMD5("123"); guavaMD5("123");}
以上示例代码正常运行后,在控制台中会输出以下结果:
JDK MD5:123 -> 202cb962ac59075b964b07152d234b70Bouncy Castle MD5:123 -> 202cb962ac59075b964b07152d234b70Guava MD5:123 -> 202cb962ac59075b964b07152d234b70
在 Node.js 环境中,我们可以使用 crypto 原生模块提供的 md5 实现,当然也可以使用主流的 MD5 第三方库,比如 md5 这个可以同时运行在服务端和客户端的第三方库。与 Java 示例一样,在介绍具体使用前,我们需要提前安装 md5 这个第三方库,具体安装方式如下:
$ npm install md5 --save
const crypto = require('crypto'); const msg = "123";function md5(data){ const hash = crypto.createHash('md5'); return hash.update(data).digest('hex');}console.log("Node.js Crypto MD5:" + msg + " -> " + md5(msg));
const md5 = require('md5');const msg = "123";console.log("MD5 Lib MD5:" + msg + " -> " + md5(msg));
以上示例代码正常运行后,在控制台中会输出以下结果:
Node.js Crypto MD5:123 -> 202cb962ac59075b964b07152d234b70MD5 Lib MD5:123 -> 202cb962ac59075b964b07152d234b70
哈希碰撞是指不同的输入却产生了相同的输出,好的哈希算法,应该没有人能从中找到 “碰撞” 或者说极度难找到,虽然 “碰撞” 是肯定存在的。
2005 年山东大学的王小云教授发布算法可以轻易构造 MD5 碰撞实例,此后 2007 年,有国外学者在王小云教授算法的基础上,提出了更进一步的 MD5 前缀碰撞构造算法 “chosen prefix collision”,此后还有专家陆续提供了MD5 碰撞构造的开源的库。
2009 年,中国科学院的谢涛和冯登国仅用了 220.96 的碰撞算法复杂度,破解了 MD5 的碰撞抵抗,该攻击在普通计算机上运行只需要数秒钟。
下面我们来看个简单的 MD5 碰撞示例:
4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa200a8284bf36e8e4b55b35f427593d849676da0d1555d8360fb5f07fea2
4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa202a8284bf36e8e4b55b35f427593d849676da0d1d55d8360fb5f07fea2
两个样本之间的差异如下图所示:
下面我们来通过 Java 代码实际验证一下样本 A1 和样本 A2 经过 MD5 运算后输出的结果是否一致:
public static void jdkMd5Hex(String hexStr) throws NoSuchAlgorithmException { byte[] bytes = hexStringToBytes(hexStr); MessageDigest md = MessageDigest.getInstance("MD5"); byte[] md5Bytes = md.digest(bytes); System.out.println("JDK MD5:" + hexStr + " -> " + bytesToHexString(md5Bytes));}
public static byte[] hexStringToBytes(String s) { int len = s.length(); byte[] data = new byte[len / 2]; for (int i = 0; i < len; i += 2) { data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i+1), 16)); } return data;}
public static void main(String[] args) throws NoSuchAlgorithmException { jdkMd5Hex("4dc968ff..."); //样本A jdkMd5Hex("4dc968ff..."); //样本B}
以上示例代码正常运行后,在控制台中会输出以下结果:
JDK MD5:4dc968ff... -> 008ee33a9d58b51cfeb425b0959121c9JDK MD5:4dc968ff... -> 008ee33a9d58b51cfeb425b0959121c9
如果你对其它 MD5 碰撞的样本感兴趣,可以查看 MD5碰撞的一些例子 这篇文章。由于基于 MD5 来验证数据完整性已不可靠,因此很多人都熟悉的 Node.js 使用了 SHA256 算法来确保数据的完整性。
(图片来源 —— https://nodejs.org/dist/v12.14.1/SHASUMS256.txt.asc)
前面我们已经提到通过对用户密码进行 MD5 运算可以提高系统的安全性。但实际上,这样的安全性还是不高。为什么呢?因为只要输入相同就会产生相同的输出。接下来我们来举一个示例,字符串 123456789 是一个很常用的密码,它经过 MD5 运算后会生成一个对应的哈希值:
MD5("123456789") -> 25f9e794323b453885f5181f1b624d0b
由于输入相同就会产生相同的结果,因此攻击者就可以根据哈希结果反推输入。其中一种常见的破解方式就是使用彩虹表。 彩虹表是一个用于加密散列函数逆运算的预先计算好的表,常用于破解加密过的密码散列。 查找表常常用于包含有限字符固定长度纯文本密码的加密。 这是以空间换时间的典型实践,在每一次尝试都计算的暴力破解中使用更少的计算能力和更多的储存空间,但却比简单的每个输入一条散列的翻查表使用更少的储存空间和更多的计算性能。
目前网上某些站点,比如 cmd5.com 已经为我们提供了 MD5 密文的反向查询服务,我们以 MD5("123456789") 生成的结果,做个简单的验证,具体如下图所示:
因为 123456789 是很常见的密码,因此该网站能够反向得出正确结果那就不足为奇了。以下是 cmd5 网站的站点说明,大家可以参考一下,感兴趣的小伙伴可以亲自验证一下。
本站针对 md5、sha1 等全球通用公开的加密算法进行反向查询,通过穷举字符组合的方式,创建了明文密文对应查询数据库,创建的记录约 90 万亿条,占用硬盘超过 500 TB,查询成功率 95% 以上,很多复杂密文只有本站才可查询。已稳定运行十余年,国内外享有盛誉。
现在我们已经知道如果用户的密码相同 MD5 的值就会一样,通过一些 MD5 密文的反向查询网站,密码大概率会被解析出来,这样使用相同密码的用户就会收到影响。那么该问题如何解决呢?答案是密码加盐。
盐(Salt),在密码学中,是指在散列之前将散列内容(例如:密码)的任意固定位置插入特定的字符串。这个在散列中加入字符串的方式称为 “加盐”。其作用是让加盐后的散列结果和没有加盐的结果不相同,在不同的应用情景中,这个处理可以增加额外的安全性。
在大部分情况,盐是不需要保密的。盐可以是随机产生的字符串,其插入的位置可以也是随意而定。如果这个散列结果在将来需要进行验证(例如:验证用户输入的密码),则需要将已使用的盐记录下来。为了便于理解,我们来举个简单的示例。
const crypto = require("crypto");function cryptPwd(password, salt) { const saltPassword = password + ":" + salt; console.log("原始密码:%s", password); console.log("加盐后的密码:%s", saltPassword); const md5 = crypto.createHash("md5"); const result = md5.update(saltPassword).digest("hex"); console.log("加盐密码的md5值:%s", result);}cryptPwd("123456789","exe");cryptPwd("123456789","eft");
以上示例代码正常运行后,在控制台中会输出以下结果:
原始密码:123456789加盐后的密码:123456789:exe加盐密码的md5值:3328003d9f786897e0749f349af490ca原始密码:123456789加盐后的密码:123456789:eft加盐密码的md5值:3c45dd21ba03e8216d56dce8fe5ebabf
通过观察以上结果,我们发现原始密码一致,但使用的盐值不一样,最终生成的 MD5 哈希值差异也比较大。此外为了提高破解的难度,我们可以随机生成盐值并且提高盐值的长度。
哈希加盐的方式确实能够增加攻击者的成本,但是今天来看还远远不够,我们需要一种更加安全的方式来存储用户的密码,这也就是今天被广泛使用的 bcrypt 。
bcrypt 是一个由 Niels Provos 以及 David Mazières 根据 Blowfish 加密算法所设计的密码散列函数,于 1999 年在 USENIX 中展示。 bcrypt 这一算法就是为哈希密码而专门设计的,所以它是一个执行相对较慢的算法,这也就能够减少攻击者每秒能够处理的密码数量,从而避免攻击者的字典攻击。 实现中 bcrypt 会使用一个加盐的流程以防御彩虹表攻击,同时 bcrypt 还是适应性函数,它可以借由增加迭代之次数来抵御日益增进的电脑运算能力透过暴力法破解。
由 bcrypt 加密的文件可在所有支持的操作系统和处理器上进行转移。它的口令必须是 8 至 56 个字符,并将在内部被转化为 448 位的密钥。然而,所提供的所有字符都具有十分重要的意义。密码越强大,您的数据就越安全。
下面我们以 Node.js 平台的 bcryptjs 为例,介绍一下如何使用 bcrypt 算法来处理用户密码。首先我们需要先安装 bcryptjs :
$ npm install bcryptjs --save
const bcrypt = require("bcryptjs");const password = "123456789";const saltRounds = 10;async function bcryptHash(str, saltRounds) { let hashedResult; try { const salt = await bcrypt.genSalt(saltRounds); hashedResult = await bcrypt.hash(str, salt); } catch (error) { throw error; } return hashedResult;}bcryptHash(password, saltRounds).then(console.log);
以上示例代码正常运行后,在控制台中会输出以下结果:
$2a$10$O1SrEy3KsgN0NQdQjaSU6OxjxDo0jf.j/e2goSwSEu4esz9i58dRm
很明显密码 123456789 经过 bcrypt 的哈希运算后,得到了一串读不懂的 “乱码”。这里我们已经完成第一步,即用户登录密码的加密。下一步我们要实现登录密码的比对,即要保证用户输入正确的密码后,能正常登录系统。
async function bcryptCompare(str, hashed) { let isMatch; try { isMatc = await bcrypt.compare(str, hashed); } catch (error) { throw error; } return isMatch;}bcryptCompare( "123456789", "$2a$10$O1SrEy3KsgN0NQdQjaSU6OxjxDo0jf.j/e2goSwSEu4esz9i58dRm").then(console.log);bcryptCompare( "123456", "$2a$10$O1SrEy3KsgN0NQdQjaSU6OxjxDo0jf.j/e2goSwSEu4esz9i58dRm").then(console.log);
以上示例代码正常运行后,在控制台中会输出以下结果:
truefalse
因为我们的原始密码是 123456789 ,很明显与 123456 并不匹配,所以会输出以上的匹配结果。
本文首先介绍了消息摘要算法、MD5 算法的相关概念和特点,然后详细介绍了 MD5 算法的用途和 Java 和 Node.js 平台的使用示例,最后我们还分析了 MD5 算法存在的缺陷和 MD5 密码的安全性问题。这里大家需要注意,由于 MD5 碰撞很容易构造,基于 MD5 来验证数据完整性已不可靠,考虑到近期谷歌已成功构造了 SHA-1(英语:Secure Hash Algorithm 1,中文名:安全散列算法1)的碰撞实例,对于数据完整性,应使用 SHA256 或更强的算法代替。
除了文中介绍的 MD5 应用场景,MD5 还可以用于实现 CDN (Content Delivery Network,内容分发网络) 内容资源的防盗链,感兴趣的小伙伴可以阅读 深入了解 Token 防盗链 这篇文章。