ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DES算法深度拆解:从Feistel结构到S盒的完整实现

DES算法深度拆解:从Feistel结构到S盒的完整实现

1. 先说清楚:DES到底是个什么东西

你在任何一本密码学的教材里都会遇到DES(Data Encryption Standard),我也一样。第一次学它的时候,我最大的感受是“晕”:又是置换表,又是S盒,又是16轮循环,一张接一张的表,怎么看都像在看天书。后来我自己一行一行实现了整个算法,才真正明白它为什么被人称作现代分组密码的“活化石”——它的每一张表、每一步操作,都暗含当年设计者的深思熟虑。

DES是一个对称分组密码算法。所谓分组密码,就是把明文切成固定长度的小块,一块一块独立加密。DES的块长是64位,密钥名义上是64位,但真正参与运算的只有56位,因为每个字节的第8位是奇偶校验位,不参与加密逻辑。加密时,64位明文块经过初始置换,拆成左右两半各32位,再经过16轮迭代,最后通过逆置换得到64位密文块。解密则是用完全相同的结构、只把子密钥顺序反过来重跑一遍。这种结构有个专门的名字,叫Feistel网络,后面几乎所有经典分组密码(包括Blowfish、Twofish)都继承了它的衣钵。

这不是一篇罗列知识点的科普,而是想带着你把DES的“内脏”掏出来,一块一块看明白:为什么要有Feistel结构?IP置换那张表到底在干什么?密钥是怎么从一把主密钥变出16把子密钥的?S盒为什么是DES的灵魂?我会用一张张展开的表、一段段可以照着算的数据,告诉你这个“退休”的算法到底是怎么运转的。

适合谁来读呢?如果你是第一次接触分组密码,建议从头到尾慢慢看,保证能建立完整的脑图。如果你是想自己写一个DES实现,或者要在竞赛、课程设计、CTF里手搓代码,那可以直接跳到轮函数和实操部分,非常有帮助。哪怕你只想知道“AES之前人们用的是什么”,这篇文章也能给你一份足够清晰的答案。

2. 整体结构:Feistel网络与16轮大循环

先看宏观结构,别钻进细节里。DES加密一个64位块,分四步走:

输入64位明文 -> 初始置换IP -> 16轮Feistel迭代 -> 交换左右32位 -> 逆置换IP^-1 -> 输出64位密文

其中“16轮Feistel迭代”是核心。每一轮做的事非常统一:把当前状态拆成左半L和右半R,然后右半通过轮函数F加工,与左半异或,再把两个半区互换位置进入下一轮。用公式表达就是:

  • L_i = R_{i-1}
  • R_i = L_{i-1} ⊕ F(R_{i-1}, K_i)

这里K_i是第i轮的子密钥,每轮都用不同的48位子密钥,而子密钥全部由同一个56位主密钥派生而来。

2.1 为什么费劲搞Feistel结构

Feistel结构最精妙的一点,是它天然可逆。加密的时候你用某个顺序放子密钥,解密的时候只要把子密钥顺序倒过来、把加密和解密的代码写成一模一样,就能正确还原。试想一下,如果轮函数F设计得非常复杂,那没关系,因为解密根本不需要求F的反函数。F只需要是“确定性的、固定输入输出长度的函数”即可,哪怕它非线性、不可逆、丢失信息,反正解密时用同样的F和逆序子密钥就能还原。

那为什么不需要F的逆?来看解密公式:

  • R_{i-1} = L_i
  • L_{i-1} = R_i ⊕ F(L_i, K_i)

这个表达式的意思是:只要知道这一轮的L_i、R_i和本轮子密钥K_i,就能无损恢复出上一轮的L_{i-1}和R_{i-1}。整个过程只用到异或和函数F本身,完全不需要解方程。这就是Feistel结构“加密解密同构”的底层逻辑。

相比之下,S盒的逆运算在实现时是很麻烦的,如果整个算法在整体层面依赖逆函数,工程实现和安全性分析都会多出很多负担。Feistel结构把“可逆性”这个包袱甩给了整体结构,让轮函数可以自由追求混淆和扩散,这是它能够横跨数十年的根本原因。

2.2 16轮数据流全景

我习惯把DES的一条完整数据流画成下面这样:

  • 初始置换IP把64位明文的位置打乱,输出L0和R0,各32位
  • 第1轮:R0进入F函数,与K1异或得到F(R0, K1),然后L0与其异或得到R1,而L1直接取R0
  • 第2轮到第16轮完全重复这个过程
  • 第16轮结束后,交换左右得到R16 || L16,而不是L16 || R16
  • 最后经过逆置换IP^-1输出64位密文

这里有一个初学者最容易忽略的细节:第16轮结束后要“交换左右”。在Feistel结构里,每一轮都已经天然交换了左右,如果第16轮之后直接按L16 || R16的顺序走,那逆置换会得到错误结果。标准做法是输出R16 || L16这个连接顺序,让整个加密和解密流程在结构上完全对称。我当年第一次实现时忘了这一步,解密出来一堆乱码,排查半天才发现问题。

这个“16轮”不是随便定的。每一轮只通过F函数把右半的信息混入左半,一轮下来,一个位最多只能影响到32位里的部分位置;需要多轮循环,才能让雪崩效应扩散到整个64位块。DES选16轮,其中一个重要原因就是保证“改变任意一个明文位或者密钥位,大约有一半的密文位会变化”这一效果得到充分体现。

3. 第一层换位:初始置换IP与逆置换

接下来看64位明文进入算法后遭遇的第一件事:初始置换IP。这个操作不涉及任何密钥,纯粹是把64个位的顺序按照一张固定表格重新排列。听起来很简单,但你拿着表动手排一次,才能真正理解什么叫“按位搬家”。

标准IP置换表是这样的,按从左到右、从上到下的顺序读取,输出的第1位来自输入的58位,第2位来自50位,以此类推:

输出位置 1-8:58 50 42 34 26 18 10 2 输出位置 9-16:60 52 44 36 28 20 12 4 输出位置 17-24:62 54 46 38 30 22 14 6 输出位置 25-32:64 56 48 40 32 24 16 8 输出位置 33-40:57 49 41 33 25 17 9 1 输出位置 41-48:59 51 43 35 27 19 11 3 输出位置 49-56:61 53 45 37 29 21 13 5 输出位置 57-64:63 55 47 39 31 23 15 7

注意看这张表:上32个输出位置来自输入的奇数位(近似),下32个来自偶数位。更精确地说,IP把原始输入的交叉位重排成“先奇数位的主块、再偶数位的主块”这样的形态。这个置换的特殊之处在于,它输出的前32位正好成为L0,后32位成为R0。

3.1 IP置换表怎么读

举个例子,假设输入64位明文是

M = 00000001 00100011 01000101 01100111 10001001 10101011 11001101 11101111

我们给这64个位编号1到64。按IP表,输出的第1位来自原第58位,M的第58位是1;输出的第2位来自原第50位,M的第50位是1;依此类推。整张表排完后,得到的L0和R0分别是:

L0 = 11001100 00000000 11001100 11111111 R0 = 11110000 11001100 10101010 11111111

这两个值在后面的实操演示里会接着用,你可以自己拿笔对着表算一遍,结果能对上,说明你懂这套“按位搬家”的玩法了。

逆置换IP^-1是IP的精确逆操作,它把置换后的64位恢复到与原输入对应的位置。两张表在结构上完全对应,一个由过程中“打散”,一个在结尾“拼回”。它们之间看起来相似但顺序完全不同,千万别在代码里把两张表混用。

3.2 为什么这一层被批评为“华而不实”

密码学界对IP和IP^-1的评价一直很微妙。从功能上看,IP置换本身不增加任何安全性,因为它的映射关系是公开的、固定的,攻击者完全可以在分析时反向恢复,相当于在算法外面加了一层固定的“转码”。从硬件实现来看,当年的芯片设计喜欢这种固定连线式的布线方式,IP置换在硬件上只需要改变引脚的连接关系就能实现,成本几乎为零,但对于纯软件实现来说,IP置换反而成了性能负担。

后来AES在设计时干脆去掉了这种固定层,直接在软件层面用字节代换和扩散结构,效率更高。但这并不意味着学DES可以跳过IP——恰恰相反,理解IP会让你明白现代密码学是如何从“硬件友好”一步步走向“软硬件兼顾”的,也能帮你以后读其他经典算法时更快适应置换表风格的操作。

4. 密钥扩展:64位主密钥如何变成16轮48位子密钥

轮函数F是每轮都用的核心组件,但每一轮用的子密钥K_i不能是同一个,否则16轮的安全性会大打折扣。DES的密钥扩展(也叫密钥编排)就是干这件事的:把一把64位主密钥变成16把48位子密钥。

整个流程分三段:先做PC-1置换去掉8个奇偶校验位,再把剩下的56位分成两半各28位,接着两个半区分别按固定规则循环左移,最后把左右两个28位合并成56位后,做PC-2压缩置换,选出48位作为本轮子密钥。

4.1 PC-1:先裁掉8个奇偶校验位

64位主密钥K里的每个字节第8位是奇偶校验位,实际参与加密的只有56位。PC-1的输入是64位,输出是56位,它把52个数据位换位,并丢弃第8、16、24、32、40、48、56、64这8个校验位。

PC-1置换表:

输出位置 1-7:57 49 41 33 25 17 9 输出位置 8-14:1 58 50 42 34 26 18 输出位置 15-21:10 2 59 51 43 35 27 输出位置 22-28:19 11 3 60 52 44 36 输出位置 29-35:63 55 47 39 31 23 15 输出位置 36-42:7 62 54 46 38 30 22 输出位置 43-49:14 6 61 53 45 37 29 输出位置 50-56:21 13 5 28 20 12 4

PC-1输出的前28位记为C0,后28位记为D0。以教科书里很经典的测试密钥K = 0x133457799BBCDFF1为例,经过PC-1后得到:

C0 = 1111000011001100101010101111 D0 = 0101010101100110011110001111

注意C0和D0各自只有28位。后面每一步循环左移都是对C、D两个半区分别进行的,绝不要把它们合在一起再移位。

4.2 循环左移表:把所有子密钥错开

DES规定了一个每轮左移位数表:

第1轮:1 第2轮:1 第3轮:2 第4轮:2 第5轮:2 第6轮:2 第7轮:2 第8轮:2 第9轮:1 第10轮:2 第11轮:2 第12轮:2 第13轮:2 第14轮:2 第15轮:2 第16轮:1

循环左移的意思是:C和D分别在自己的28位范围内左移,溢出到最左侧的位回到最右侧。比如C0 = 1111000011001100101010101111左移1位就变成1110000110011001010101011111。

第i轮的子密钥由C_i和D_i合并后经过PC-2得到,而C_i和D_i是在C_{i-1}和D_{i-1}的基础上再左移相应位数得到的,所以每轮子密钥都不一样。这个移位表是有意设计的:左移1位和2位的交替,能保证16轮之间不存在明显的周期性重复。

4.3 PC-2:压缩置换到48位

C_i和D_i合并后是56位,但轮函数F需要48位子密钥,所以PC-2是一个56位输入、48位输出的置换表,它丢掉其中8位并重排顺序。

PC-2置换表:

输出位置 1-6:14 17 11 24 1 5 输出位置 7-12:3 28 15 6 21 10 输出位置 13-18:23 19 12 4 26 8 输出位置 19-24:16 7 27 20 13 2 输出位置 25-30:41 52 31 37 47 55 输出位置 31-36:30 40 51 45 33 48 输出位置 37-42:44 49 39 56 34 53 输出位置 43-48:46 42 50 36 29 32

以刚才的C1和D1为例,合并后经过PC-2,得到第1轮子密钥:

K1 = 000110 110000 001011 101111 111111 000111 000001 110010

这正是著名的教科书测试向量里的K1。建议你手动推一次这个结果:把K的64位逐位标号,按PC-1取位,两个半区各左移一位,再按PC-2取位,中间每一步都用二进制写出来。走完一遍,密钥扩展的整个机制就彻底通了。

子密钥生成过程是单向依赖的:K_i只依赖上一轮的C_{i-1}和D_{i-1},在代码里用一个循环顺次生成即可。解密时只需要先跑一遍密钥扩展得到全部16个子密钥,然后从K16逆序应用到K1。

5. 轮函数F:DES发动机的内部构造

有了子密钥,接下来看轮函数F。F的输入是32位的右半区R_{i-1}和48位的子密钥K_i,输出是32位结果,要与左半区L_{i-1}异或。F内部有四步:E扩展、与子密钥异或、S盒代换、P置换。

用一个生活化的比喻帮助你理解:F函数就是一台加工机器,先把32位原料扩展成48位“半成品”,然后与子密钥混合,再经过8个S盒分别做非线性压缩,最后把32位结果重新排列后输出。

5.1 E扩展:把32位撑到48位

E扩展的作用是让32位的右半区R变成48位,这样才能和48位子密钥异或。如果只是简单地加0填充,安全性和扩散性都很差。DES的E扩展用的是“重复边界位”的方式:把32位分成8组,每组4位,扩展后每组变成6位——第1位取前一组最后一位,最后一位取下一位第一位。

E扩展表:

输出位置 1-6:32 1 2 3 4 5 输出位置 7-12:4 5 6 7 8 9 输出位置 13-18:8 9 10 11 12 13 输出位置 19-24:12 13 14 15 16 17 输出位置 25-30:16 17 18 19 20 21 输出位置 31-36:20 21 22 23 24 25 输出位置 37-42:24 25 26 27 28 29 输出位置 43-48:28 29 30 31 32 1

看到没有,第1组的前3位来自R的第32、1、2位,最后一位来自第5位,边界位被重复使用了。这种扩展方式保证了任何一个输入位的改变,会影响至少两个输出组,进而影响多个S盒的输出,加速雪崩效应。

5.2 第1轮跟踪:子密钥异或

还是用之前的例子。R0 = 11110000 11001100 10101010 11111111,按照E扩展表重排后得到:

E(R0) = 111110 100001 011001 011001 010101 100101 011111 111111

然后与K1 = 000110 110000 001011 101111 111111 000111 000001 110010逐位异或,得到8个6位组:

A = 111000 010001 010010 110110 101010 011010 010110 001101

这里的异或运算没有技巧可言,就是每一位相同取0、不同取1。但这一步是整个F函数里唯一直接与密钥发生混合的地方,所以它承担着“把密钥信息渗透进数据”的重任。

5.3 S盒:非线性就靠它

S盒是整个DES里唯一引入非线性的环节,也是对抗线性密码分析和差分密码分析的关键。没有S盒,整个DES就会退化为一个仿射变换,用线性代数就能破解,那加密就形同虚设了。

E扩展异或后的48位会被切成8个6位组,分别进入8个不同的S盒S1到S8。每个S盒的输入是6位:最高位和最低位组成2位行号(0到3),中间4位组成列号(0到15),输出是4位。每个S盒都是一张4行16列的查找表,里面是0到15之间的十进制数。

以S1为例,标准查找表如下:

  • 第0行:14 4 13 1 2 15 11 8 3 10 6 12 5 9 0 7
  • 第1行:0 15 7 4 14 2 13 1 10 6 12 11 9 5 3 8
  • 第2行:4 1 14 8 13 6 2 11 15 12 9 7 3 10 5 0
  • 第3行:15 12 8 2 4 9 1 7 5 11 3 14 10 0 6 13

上面那一组A的第一个S盒输入是111000,行号取第1位和第6位,即10,对应第2行;列号取中间4位1100,即十进制12;查S1第2行第12列得到12,二进制是1100。八个S盒依次查完,输出拼在一起就是32位。

在我们的例子里,8个S盒查表后依次得到:

S1输出:1100 S2输出:1001 S3输出:1101 S4输出:1010 S5输出:1111 S6输出:0111 S7输出:0111 S8输出:1101

拼起来是:

S_out = 1100 1001 1101 1010 1111 0111 0111 1101

这里有一个初学者容易犯的错:查表时把行列搞反,或者忘记行号是首尾两位。记住口诀:外面两位定行,中间四位定列。

S盒的输出长度只有输入长度的三分之二,6位进、4位出,这本身就是信息压缩。8个S盒都设计成非线性的,且输出每一位对输入的任何一位都高度敏感。当年美国国家安全局参与了S盒设计的部分过程,外界一度怀疑其中藏着后门,直到后来公开的差分分析研究证明这些S盒对差分攻击具有相当强的抵抗力,这个争议才逐渐平息。

5.4 P置换:让雪崩效应更彻底

S盒输出32位后,最后一步是P置换。它的作用是把S盒输出的32位重新打散位置,特别是让每个S盒的输出位扩散到多个位置,影响下一轮的多个S盒输入。

P置换表:

输出位置 1-4:16 7 20 21 输出位置 5-8:29 12 28 17 输出位置 9-12:1 15 23 26 输出位置 13-16:5 18 31 10 输出位置 17-20:2 8 24 14 输出位置 21-24:32 27 3 9 输出位置 25-28:19 13 30 6 输出位置 29-32:22 11 4 25

把前面例子的S_out:1100 1001 1101 1010 1111 0111 0111 1101,按P置换表重排,得到最终F函数结果:

F(R0, K1) = 0010 1111 1111 1101 1110 1101 1110 1000

到这里,轮函数F的完整一次运算就走完了。

6. 手把手跑通第一轮:教科书数据全流程演示

前面已经把每个零件都拆开讲过,现在把它们组装起来,一起完整看一遍第一轮该怎么算。我选用经典的测试向量:明文M = 0x0123456789ABCDEF,密钥K = 0x133457799BBCDFF1,最终密文是公开教材里的0x85E813540F0AB405。如果你自己动手算,最后能对到这个结果,就说明整个流程没问题。

6.1 明文与密钥准备

把明文转成二进制:

M = 00000001 00100011 01000101 01100111 10001001 10101011 11001101 11101111

密钥转成二进制:

K = 00010011 00110100 01010111 01111001 10011011 10111100 11011111 11110001

注意密钥共64位,但第8、16、24、32、40、48、56、64位是奇偶校验位,在PC-1阶段会被丢掉。

6.2 初始置换拆出L0和R0

按之前IP表操作M,得到:

L0 = 11001100 00000000 11001100 11111111 R0 = 11110000 11001100 10101010 11111111

这一步值得用纸笔再核对一遍。把M的64个位编号,然后逐个对应IP表上的数字,你会看到输出前32位组成L0,后32位组成R0。

6.3 第一轮计算全过程

密钥扩展第1轮的子密钥我们前面已经算过:

K1 = 000110 110000 001011 101111 111111 000111 000001 110010

接下来计算F(R0, K1):

  1. E扩展:E(R0) = 111110 100001 011001 011001 010101 100101 011111 111111
  2. 异或K1:A = E(R0) XOR K1 = 111000 010001 010010 110110 101010 011010 010110 001101
  3. 八个S盒查表:S_out = 1100 1001 1101 1010 1111 0111 0111 1101
  4. P置换:F = 0010 1111 1111 1101 1110 1101 1110 1000

然后更新左右半区:

L1 = R0 = 11110000 11001100 10101010 11111111 R1 = L0 XOR F = 11100011 11111101 00100001 00010111

这一轮结束。第二轮开始前,R0已经变成L1,新的R1继续进下一轮轮函数。如此重复16次。

6.4 剩下15轮与最终密文

第2轮到第16轮的中间过程我就不展开手算了,但流程完全一样:每轮先用上一轮的R代入F函数,与对应子密钥K_i运算,再和上一轮的L异或,接下来左右互换。第16轮结束后,把左右交换得到R16 || L16,最后经过逆置换IP^-1得到密文:

密文 = 0x85E813540F0AB405

我用这套数据自己跑过完整实现,加密结果能精确对上这个值。你可以把它当作最终校验标准,如果自己写的代码加密结果对不上,优先检查子密钥顺序、左右交换时机和S盒查表索引这三个最容易出错的地方。

7. 安全性与工程实现里的那些坑

DES本身已经“退休”了,但围绕它的安全争议和实现坑,到现在依然值得反复念叨。

7.1 56位密钥为什么注定被暴力破解

56位密钥意味着有2的56次方种可能的密钥组合,这个数量大约是7.2乘以10的16次方。在上世纪70年代,这看起来是个天文数字;但在现代计算环境下,专门设计的硬件破解机可以在极短时间内穷举完所有可能。1998年前后,专用破解设备就能在几十小时内破解DES。换句话说,DES的密钥空间太小,无论算法结构多么精巧,都已经扛不住现代暴力攻击。

正因为如此,才有了3DES:用三个不同的DES密钥依次做加密-解密-加密,相当于把密钥长度提升到112位或168位,同时复用DES的成熟实现。3DES到现在某些旧系统里还在服役,但它的性能瓶颈和块长限制,最终让它被AES取代。

这给我们一个重要教训:分组密码的安全性,算法公开不是问题,密钥长度和结构设计才是根本。现代密码学里,算法的安全性应当完全取决于密钥,而不是算法的保密性。这一点在DES的生态上体现得淋漓尽致。

7.2 写DES代码时最容易翻车的三个点

第一,子密钥顺序。许多人写解密时忘了把子密钥数组倒过来用。哪怕前面加密全对,解密结果也会是乱码。正确的做法是先正常生成16个子密钥,解密时从K16开始依次用到K1。

第二,S盒行号取位。S盒输入是6位,行号是第1位和第6位拼接。很多人在代码里习惯性取“前四位”作为行号,这就全错了。我的经验是用位运算先把第0位和第5位提出来,再拼成行号,不要留给人肉拼接的机会。

第三,16轮结束后左右交换。这一点我前面专门提过,依然是最容易被忽略的。在第16轮循环的代码外面再交换一次L和R,再进逆置换,才能得到正确密文。很多教材伪代码里特意标注了这一步,就是因为太容易写漏。

还有一个小坑是位序问题。DES的位编号习惯是1到64,bit 1是最高位。如果你用普通字节数组存数据,要明确“数组第一个元素的高位”对应bit 1。不同实现里字节内位序的约定如果不一致,表和数据都会错位。这个问题很隐蔽,我调试时花了很长时间才定位到。

7.3 常常被误解的几个点

  • DES加密每个块互相独立,所以相同的明文块在相同密钥下会得到相同密文。这不是BUG,而是“电子密码本(ECB)”模式的特性,但它会泄露明文结构信息。实际系统里一般不用ECB,而用CBC、CTR等带随机性的工作模式。如果有人说“DES不可靠是因为同一明文加密结果相同”,那其实是模式选择问题,不是算法本身的问题。
  • DES的密钥不是64位,是56位。凡是把密钥位数说成64位的,都是把校验位也算进去了。
  • DES已经过时,但它的设计思想没有过时。Feistel结构、S盒去线性化、扩散与混淆的拆解,这些思路在AES、ChaCha20等现代算法身上依然能看到影子。

8. 最后一句经验之谈

我自己动手实现DES,前后踩了不少坑,最大的收获不是“我会写一个DES”,而是理解了“分组密码为什么必须这样设计”。当你亲手把一张置换表变成一行行代码,把S盒查表变成内存数组索引,再看着最终密文和教材对上的那一刻,很多算法书里干巴巴的概念才真正有了立体感。

如果你想学扎实,强烈建议不要只读不写。拿纸笔算第一轮,再用任意一门语言实现完整加密解密,最后用标准测试向量验证。这个过程走完,DES这块硬骨头才算是真正啃下来了。之后再去学AES、SM4这些算法,你会发现很多底层观念是相通的——因为它们都是在解决同一个问题:如何用公开的算法、够长的密钥,安全又高效地守护数据。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进