3、MD5的数学基础:位运算、模运算、非线性函数

很多人在学MD5时,一上来就盯着那64步的循环看。结果呢?越看越晕。我当初也一样,觉得这东西太复杂了。

后来我发现,其实MD5的数学基础就三个东西:位运算、模运算、非线性函数。搞懂这三个,你就掌握了MD5的核心心跳。

下面我一个个讲。嗯,咱们从最基础的位运算开始。

3.1 位运算:MD5的原子操作

位运算,说白了就是直接在二进制位上干活。你想想看,计算机最底层就是0和1。位运算速度飞快,这是所有哈希算法的首选。

MD5里用到了哪几种位运算?我列了个表,一目了然:

运算 符号 含义 例子 (32位)
按位与 & 全1则1,否则0 0x12345678 & 0xFFFF0000 = 0x12340000
按位或 | 有1则1,全0则0 0x12340000 | 0x00005678 = 0x12345678
按位非 ~ 0变1,1变0 ~0x12345678 = 0xEDCBA987
异或 ^ 相异为1,相同为0 0x12345678 ^ 0x87654321 = 0x95511559
左移 << 高位丢弃,低位补0 0x80000001 << 1 = 0x00000002
右移 >> 逻辑右移:高位补0 0x80000001 >> 1 = 0x40000000
循环左移 <<< 左移后移出的位补到低位 0x80000001 <<< 1 = 0x00000003

这里面最关键的,其实是循环左移。MD5的每一轮都会对某个中间结果做循环左移。为什么要循环左移?因为如果不循环,信息就从高位那头跑出去了。循环一下,信息还在里面打转,增加了混合度。

我个人习惯用Python来验证这些位运算。MD5里到处都是32位无符号整数的操作,而Python的整数是无限精度的,所以得加个掩码才能模拟。你看这个例子:

# 模拟32位循环左移
def left_rotate(value, shift):
    value &= 0xFFFFFFFF  # 截断到32位
    return ((value << shift) | (value >> (32 - shift))) & 0xFFFFFFFF

# 测试一下
x = 0x80000001
print(hex(left_rotate(x, 1)))  # 输出: 0x3  (因为0x80000001左移1位变0x2,移出的1补回来)

我的经验:位运算切不可掉以轻心。我在一个项目里就吃过亏——Python里没有无符号右移,用>>时负数的符号位会扩展。这就是个坑。MD5里所有数字都必须当作无符号32位来处理,一定要加& 0xFFFFFFFF。

3.2 模运算:2^32的魔法

搞清楚了位运算,咱们再来看看模运算。MD5里只用到一种模运算:模2^32加。

为什么会是2^32?因为MD5处理的是32位整数。2^32就是4294967296。两个32位数相加,结果可能超过32位。怎么办?取个模,把高出的部分丢掉,只保留低32位。

说白了,这就是个溢出加法。我在刚开始学的时候,觉得这玩意儿太粗暴了。后来才明白,这种溢出恰恰是信息混合的关键——高位被丢弃后,低位仍然保留了两份输入的全部信息。

# 模2^32加
a = 0xFFFFFFFF
b = 0x00000001
result = (a + b) & 0xFFFFFFFF
print(hex(result))  # 输出: 0x0 (因为 0xFFFFFFFF + 1 = 0x100000000, 截断后为0)

你看,0xFFFFFFFF加1,正常结果应该是0x100000000,但模2^32之后变成0了。这就是溢出。

避坑指南:我曾经在一个C项目里忘记把加法的结果强制转为uint32_t,结果编译器做了整型提升,中间结果用了64位,最终哈希值完全不对。排查了两天才找到。记住:每一步加法都要模2^32,不能等最后再取模。

MD5里除了加法,没有乘法、减法、除法。为什么呢?因为哈希算法的设计要求就是快。加法在CPU里一个时钟周期就完事,而乘除法要慢得多。

3.3 非线性函数:混合的催化剂

有了位运算和模运算,MD5还需要一个东西:非线性函数。没有它们,整个运算就太线性了,输入的小变化只能引起输出的线性变化,哈希效果就很差。

MD5用了四个非线性函数,对应四轮运算:

轮次 函数 表达式 特点
第1轮 F (X & Y) | (~X & Z) 每一位的结果是Y和Z根据X选择的
第2轮 G (X & Z) | (Y & ~Z) 与F对称,只是角色互换
第3轮 H X ^ Y ^ Z 异或,完全对称
第4轮 I Y ^ (X | ~Z) 混合了或和非

你看这规律:F和G是对称的,H是最简单的异或,I稍微复杂一点。它们都是非线性的——也就是说,你不能用线性组合来逼近它。这就保证了MD5的雪崩效应:输入哪怕只改一个比特,输出的一半比特都会翻转。

# MD5的四个非线性函数
def F(x, y, z):
    return (x & y) | (~x & z)

def G(x, y, z):
    return (x & z) | (y & ~z)

def H(x, y, z):
    return x ^ y ^ z

def I(x, y, z):
    return y ^ (x | ~z)

# 测试一下
x, y, z = 0x12345678, 0x9ABCDEF0, 0xFEDCBA98
print(hex(F(x, y, z)))  # 0x1A345678
print(hex(H(x, y, z)))  # 0x4B9BCF06

关键点:这四个函数虽然不同,但有一个共同性质——它们都在比特级别上均匀混合了三个输入。无论输入怎么变,输出的每一位都有50%的翻转概率。这是MD5安全性的基础。

现在我画一张图,把这三个基础的关系理清楚。你看了就明白MD5是怎么搭建的了:

MD5 数学基础结构图 位运算 & | ~ ^ << >> 循环左移 <<< 原子操作 模运算 模 2^32 加 溢出即混合 保持信息完整性 扩散操作 非线性函数 F: (X&Y)|(~X&Z) G: (X&Z)|(Y&~Z) H: X^Y^Z I: Y^(X|~Z) MD5 压缩函数 位运算 + 模运算 + 非线性函数 = 信息混合与扩散 三者缺一不可:位运算保证速度,模运算保证溢出混合,非线性函数保证雪崩效应。

看到没?位运算、模运算、非线性函数,三个基础模块汇聚到MD5的压缩函数里。MD5的每一轮步子都是这样:先对三个32位输入做非线性函数,再加一个32位常量和某个消息字,然后循环左移,最后加回原始值。

我的一点建议:如果你想把MD5的自己实现一遍,先别急着写64步。先把left_rotate和四个非线性函数写对,然后用几个简单的输入验证结果。我在一个测试框架里就是这么做的,先测位操作,再测函数,最后集成。这样出了问题,定位很快。

3.4 三个基础如何配合?

前面讲了三个基础分别是什么。现在我来聊聊它们怎么配合的。

你想想看,如果你只用位运算,那就是线性的,很容易找到碰撞。如果你只用模运算,那就是加法,输入和输出的关系太直接。如果你只用非线性函数,没有移位和加法,信息扩散不出去。

MD5的高明之处,就在于把三者组合起来:

  1. 非线性函数做第一层混合:根据三个输入产生一个输出,打破线性关系。
  2. 模加做第二层混合:把当前状态和消息字、常数混在一起,产生溢出。
  3. 循环左移做第三层扩散:让高位的比特跑到低位去,下次运算时就能影响到其他比特。

这三步构成了MD5的一轮运算,迭代16次就是一层,4层就是64次。每一次都在前一次的基础上把信息搅得更均匀。

一句话总结:MD5的数学基础其实就是三个玩意儿——位运算让你能快速操作比特,模运算让你能自然溢出,非线性函数让输出不可预测。三者缺一不可。

好了,这一章就到这儿。你只要把位运算、模加和非线性函数搞清楚了,后面看MD5的64步循环时,就会发现每一步都是在做同样的事情——混合、扩散、再混合、再扩散。