MD5(Message-Digest Algorithm 5)是一种广泛使用的密码散列函数。它于1991年由罗纳德·里维斯特设计出来,是MD4的改进版本。MD5的主要功能是提供一种将任意长度的消息转换成固定长度(128位,即16字节)的摘要的方法。这个过程通常被称为“散列”。
MD5的设计目的是为了提供一种安全的方式来存储和验证信息,比如在数据库中安全地存储密码。MD5散列是不可逆的,这意味着从产生的摘要中,无法恢复原始数据。先来看看MD5的一些简单的实际运用:
然而,随着计算能力的增强和对MD5算法的深入分析,它在安全性方面的局限性逐渐暴露出来。特别是在2000年以后,研究人员发现了MD5的多个弱点,使得攻击者可以相对容易地生成相同MD5摘要的不同消息,这种情况被称为“碰撞”。因此,MD5不再被认为是一种安全的散列函数,尤其是在需要高安全性的场合。尽管如此,MD5由于其计算速度快和实现简单,在很多非安全性要求的应用中仍然在使用。例如,在一些老旧系统中,MD5仍用于验证数据完整性和生成文件的检验和。但在安全性要求较高的应用中,通常推荐使用更安全的散列函数,如SHA-256。
上面也说了,MD5的主要功能是提供一种将任意长度的消息转换成固定长度(128位/32个十六进制位/16字节)的摘要的方法,这个过程通常被称为“散列”,而通过这个密码散列函数的到的值,一般叫做散列值。先来看看一个MD5加密的例子:
序号 | 原始值 | 散列值(32个十六进制) |
---|---|---|
1 | Hello World | B10A8DB164E0754105B7A99BE72E3FE5 |
2 | Hello World | B10A8DB164E0754105B7A99BE72E3FE5 |
3 | Hello World! | ED076287532E86365E841E92BFC50D8C |
通过上面的表格MD5加密的例子可以得到,相同的原始值无论加密多少次,得到的散列值都是一样的,而相似度极大的原始值比如上面就多了一个感叹号,得到的散列值也是大有不同的。所以这为上面讲到的“登录界面”,“下载”的实现提供了基础。
MD5的实现大致可以分成3个主要部分:填充对齐,分块,多轮压缩。下面就来详细并且通俗的讲一下这个过程。首先我们知道所有文件实质上都是一个个由0/1比特位组成的,现在我们有一个文件M,它的比特位为N,即N是由一堆的01组成的东东。
原始数据(n位) | 填充内容1(1~512位) | 填充内容2(64位) |
---|---|---|
1011001…01001 | 1…000000000 | 表示消息长度的01串 |
下面附上C++的MD5加密代码,感兴趣可以研究一下:
#include <iostream>
#include <cstring>
#include <cstdint>
#include <string>
class MD5 {
public:
MD5(const std::string& message) {
init();
update(reinterpret_cast<const uint8_t*>(message.c_str()), message.length());
finalize();
}
const uint8_t* digest() const {
return buffer;
}
std::string toString() const {
const char hexDigits[] = "0123456789abcdef";
std::string str;
for (int i = 0; i < 16; ++i) {
str.append(1, hexDigits[(buffer[i] >> 4) & 0x0F]);
str.append(1, hexDigits[buffer[i] & 0x0F]);
}
return str;
}
private:
void init() {
h0 = 0x67452301;
h1 = 0xEFCDAB89;
h2 = 0x98BADCFE;
h3 = 0x10325476;
unprocessedBytes = 0;
size = 0;
}
void update(const uint8_t* msg, size_t length) {
size += length;
size_t i = 0;
if (unprocessedBytes > 0) {
if (length + unprocessedBytes >= 64) {
memcpy(&processedBytes[unprocessedBytes], msg, 64 - unprocessedBytes);
processBlock(processedBytes);
length -= 64 - unprocessedBytes;
i += 64 - unprocessedBytes;
unprocessedBytes = 0;
}
}
for (; i + 63 < length; i += 64) {
processBlock(msg + i);
}
if (i < length) {
memcpy(processedBytes, msg + i, length - i);
unprocessedBytes = length - i;
}
}
void finalize() {
uint8_t finalBlock[128];
size_t finalBlockSize = unprocessedBytes;
memcpy(finalBlock, processedBytes, unprocessedBytes);
finalBlock[finalBlockSize++] = 0x80;
if (finalBlockSize > 56) {
memset(finalBlock + finalBlockSize, 0, 64 - finalBlockSize);
processBlock(finalBlock);
finalBlockSize = 0;
}
memset(finalBlock + finalBlockSize, 0, 56 - finalBlockSize);
uint64_t sizeInBits = size * 8;
memcpy(finalBlock + 56, &sizeInBits, 8);
processBlock(finalBlock);
memcpy(buffer, &h0, 4);
memcpy(buffer + 4, &h1, 4);
memcpy(buffer + 8, &h2, 4);
memcpy(buffer + 12, &h3, 4);
}
void processBlock(const uint8_t* block) {
uint32_t a = h0;
uint32_t b = h1;
uint32_t c = h2;
uint32_t d = h3;
uint32_t M[16];
for (int i = 0; i < 16; ++i) {
M[i] = (block[i * 4 + 0] << 0) |
(block[i * 4 + 1] << 8) |
(block[i * 4 + 2] << 16) |
(block[i * 4 + 3] << 24);
}
// 主循环
for (unsigned int i = 0; i < 64; ++i) {
uint32_t F, g;
if (i < 16) {
F = (b & c) | ((~b) & d);
g = i;
}
else if (i < 32) {
F = (d & b) | ((~d) & c);
g = (5 * i + 1) % 16;
}
else if (i < 48) {
F = b ^ c ^ d;
g = (3 * i + 5) % 16;
}
else {
F = c ^ (b | (~d));
g = (7 * i) % 16;
}
uint32_t D = d;
d = c;
c = b;
b = b + leftRotate((a + F + K[i] + M[g]), S[i]);
a = D;
}
h0 += a;
h1 += b;
h2 += c;
h3 += d;
}
static inline uint32_t leftRotate(uint32_t x, uint32_t c) {
return (x << c) | (x >> (32 - c));
}
uint32_t h0, h1, h2, h3;
uint8_t buffer[16];
uint8_t processedBytes[64];
size_t unprocessedBytes;
uint64_t size;
static constexpr uint32_t K[64] = {
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391
};
static constexpr uint32_t S[64] = {
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21
};
};
int main() {
std::string message;
std::cout << "Enter message: ";
std::getline(std::cin, message);
MD5 md5(message);
std::cout << "MD5 digest: " << md5.toString() << std::endl;
return 0;
}
我们已经知道了它的加密过程吧,现在我们来破解它。首先我们先明确一点,我们需要的是什么,是原文还是寻找能产生相同散列值的内容。MD5事实上是一个散列函数而不是一个加密函数,他不是一个可逆的过程,即不能通过散列值得出原始内容,就像你不能由一个128位的散列值恢复成一部2个G的电影,这是违背信息论的。同样前面也讲过多轮压缩的过程中也会进行与或非等操作,比如:1(原文)|1=1,你能通过后两个1来确认前面的是1还是0吗?这显然是不能的。那这样我们攻击个屁!
仔细想想,破解它一定要得到原始内容吗?我们好像忽略了一个东西,那就是消息内容和散列值是一一对应的吗?显然不是,原始内容是无穷无尽的,而散列值只有那128位,这可以得出一个散列值对应的也是无穷个原始内容,而这个原始内容我们很难找到罢了。去寻找两个能产生一样散列值的不同原始内容,叫做“碰撞”。
对于MD5的破解,大体上可以分成3类,原像攻击,第二原像攻击,碰撞攻击:
穷举法和字典法都是利用计算机资源没有太多头绪尝试碰撞已知的MD5码。对于穷举法,就是不断尝试各种字符的排列组合,看哪一个组合可以对的上,这个方法的缺点就是太耗费时间,一个8位数的字母和数字组合的密码的组合有两百万亿种;对于字典法,就是把已知的常见的密码和对应的散列值存在一起,这种方法相对于穷举法更加理性一些,缺点就是耗费空间。
生成链:
存储链端点:
破解过程:
H(X):生成信息摘要的哈希函数,比如MD5,SHA256。
R(X):从信息摘要种转化为另一个字符串的还原函数,其中R(X)的定义域是H(X)的值域,R(X)的值域是H(X)的定义域,需要注意的就是这两个并非反函数关系。
通过交替运算H和R若干次,可以形成一个链条,假设原文是aaaaaa,哈希长度位32位,那么哈希链表就是这样子:
同时我们只需要将首段和尾端存入哈希表里面。那接下来就来演示一下获得原文的过程:
给定信息摘要:920ECF10,R(920ECF10)=kiebgt,查询哈希表可以找到首段是aaaaaa,因此摘要920ECF10极有可能在这个链表里面。接下来从aaaaaa开始,重新交替运算R(X)H(X),看看摘要值时候是其中一次H(X)的结果,如果是的话,前面一个节点就是可能的原文。
彩虹表是哈希链表法的一种改进。它通过引入“彩虹”效果来减少链之间的重叠和冲突。彩虹表的关键特点包括:
在彩虹表中,一条哈希链从一个特定的起始点(原始密码候选)开始,通过交替应用哈希函数和还原函数,形成了一系列的中间值。这条链实际上在“探索”一系列可能的密码。
不同的潜在密码:每次应用还原函数时,都可能产生一个不同的潜在密码。因此,一条链实际上代表了从起始点出发,通过多次变换能够到达的多个不同的潜在密码。而这个潜在密码我来梳理一下,这里用彩虹表来解释,在彩虹表中,一个明文A应用一次哈希函数得到一个散列值A,然后散列值应用一次还原函数得到明文B,明文B应用哈希函数得到散列值B,同理得到明文C,散列值C…假如里面存在潜在相同的散列值,那么可以得到前一个的还原函数是有效的,这个还原函数恰巧将上一个散列值给还原成我们想要的明文,这个明文又被哈希函数转化为那个相同的散列值。
利用差分攻击破解MD5是在2004年我国山东大学的王小云教授及其团队发现的,他们找到了快速发现大量MD5碰撞的方法,即找到两个原始消息使他们的MD5散列值相同,并于2005年发表,他们的研究基于模块化差分,大体思路是先找到局部碰撞,然后分析差分如何传播,找到差分路径,再利用消息修改技术最后得到能产生碰撞的消息对。
差分攻击是一种密码分析技术,它被用来破解加密算法,包括哈希函数如MD5。然而,需要注意的是,差分攻击主要用于对称加密算法,而对于哈希函数,特别是像MD5这样的,通常采用的是其他类型的攻击方法,如碰撞攻击。不过,为了解释差分攻击的原理,我会首先描述它在对称加密算法中的应用,然后讨论它在哈希函数中的应用情景。
在对称加密算法中
基本原理:
步骤:
在哈希函数中
碰撞攻击:
MD5的脆弱性:
李德刚, 杨阳, 曾光. 基于选择前缀攻击的哈希函数多文件格式碰撞[J]. 密码学报, 202X, X(X): 1–16. [DOI: 10.13868/j.cnki.jcr.000659]
相同前缀碰撞方法,该方法利用两组消息块序列,通过精心设计,使得它们经过MD5处理后能够产生相同的中间哈希值(IHV)。在此基础上,可以在这些块之后附加任意长度相同的后缀而不影响碰撞结果。这个过程包括:
选择消息块:从两个不同的消息块对 {B0, B1} 和 {B′0, B′1} 开始,这些块在内容上略有差异。
构建碰撞:通过对这些消息块的处理,确保它们在经历MD5哈希过程后,即使有不同的输入块,也能得到相同的中间哈希值(IHV)。
附加后缀:在经过处理的块后面附加任意相同的后缀(S),产生的完整消息为 {P||Sr||B0||B1||S} 和 {P||Sr||B′0||B′1||S},其中 P 是前缀,Sr 是填充部分,保证整个消息长度符合MD5处理的要求。
结果:最终,这两个完整的消息将产生相同的MD5哈希值,即使它们在 {B0, B1} 与 {B′0, B′1} 部分有所不同。
这个方法也应用到了差分原理。