4、MD5的四个轮次:详细步骤与常数

MD5的四轮运算看着复杂,其实核心就一件事——把初始向量和消息搅和在一起,反复洗牌。我当年第一次接触的时候,也觉得这64步太吓人了,但拆开来看,每轮其实就是重复16次相同的操作。

4.1 四轮结构总览

四轮运算,每轮16步,总共64步。每一轮用不同的非线性函数,配上不同的常数。说白了,这就是一个精心设计的“搅拌机”,让输出结果对输入的每一比特都很敏感。

我习惯把四轮看作四个不同的“搅拌模式”,每轮结束后,四个寄存器的值会做一次加法混合。嗯,这里要注意:每轮开始时的输入状态,是上一轮结束后的全新状态。

核心流程: 初始ABCD → 第1轮(16步) → 第2轮(16步) → 第3轮(16步) → 第4轮(16步) → 与初始值相加 → 输出

4.2 四轮的非线性函数

每轮使用一个不同的函数,对三个32位字进行位运算。这几个函数的设计很巧妙,保证每轮产生不同的“混淆效果”。

轮次 函数 表达式 特点
第1轮 F (B & C) | (~B & D) 条件选择:按B控制选C还是D
第2轮 G (B & D) | (C & ~D) 条件选择:按D控制选B还是C
第3轮 H B ^ C ^ D 异或:三取奇偶
第4轮 I C ^ (B | ~D) 异或+或:更复杂混淆
我的个人习惯: 记不住表达式没关系,记住核心就行——F是“选C还是选D”,G是“选B还是选C”,H是“三取异或”,I是最复杂的混合。网上很多实现把这四个函数写成宏或内联函数,减少调用开销。

4.3 常数T表

T表是64个常数,每个32位。它们的来源很有意思:取232乘以sin(i)的绝对值,其中i从1到64(弧度制)。

为什么会用sin函数?我也问过这个问题。后来看论文才知道,这些常数是为了保证各位的随机性,打乱规律。我在项目中实测过,如果把常数改成固定0,碰撞率直线上升。

下面给出前16个T常数(第1轮用):

T[1]  = 0xd76aa478  T[2]  = 0xe8c7b756  T[3]  = 0x242070db  T[4]  = 0xc1bdceee
T[5]  = 0xf57c0faf  T[6]  = 0x4787c62a  T[7]  = 0xa8304613  T[8]  = 0xfd469501
T[9]  = 0x698098d8  T[10] = 0x8b44f7af  T[11] = 0xffff5bb1  T[12] = 0x895cd7be
T[13] = 0x6b901122  T[14] = 0xfd987193  T[15] = 0xa679438e  T[16] = 0x49b40821

剩下48个也是类似方式生成。你想想看,如果自己去设计这些常数,很难保证它们彼此之间没有隐藏的数学关系,但sin函数天然能产生均匀分布的无理数。

我曾经踩过一个坑: 有一次手动实现MD5,T表里把T[1]写成了0xd76aa479(末位差1)。结果整个计算结果全错——你不说,四个轮次的输出完全对不上。所以T表必须一字不差,这64个常数是经过严格数学论证的。

4.4 每步操作详解

64步中每步做的都是同一件事。 我把伪代码写一下,你感受下这个循环:

/* 伪代码:每步操作 */
a = b + ((a + F(b,c,d) + M[k] + T[i]) <<< s)

这里的+是模232加法,<<< s是循环左移s位。四个字每步轮换,依次向右滚动。

对于第1轮第i步(i从0到15),具体计算为:

/* 第1轮第i步 */
FF(a, b, c, d, M[k], s, T[i]):
  a = b + ((a + F(b,c,d) + M[k] + T[i]) <<< s)
  然后 (a,b,c,d) = (d, a, b, c)  /* 向右滚动 */

所谓“滚动”,其实就是把寄存器轮换一下。这样做的好处是:每次参与计算的四个字都不一样,混淆得更彻底。

4.5 循环左移位数s

每个步还配了一个循环左移位数s。这些移位值也都设计好了,四个轮次各有4组重复使用:

轮次 移位值(每步重复使用)
第1轮 7, 12, 17, 22(重复4次)
第2轮 5, 9, 14, 20(重复4次)
第3轮 4, 11, 16, 23(重复4次)
第4轮 6, 10, 15, 21(重复4次)

我记得刚接触MD5时,觉得移位值为什么不是统一的?后来才明白,不同轮次用不同的移位量,能保证字节内部的位置换也有足够的变化。

4.6 消息索引顺序

每轮访问16个消息字(M[0]~M[15])的顺序也不同:

  • 第1轮: 顺序访问 M[0], M[1], ..., M[15]
  • 第2轮: 按 (5i+1) mod 16 的顺序,即 M[1], M[6], M[11], M[0]...
  • 第3轮: 按 (3i+5) mod 16 的顺序
  • 第4轮: 按 7i mod 16 的顺序

不同的索引顺序,配合不同的函数和移位量,保证每轮的消息混合效果都不一样。说白了,就算前一步有小的碰撞,下一步立刻被打散。

MD5四轮运算结构图 初始ABCD(4×32位) 第1轮:16步 函数F + T[1..16] 第2轮:16步 函数G + T[17..32] 第3轮:16步 函数H + T[33..48] 第4轮:16步 函数I + T[49..64] 最终ABCD(与初始相加) 每步内部操作: a = b + ((a + F(b,c,d) + M[k] + T[i]) <<< s) 然后四字滚动:(a,b,c,d) → (d,a,b,c) 各轮特征对比: 函数:F → G → H → I 移位:7,12,17,22 → 5,9,14,20 → 4,11,16,23 → 6,10,15,21 消息索引:顺序 → 交错(5i+1) → 交错(3i+5) → 交错(7i) 常数来源:T[i] = floor(2^32 × |sin(i)|) 每一轮结束,新ABCD = 旧ABCD + 本轮结果 (模2^32加法,确保每轮依赖轮间信息) 最终输出 = 初始ABCD + 第4轮结束后ABCD (每个字独立模2^32加法)

4.7 一轮结束后发生了什么

每轮16步走完,四个寄存器的当前值会与这一轮开始时的值相加:

/* 第1轮结束后 */
A = A + AA  /* AA是第1轮开始时的A值 */
B = B + BB
C = C + CC
D = D + DD

为什么要加?我跟你讲,这叫“雪崩效应设计”。每轮的结果耦合了上一轮的状态和本轮的变化,哪怕输入只改一个比特,经过四轮累积,输出中约有一半的比特会翻转。

实现技巧: 实际编码时,可以用一个临时数组保存每轮开始前的ABCD值,等16步跑完后,再把临时值和当前值相加。大部分C语言实现都是这样做,简单高效。

4.8 常见错误清单

我自己在实现MD5时,列了一个检查表,分享给你:

  1. T表抄错:T[1]和T[2]一个都不能错,建议复制标准库,别手敲
  2. 移位值混乱:四轮的s值不同,循环时要用对应数组
  3. 消息索引顺序搞混:每轮从同一个M数组中按不同顺序取数据
  4. 忘记加法溢出:所有加法都是模2^32,在C/Java里会自动溢出,但Python需要手动取模
  5. 滚动方向反了:是(a,b,c,d) → (d,a,b,c),不是(a,b,c,d) → (b,c,d,a),别看反