cdcq的密码学教程二——手把手教你5轮和8轮DES的差分攻击

一、前言

上节教程我们一起学习了 DES 的基本原理和代码实现,如果你还不会写 DES ,那么最好先学习一下这篇文章。地址在这里:https://cdcq.github.io/2022/01/06/20220106a/

这一节我们就来学习 DES 的差分攻击。读者们看到差分攻击这个名字也不用怕,这个算法听起来很神秘,但是原理却非常简单。只要耐心地看完每一个步骤,即使是没接触过密码学的人也可以破解 5 轮甚至 8 轮 DES 。此外本文也会有一些公式计算,希望读者能鼓起勇气,尝试理解每一个符号的含义,你就会发现原来差分攻击是这样的简单而有趣!另外,我们也可以从 8 轮攻击入手,具体地实践迭代分组密码的差分攻击的标准流程。

现在让我们开始吧。首先要说一点的是,差分攻击中的差分和数学中的差分其实是不一样的。在数学中,我们把一个数列 a1,a2,a3,...a_1, a_2, a_3, ... 相邻两个数之间做差,得到一个新的数列 a2a1,a3a2,...a_2 - a_1, a_3 - a_2, ... ,这个数列就叫做差分数列。而在密码学中,差分是两个比特串之间做异或操作。模 2 域上的加和减的结果是一样的,都相当于异或,所以两个比特串做差其实就是异或操作了。

为什么是差分呢?让我们复习一下 DES 的核心——轮函数:

(图 1 - DES 的轮函数)

可以看到,轮函数中起到保密作用的其实就在轮密钥加这一步。这时,聪明的密码学家们就注意到,两个明文 m1,m2m_1, m_2 的差分 m1m2m_1 \oplus m_2 ,和用同一个密钥 kk 异或过的差分 (m1k)(m2k)(m_1 \oplus k) \oplus (m_2 \oplus k) 是一样的!这是因为异或同一个东西两遍等于没有异或,而且异或运算有交换率,所以 (m1k)(m2k)=m1m2(kk)=m1m2(m_1 \oplus k) \oplus (m_2 \oplus k) = m_1 \oplus m_2 \oplus (k \oplus k) = m_1 \oplus m_2

于是我们绕过了密钥的随机性的影响,直接得到 S 盒的差分。接下來就可以根据 S 盒的差分特性来获取信息了。顺带提一点,差分攻击是选择明文攻击,也就是要可以用待破解的算法加密任意字节,并得到加密结果,才能实现攻击。

二、S 盒的差分分析

(图2 - S 盒的差分分析模型)

DES 能够使用差分攻击的原因在于 S 盒的差分分布具有不均匀性。也就是说对于相同的输入差分,不同的输入对应着不同的输出差分(注意区分输入和输入差分)。我们记输入差分为 α\alpha ,输出差分为 β\beta ,那么定义

INs(α,β)={x{0,1}8S(xa)S(x)=β}Ns(α,β)=#INs(α,β).\begin{aligned} IN_s(\alpha, \beta) = \{ x \in \{0, 1\}^8 \mid S(x \oplus a) \oplus S(x) = \beta \} \\ N_s(\alpha, \beta) = \#IN_s(\alpha, \beta) . \end{aligned}

其中 Ns(α,β)N_s(\alpha, \beta) 表示的是 INs(α,β)IN_s(\alpha, \beta) 的个数,称它为 S 盒的差分分布表。{0,1}8\{0, 1\}^8 是模 2 域 GF(2)GF(2) 的扩张,但我们不用管这个繁琐的概念,把它理解成是全体长度为 8 的比特串的集合就可以。

现在随意选取一个输入差分 α\alpha 、一对输入 (x,xα)(x, x \oplus \alpha) ,并且得到它们的输出差分 β\beta ,通过求 INs(α,β)IN_s(\alpha, \beta) 就可以知道经过密钥异或后的输入 zz 的取值范围 AA 。那么密钥 kk 的取值范围就是 {kk=zx,zA}\{ k \mid k = z \oplus x, z \in A \} ,而这个集合一般是 {0,1}8\{0, 1\}^8 的一个真子集,因此我们成功地排除掉了一些不可能的密钥。重复选取多对 α\alphaxx ,并不断对密钥的取值集合取交集,就可以将密钥的取值范围缩小到 1 个,这时我们就成功还原出了密钥。需要注意的是,如果只选择多对输入 xx ,而输入差分 α\alpha 保持不变,那么最后会剩下至少 2 个候选项,因为 kkkαk \oplus \alpha 都可以满足输入输出差分。由于 S 盒是标准公开的,所以我们在本地就可以自己运行这样的过程。

来看一个具体的例子,假设分析的 S 盒是第 1 个 S 盒,并且 k=23xk = 23_x ,其中下标 x 表示这是一个 16 进制数。现在选取 α=34x\alpha = 34_xx=1xx = 1_x ,那么得到

y=S1(1x23x)=1xy=S1(1x34x23x)=Cxβ=yy=Dx.\begin{aligned} y = S_1(1_x \oplus 23_x) = 1_x \\ y^* = S_1(1_x \oplus 34_x \oplus 23_x) = C_x \\ \beta = y \oplus y^* = D_x . \end{aligned}

查找 INs(34x,Dx)IN_s(34_x, D_x) 可以得到

z{6x,10x,16x,1Cx,32x,24x,22x,28x},z \in \{ 6_x, 10_x, 16_x, 1C_x, 32_x, 24_x, 22_x, 28_x \} ,

所以

k{7x,11x,17x,1Dx,33x,25x,23x,29}.k \in \{ 7_x, 11_x, 17_x, 1D_x, 33_x, 25_x, 23_x, 29 \} .

如果再选取 α=34x,x=21\alpha = 34_x, x=21 ,则可以将密钥的范围进一步缩小到 k{3x,0x,17x,37x,34x,23x}k \in \{ 3_x, 0_x, 17_x, 37_x, 34_x, 23_x \} 。继续选取不同的输入和输入差分就可以准确求出 k=23xk = 23_x ,这里就不继续列举了。

S 盒分析的代码实现也很简单,只需要遍历已经知道的密钥的取值集合,使用枚举的密钥计算,然后判断输出差分是否相同。所以这里暂不给出分析的代码,我会在讲到 5 轮 DES 差分攻击的代码时再提到它。

三、5 轮 DES 的差分攻击

对 S 盒的差分分析固然很有效,但 DES 的其他部件也不是吃素的,扩散性和混淆性使得这个算法很难分析。于是聪明的密码学家们指出:只需要选取合适的输入差分,就有办法求出最后一个 S 盒的输出差分。

现在取差分 (20000000x,00000000x)({20000000}_x, {00000000}_x) 作为 Feistel 结构的输入差分,可以得到如下的差分传播路径:

(图 3 - 一条差分路径)

在图中,用 a,b,c,da', b', c', d' 表示轮函数的输入差分,用 A,B,C,DA', B', C', D' 表示轮函数的输出差分,而 (L,R),(l,r)(L', R'), (l', r') 则分别表示 Feistel 结构的输入差分和输出差分。星号表示这个字节的差分是未知的,0 则肯定地说明这个字节的差分是 0 。注意,这里所提到的输入输出差分都是 Feistel 结构的差分,而不是 DES 的差分。不过 FP 和 IP 都是公开的,而且是一一置换,所以我们可以对输入做 IP 变换,而对输出做 FP 变换(还记得 IP 和 FP 互为逆变换吗),就可以构造出 Feistel 结构的输入差分,并且求出 Feistel 结构的输出差分,也就是 (l,r)(l', r')

我们看到,输入差分仅有 1 个比特为 1 ,在第 3 轮之后差异还没有完全扩散开。只需要对第 3 轮轮函数的输出做逆 P 盒,就可以得到一个第 1 和第 7 字节都是 0 的比特串。接下来则是 DES 差分攻击中最精彩的地方。写出最后一轮轮函数的输出差分 EE' 的表达式:

E=ld=lLAC.E' = l' \oplus d' = l' \oplus L' \oplus A' \oplus C' .

由于 P 盒也是一个一一置换,所以如果 P 盒的输入差分为 0 (输入没有区别),那么输出差分也为 0 (输出也没有区别)。那么有 A=P(00000000x)=00000000xA' = P(00000000_x) = 00000000_x 。所以

E=lLC=l20000000xP(00x).E' = l' \oplus L' \oplus C' = l' \oplus 20000000_x \oplus P(0*****0*_x) .

轮函数做完 S 盒之后再做一个 P 盒就输出了,所以只需对 EE' 做逆 P 盒,就得到 S 盒的输出差分。P 盒仅替换字节的顺序,有 P(ab)=P(a)P(b)P(a \oplus b) = P(a) \oplus P(b) 的性质,于是我们得到

P(E)=P1(l20000000xP(00x))=P1(l20000000x)P1(P(00x))=P1(l20000000x)00x.\begin{aligned} P(E') = P^{-1}(l' \oplus 20000000_x \oplus P(0*****0*_x)) \\ = P^{-1}(l' \oplus 20000000_x) \oplus P^{-1}(P(0*****0*_x)) \\ = P^{-1}(l' \oplus 20000000_x) \oplus 0*****0*_x . \end{aligned}

这意味着我们求出了第 1 和第 7 个 S 盒的输出差分!

至于输入差分,可以发现其实就是 Feistel 结构的输出差分 rr' 。有了输入和输出差分,就可以还原出最后一轮轮密钥的第 1 和第 7 个部分,总共 12 bit 密钥。剩下的部分可以选择其他的差分路径,我会在下一节代码实现中提到如何寻找差分路径。

四、5 轮攻击的代码实现

现在让我们来写代码吧。首先我们写出函数框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Bytes Dif(int x, Bytes d) {
int kflag[64] = {0};
for (int i = 0; i < 64; i++) {
kflag[i] = 1;
}
int cnt = 64;
std::independent_bits_engine<std::default_random_engine, 64, Bytes> engine;

while (cnt > 1) {
...
}

for (int i = 0; i < 64; i++) {
if (kflag[i]) {
return i;
}
}
return -1;
}

参数 x 表示现在对第几个 S 盒进行分析,d 则表示选定的输入差分。kflag 表示密钥空间,kflag[i] == 0 表示密钥 i 被排除,cnt 表示密钥空间的大小。此外我们还需要一个随机数发生器,这里我选择 independent_bits_engine

接下來来写每一轮迭代时对 S 盒的分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Bytes m1 = engine();
Bytes m2 = m1 ^ d;
Bytes c1 = IP(Encrypt(FP(m1), 5));
Bytes c2 = IP(Encrypt(FP(m2), 5));
Bytes e = c1 ^ c2;
Bytes L = d >> 32;
Bytes l = e >> 32;
Bytes r = e ^ l << 32;
Bytes a1 = Block(Expand(c1 & ((1ULL << 32) - 1)), 6, x);
Analyse(x, Block(Expand(r), 6, x), Block(ReP(l ^ L), 4, x), mflag);
for (int i = 0; i < 64; i++) {
if (kflag[i] && !mflag[a1 ^ i]) {
kflag[i] = 0;
cnt--;
}
}

这里边出现了几个函数,其中 engine 是调用随机数生成器生成一个 64 bit 的随机串;Encrypt 是一个自带密钥且只管加密的 DES ,参数 5 则表示进行 5 轮;Block 表示取某个 S 盒对应的块;Expand 是轮函数中的明文扩展算法;ReP 是 逆 P 盒;Analyse 则是对 S 盒的差分分析。它们的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void ReBox(int a[], int b[], int m, int n) {
for (int i = 0; i < n; i++) {
b[i] = -1;
}
for (int i = 0; i < m; i++) {
b[a[i]] = i;
}
}

Bytes ReP(Bytes a) {
int p[32] = {
15, 6, 19, 20, 28, 11, 27, 16,
0, 14, 22, 25, 4, 17, 30, 9,
1, 7, 23, 13, 31, 26, 2, 8,
18, 12, 29, 5, 21, 10, 3, 24
};
int r_p[32] = {0};
ReBox(p, r_p, 32, 32);
return Permutate(a, r_p, 32, 32);
}

Bytes Block(Bytes a, int b, int x) {
return a >> (7 - x) * b & ((1 << b) - 1);
}

void Analyse(int x, Bytes a, Bytes b, int flag[]) {
for (int i = 0; i < 64; i++) {
if ((S(x, i) ^ S(x, i ^ a)) == b) {
flag[i] = 1;
} else {
flag[i] = 0;
}
}
}

其中 ReBox 是求逆盒数组的;Permutate 是位置换操作,具体代码可以在上一篇介绍 DES 的博客找到;Analyse 中的 flag 参数是数组引用,对它的任何修改都会反映到调用处的实参,也就是 mflag 上,我通过这种方式来在 c++ 中返回一个数组;Block 中则有大量位运算,不熟悉优先级的读者建议多查查优先级表。

前面提到过,(20000000x,00000000x)({20000000}_x, {00000000}_x) 这条差分路径只能求出 12 bit 密钥,所以我们还需要寻找其他的差分路径。这里我选择的方法是随机 10610 ^ 6 次,每次都选择随机的明文和密钥,然后将每一次加密的 P1(C)P^{-1}(C') 全部或起来(不是异或哦)。如果某一个字节受到了差分扩散的污染,这一部分经过 10610 ^ 6 次却仍然全 0 的概率是极低的。这样做其实没什么道理,因为我们不清楚扩散的具体细节,但是运行效果却很好,这就足够了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Bytes Find(Bytes L) {
Bytes d = L << 32;

int cnt1 = 1000;
int cnt2 = 1000;

Bytes res = 0;
std::independent_bits_engine<std::default_random_engine, 64, Bytes> engine;
for (int i = 0; i < cnt1; i++) {
Bytes key = engine();
for (int j = 0; j < cnt2; j++) {
Bytes m1 = engine();
Bytes m2 = m1 ^ d;
Bytes d1 = IP(DES(FP(m1), key, 0, 3));
Bytes d2 = IP(DES(FP(m2), key, 0, 3));
Bytes c1 = ReP(d1 >> 32 ^ L);
Bytes c2 = ReP(d2 >> 32 ^ L);
Bytes c = c1 ^ c2;
res |= c;
}
}
return res;
}

至于要选择哪些差分来进行分析呢?可以选择所有 L=2i,R=0,i=0,1,,31L' = 2^i, R' = 0, i = 0, 1, \cdots , 31 来分析。就像前面提到的,每个差分只有 1 个比特为 1 ,污染其他比特的概率比其他情况小很多。

进行分析后从中选取一些合适的差分,然后调用之前写的 Dif 函数,就可以依次把每一个 S 盒对应的密钥求出来,从而还原出 48 bit 轮密钥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/*
Analyze 20000000:
0fffff0f
Analyze 04000000:
f0fff0ff
Analyze 00000002:
ff0ffff0
Analyze 00000200:
fff0f0ff
Analyze 00000020:
ffff0f0f
*/
Bytes d[8] = {
0x2000000000000000ULL,
0x0400000000000000ULL,
0x0000000200000000ULL,
0x0000020000000000ULL,
0x0000002000000000ULL,
0x0400000000000000ULL,
0x2000000000000000ULL,
0x0000000200000000ULL
};
Bytes keys[8] = {0};
Bytes key = 0;
for (int i = 0; i < 8; i++) {
keys[i] = Dif(i, d[i]);
printf("Key %d: %llu\n", i, keys[i]);
key = key << 6 | keys[i];
}
printf("48 bit final key: %012llx\n", key);

聪明的读者们一定注意到了,DES 的有效密钥长度有 56 bit ,怎么才求出 48 bit 呢。这是因为密钥扩展算法将 56 bit 密钥分配到了每一轮 48 bit 的轮密钥里,因此我们还需要暴力枚举剩下的 8 bit 才能还原出所有密钥。我的做法是根据密钥扩展算法中 PC2 的盒,求出最后一轮密钥舍弃了哪 8 bit ,并用一个字节串 mask 表示。如果有一位弃掉了,就让 mask 这一位置 1 。然后 mask 和轮密钥同步逆序执行密钥扩展算法的过程。最后暴力枚举 0 到 255 ,把这些字节分配到 mask 所指示的字节,并与轮密钥或起来,就可以枚举缺失的 8 bit 密钥了。把使用这个密钥的 DES 和要进行破解的 DES 的加密结果比较就可以判断是否正确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
Bytes Distribute(Bytes a, int b[], int m, int n) {
Bytes c = 0;
for (int i = m - 1; i >= 0; i--) {
c = c | ((a & 1) << (n - b[i] - 1));
a >>= 1;
}
return c;
}

Bytes ReKeygen(Bytes final_key, int round) {
final_key = RePC2(final_key);
int loss[8] = {8, 17, 21, 24, 34, 37, 42, 53};
Bytes mask = 0;
for (int i = 0; i < 8; i++) {
mask = mask | 1ULL << (55 - loss[i]);
}

Bytes l = final_key >> 28;
Bytes r = final_key ^ l << 28;
Bytes mask_l = mask >> 28;
Bytes mask_r = mask ^ mask_l << 28;
int offset[16] = {
1, 1, 2, 2, 2, 2, 2, 2,
1, 2, 2, 2, 2, 2, 2, 1
};

for (int i = round - 1; i >= 0; i--) {
l = Rotate(l, 28 - offset[i]);
r = Rotate(r, 28 - offset[i]);
mask_l = Rotate(mask_l, 28 - offset[i]);
mask_r = Rotate(mask_r, 28 - offset[i]);
}
Bytes key = RePC1(l, r);
mask = RePC1(mask_l, mask_r);

int mask_list[8] = {0};
for (int i = 63, j = 0; i >= 0; i--) {
if (mask & 1){
mask_list[j] = i;
j++;
}
mask >>= 1;
}
Bytes guess_key = 0;
for (int i = 0; i < 1 << 8; i++) {
guess_key = Distribute(i, mask_list, 8, 64) | key;
if (Encrypt(0x1234567890abcdefULL, 5) == DES(0x1234567890abcdefULL, guess_key, 0, 5)) {
printf("Ye!\n");
printf("Key: %016llx\n", guess_key);
return guess_key;
}
}

return 0;
}

其中 Distribute 就是将猜测的 8 bit 密钥分配到 mask 指示的位置的;RePC1 和 RePC2 的写法和 ReP 一致;Rotate 是密钥扩展算法中的循环左移过程,代码可以在我上一篇讲 DES 的文章找到。

这下我们终于得到了所有的有效密钥,成功破解了 5 轮 DES 。

五、差分攻击的标准流程

当轮数超过 5 轮时,中间过程的差分值就很难确定了。但聪明的科学家们又发现,某些差分值仍然会以更高的概率出现,这种不均匀性就为获取信息提供了可能。

现在来做几个定义:

定义 1(差分值)X,X{0,1}nX, X^* \in \{0, 1\}^n ,则 XXXX^* 的差分值定义为 ΔX=XX\Delta X = X \oplus X^*

定义 2(差分对)α,β{0,1}n\alpha , \beta \in \{0, 1\}^n ,假设迭代分组密码的输入对 (X,X)(X, X^*) 的差分值为 XX=αX \oplus X^* = \alpha ,经过 ii 轮加密后,输出对 (Yi,Yi)(Y_i, Y_i^*) 的差分值为 YiYi=βiY_i \oplus Y_i^* = \beta_i ,则称 (α,β)(\alpha, \beta) 为该密码的一个 ii 轮差分对。

定义 3(差分特征) 迭代分组密码的一条 ii 轮差分特征 Ω=(β0,β1,,βi)\Omega = (\beta_0, \beta_1, \cdots, \beta_i) 是指当输入对 (X,X)(X, X^*) 的差分值满足 XX=β0X \oplus X^* = \beta_0 时,ii 轮加密的中间状态 (Yj,Yj)(Y_j, Y_j^*) 的差分值满足 YjYj=βjY_j \oplus Y_j^* = \beta_j ,其中 j=1,2,,ij = 1, 2, \cdots , i

差分特征也称差分路径,它与差分对的区别在于差分对只规定了输入和输出差分,而差分路径则指定了中间状态的差分值。这三个定义我们在前文已经接触过,但现在最好还是规范地定义一下。

定义 4(差分概率) 迭代分组密码的一条 ii 轮差分 (α,β)(\alpha, \beta) 所对应的概率 DP(α,β)DP(\alpha, \beta) 是指在输入 XX 、轮密钥 K1,K2,,KiK_1, K_2, \cdots, K_i 取值独立且均匀分布的情况下,差分对为 (α,β)(\alpha, \beta) 的概率。特别的,当 i=1i=1 时,DP(α,β)DP (\alpha, \beta) 表示轮函数的差分传播概率。

定义 5(差分特征概率) 迭代分组密码的一条 ii 轮差分特征 Ω=(β0,β1,,βi)\Omega = (\beta_0, \beta_1, \cdots, \beta_i) 的概率 DP(Ω)DP(\Omega) 是指在输入 XX 、轮密钥 K1,K2,,KiK_1, K_2, \cdots, K_i 取值独立且均匀分布的情况下,差分特征为 Ω\Omega 的概率。

i>1i > 1 时,差分概率和差分特征概率存在这样的关系:

DP(Ω)=j=1iDP(βj1,βj)DP(\Omega) = \prod_{j=1}^{i} DP(\beta_{j-1}, \beta_j)

定义 6(正确对与错误对) 给定一条差分特征 Ω=(β0,β1,,βi)\Omega = (\beta_0, \beta_1, \cdots, \beta_i) ,若输入对 (X,X)(X, X^*) 在加密过程中的中间状态的差分满足差分特征 Ω\Omega ,则称 (X,X)(X, X^*) 对于 Ω\Omega 是一个正确对,否则是错误对。在输入对和轮密钥随机且均匀选取的情况下,输入对是正确对的概率就是 DP(Ω)DP(\Omega)

定义 7(差分区别器) 对于 {0,1}n\{0, 1\}^n 上的一个随机置换,给定任意非零差分 (α,β)(\alpha, \beta) ,则 DP(α,β)=12n112nDP(\alpha, \beta) = \frac{1}{2^n - 1} \approx \frac{1}{2^n} 。如果找到了一条 r1r - 1 轮差分,其概率大于 12n\frac{1}{2^n} ,就可以将 r1r - 1 轮的加密算法与随机置换区分开。这种差分就叫做差分区别器。

有了以上概念,我们就可以给出差分攻击的步骤:

步骤 1. 选择一条 r1r-1 轮的高概率差分 (α,β)(\alpha, \beta) 作为区别器,设它的概率是 pp 。这里 β\beta 可以有不确定的字节。

步骤 2. 根据区别器的输出差分,确定有哪些密钥比特可以进行攻击。假设攻击的密钥量为 ll bit ,那么对于每一个候选密钥 gki,i=0,1,,2l1gk_i, i = 0, 1, \cdots, 2^l - 1 ,都设置一个计数器 λi\lambda_i 并初始化为 0 。

步骤 3. 均匀随机地取明文 XX ,计算 X=XαX^* = X \oplus \alpha ,并在同一未知密钥下加密,获得相应的密文 ZZZZ^* 。这里选择明文对的数目为 mc1pm \approx c\cdot \frac{1}{p}cc 是某个常数。

步骤 4. 根据区分器的输出,先尽可能地根据步骤 3 中获得的所有密文过滤掉错误对,然后分别用每一个候选密钥 gkigk_i 对其进行解密,并计算解密后的差分。如果解密之后的差分与 β\beta 一致,就将对应的计数器 λi\lambda_i 加 1 。

步骤 5. 选取 2l2^l 个计数器中明显大于其他其他计数器的对应密钥作为攻击得到的密钥。

上述步骤就是差分攻击的标准流程,而之前的 5 轮攻击其实只是一个简化版的过程。

像这种在 r1r - 1 轮差分区别器后再加一轮的攻击称为 1-R 攻击。如果在区别器后添加 t 轮进行攻击则叫做 t-R 攻击。

这里可能会有几个问题:

(1) 如何根据 β\beta 确定有那些密钥可以破解呢?这个问题的思路和 5 轮攻击的思路是一样的,在对输出做逆 P 盒、对输入做明文扩展算法之后,每一个 S 盒的输入、输出以及对应的密钥都是相互独立的。如果 β\beta 中有某一块差分可以确定(在正确对中),那么就能破解这一个 S 盒的密钥。利用这个特点也可以通过对 S 盒的差分分析来过滤错误对。

(2) 如何判断最后一轮解密的结果正确呢?答案是错误密钥假设原理:当攻击所猜测的轮密钥错误时,对最后一轮的解密相当于进行一次额外的加密,此时得到的结果会更加随机。所以如果发现解密后的差分与 β\beta 一致,就有相对错误密钥更高的概率认为密钥猜测正确。

(3) 选择明文对的数量 mc1pm \approx c \cdot \frac{1}{p} 又如何来确定呢?这个问题较为复杂,在差分攻击的原始文献中,Biham 和 Shamir 引入了信躁比的概念。这里只给出具体的计算方法,至于其背后的原理,读者可以参考《分组密码的攻击方法与实例分析》一书(事实上,这篇文章的大多数内容都来自这本书)。如果不容易看懂,也不影响后面实现 8 轮攻击,看不懂的读者可以跳过。

假设过滤错误对的平均强度为 λ,0<λ1\lambda, 0 < \lambda \leq 1 ,每一个密文平均对应的密钥个数为 ν\nu ,那么所有猜测的密钥平均被统计了 mλν2l\frac{m \cdot \lambda \cdot \nu}{2^l} 次。而其中正确密钥期望被统计了 mpm \cdot p 次,二者之比就定义为信躁比:

S/N=mpmλν/2l=2lpλν.S/N = \frac{m \cdot p}{m \cdot \lambda \cdot \nu / 2^l} = \frac{2^l \cdot p}{\lambda \cdot \nu}.

信躁比越大,正确密钥被统计的次数越多,这样就更能与其他密钥区分开,需要选择的明文数量就会变小。从公式中可以看出一个非常有用的信息,那就是提高攻击的信躁比的方法:提高攻击猜测的密钥量 ll 、寻找更高概率的差分、以及降低 λν\lambda \cdot \nu (强度的定义是值越高,剩下的差分对越多)。至于明文对的数量,Biham 和 Shamir 在对 DES 进行大量分析后总结出如下结论:

  1. S/N=12S/N = 1 \sim 2 时,cc204020 \sim 40
  2. S/NS/N 取较大值,cc343 \sim 4
  3. S/NS/N 取较小值,cc 需要取更大值。

差分攻击还有许多理论上的细节,同样推荐想了解更多细节的读者阅读《分组密码的攻击方法与实例分析》。但现在我们已经知道了差分攻击的基本原理,可以尝试破解 8 轮 DES 了。

六、8 轮 DES 的差分攻击

对 8 轮 DES 的攻击也从选择差分路径开始。这次我们选择 (405C0000x,04000000x)({405C0000}_x, {04000000}_x) 作为输入差分。它的差分路径和差分特征概率如图:

(图 4 - 一条 5 轮差分路径)

这条路径的差分特征概率为

PΩ=1664×(1064×1664)×1×(1064×1664)×1664213.36.P_{\Omega} = \frac{16}{64} \times ( \frac{10}{64} \times \frac{16}{64} ) \times 1 \times ( \frac{10}{64} \times \frac{16}{64} ) \times \frac{16}{64} \approx 2^{-13.36} .

和 5 轮攻击类似,路径中第 6 轮轮函数输出做逆 P 盒后第 2, 5, 6, 7, 8 字节均为 0 。所以在正确对中,有

H=lg=lP(00000x)04000000xP1(H)=P1(l04000000x)00000x.\begin{aligned} H' = l' \oplus g' = l' \oplus P({*0**0000}_x) \oplus {04000000}_x \\ P^{-1}(H') = P^{-1}(l' \oplus {04000000}_x) \oplus {*0**0000}_x . \end{aligned}

只需猜测第 8 轮进入 S2,S5,S6,S7,S8S_2, S_5, S_6, S_7, S_8 的共 l=30l = 30 bit 的轮密钥,然后计算这 5 个 S 盒的输出差分,判断其是否与 P1(l04000000x)P^{-1}(l' \oplus {04000000}_x) 中对应的分量相等。如果相等,就在对应的密钥计数器上加 1 。此外,可以先假设输入是正确对,并且计算 5 个 S 盒的差分对。如果发现其中任意一个 S 盒的差分分布表项为 0 ,则表示这一定是错误对,可以直接筛除掉。

这次我们选择了一个 5 轮差分路径,并在其后接上 3 轮的分析来破解密钥,因此这是一个 3-R 攻击。

下面计算信躁比并确定选择明文对的数量。S 盒差分分布表中值为 0 的概率约为 20%20\% ,从而每个 S 盒的过滤强度约为 0.80.8 ,5 个 S 盒的过滤强度约为 λ0.85=0.32768\lambda \approx 0.8^5 = 0.32768 。由于 S 盒是 6 进 4 出的查表变换,所以差分分布表的平均值为 2624=4\frac{2^6}{2^4} = 4 ,而一次攻击猜测 5 个 S 盒对应的密钥,所以 ν=45=210\nu = 4^5 = 2^{10} 。因此这个攻击的信躁比为

S/N=2lpλν=230213.360.85210300.S/N = \frac{2^l \cdot p}{\lambda \cdot \nu} = \frac{2^{30} \cdot 2^{-13.36}}{0.8^5 \cdot 2^{10}} \approx 300 .

所以取 c=3c = 3 就可以了,选择明文对的数目为 m=c1PΩ215m = c \cdot \frac{1}{P_{\Omega}} \approx 2 ^ {15}

七、8 轮攻击的代码实现

虽然在分析上我们已经找到了 8 轮攻击的方法,但是实际运行起来会发现跑不出结果,因此还需要在运算上做一些优化。

第一个问题是每次猜测 30 bit 的密钥量太大了,需要的储存空间约为 2304Byte=4GB2^{30} \cdot 4 Byte = 4GB 。一个折中的方案是每次只猜 18 bit ,相应地提高选择的明文个数。我的实践经验是 2182 ^ {18} 个明文可以得到满意的结果。

第二个问题是每次枚举一整个密钥再解密,实际上有很多运算是浪费的。这是因为每一个 S 盒的加密都是独立的,一个 S 盒的加密结果和其他 S 盒组合多次之后就要重复计算多次。我的解决方案是先计算出每个 S 盒的加密结果,求出每个 S 盒的哪些密钥是满足要求的,然后再用 DFS 把满足要求的所有情况组合起来,并让计数器加 1 。

第三个问题是还原出 30 bit 密钥之后还需要再暴力枚举 18 bit 密钥才能得到 48 bit 轮密钥,然后再执行反密钥扩展算法枚举剩下的 8 bit 。这里每一次暴力枚举都是相互独立的,因此有多线程计算的优化空间。我的代码中使用了 8 线程,效果显著,并且可以根据处理器核心数手动调整。

现在来看差分攻击的函数代码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
void DFS(int x, Bytes key, Bytes mask1, int block_cnt[8][64], int cnt[]) {
if (x == 8) {
cnt[key]++;
return ;
}
if (!Block(mask1, 6, x)) {
DFS(x + 1, key, mask1, block_cnt, cnt);
return ;
}
for (int i = 0; i < 64; i++) {
if (block_cnt[x][i]){
DFS(x + 1, key << 6 | i, mask1, block_cnt, cnt);
}
}
}

Bytes Dif(Bytes d, Bytes D, Bytes mask, Bytes mask1) {
int m_cnt = 1 << 18;
int mask_cnt = 0;
for (int i = 0; i < 8; i++) {
if (Block(mask1, 6, i)) {
mask_cnt+=6;
}
}
int cnt[1 << mask_cnt] = {0};

std::independent_bits_engine<std::default_random_engine, 64, Bytes> engine;
while(m_cnt --> 0) {
Bytes m1 = engine();
Bytes m2 = m1 ^ d;
Bytes c1 = IP(Encrypt(FP(m1), 8));
Bytes c2 = IP(Encrypt(FP(m2), 8));
Bytes e = c1 ^ c2;
Bytes l = e >> 32;
Bytes r = e ^ l << 32;
Bytes b = ReP(l ^ D);
Bytes a = Expand(r);
if (!Check1(a, b, mask)) {
m_cnt++;
continue;
}

Bytes a1 = Expand(c1 & ((1ULL << 32) - 1));
Bytes a2 = Expand(c2 & ((1ULL << 32) - 1));

int block_cnt[8][64] = {0};
for (int i = 0; i < 8; i++) {
if (!Block(mask1, 6, i)) {
continue;
}
for (int j = 0; j < 64; j++) {
if (!(S(i, Block(a1, 6, i) ^ j) ^ S(i, Block(a2, 6, i) ^ j) ^ Block(b, 4, i))) {
block_cnt[i][j]++;
}
}
}
DFS(0, 0, mask1, block_cnt, cnt);
}

double ave = 0;
for (int i = 0; i < 1 << mask_cnt; i++) {
ave += cnt[i];
}
ave /= 1 << mask_cnt;
printf("Averange: %.3lf\n", ave);
int mx = 0;
for (int i = 0; i < 1 << mask_cnt; i++) {
if (cnt[i] > cnt[mx]) {
mx = i;
}
}
for (int i = 0; i < 1 << mask_cnt; i++) {
if (cnt[i] > cnt[mx] * 0.8) {
printf("%d: %d\n", i, cnt[i]);
}
}
return MaskPermutate(mx, mask1, mask_cnt, 48);
}

参数 d 表示 Feistel 结构的输入差分;参数 D 是差分路径中第 5 轮输出的右半部分,用来求出最后一轮的 S 盒输出差分;参数 mask 表示总共有哪些密钥比特需要破解,用法和反密钥扩展算法中的一样,如果第 i 个比特需要破解,那么 mask 的第 i 位就为 1 ;参数 mask1 则是本次运行所求的密钥比特。

函数中求最后一轮 S 盒差分对的代码和 5 轮攻击基本一致。接着只需用一个 block_cnt 来表示每个 S 盒中的哪些密钥符合要求。Block 函数的在这里的作用则是把 mask 对应位置的 bit 提取出来,来判断当前 S 盒是否需要分析。求得 block_cnt 后执行 DFS 函数组合所有可能的情况,并让计数器加 1 。最后还需要用一个标准来找出数量有明显优势的密钥。我的策略是找出计数器最大值,如果其它所有计数器都没有超过它的 80%80\% ,那么就找到了密钥。

在主函数中则使用了多线程计算来加速:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void BruteForce(int x, Bytes mask3, Bytes key, int *flag_point) {
Bytes final_key = MaskPermutate(x, mask3, 18, 48) | key;
if (ReKeygen(final_key枚举, 8)) {
*flag_point = 1;
}
}

int main() {
std::time_t t = std::time(0);
Bytes mask = 0b000000111111000000000000111111111111111111111111ULL;
Bytes mask1 = 0b000000111111000000000000111111111111000000000000ULL;
Bytes mask2 = 0b000000000000000000000000000000111111111111111111ULL;
Bytes key1 = Dif(0x405C000004000000ULL, 0x04000000ULL, mask, mask1);
Bytes key2 = Dif(0x405C000004000000ULL, 0x04000000ULL, mask, mask2);
Bytes key = key1 | key2;
printf("30 bit key: %llu\n", key);
Bytes mask3 = mask ^ ((1ULL << 48) - 1);
int th_cnt = 8;
std::thread ths[th_cnt];
int flag = 0;
int *flag_point = &flag;
for (int i = 0; i < 1 << 18; i += th_cnt) {
if (i % 8192 == 0) {
printf("%.0lf%%\n", i * 100.0 / (1 << 18));
fflush(stdout);
}
for (int j = 0; j < th_cnt; j++) {
ths[j] = std::thread(BruteForce, i + j, mask3, key, flag_point);
}
for (int j = 0; j < th_cnt; j++) {
ths[j].join();
}
if (*flag_point) {
break;
}
}
printf("Time: %ds\n", (int)(std::time(0) - t));
return 0;
}

th_cnt 是线程的数量,每次取 8 个要猜测的密钥,并用 std::thread 创建线程,在之后要用 join 函数等待线程完成。flag_point 指针则是为了判断是否有线程完成了枚举。

尽管我的电脑性能一般,但是由于充分利用了 CPU 的性能,所以只需要 3-4 分钟就可以还原出所有密钥。

八、总结

当然对 DES 的差分分析没有就此结束。对于更高轮的 DES 算法的差分攻击,需要攻击者寻找高概率的迭代差分特征,然后将迭代特征进行极联来构造高轮数的差分特征。DES 存在两条概率为 1234\frac{1}{234} 的 2 轮迭代特征,攻击者可以用它们构造出概率为 2562^{-56} 的差分区别器。此外,Biham 在 Crypto1992 上提出了对 16 轮完整 DES 算法的差分攻击。这个攻击方法构造出 13 轮差分特征,通过在此前加 1 轮,后面加 2 轮的方式来完成对 16 轮密码的完整攻击。攻击中采用了大量的技巧,将数据复杂度降低到了 2472^{47} ,这是目前差分密码分析对 DES 最好的攻击结果。至此,我们终于可以宣告 DES 的终结了。

从简单的一个 S 盒的分析,再到 5 轮的差分分析,再到 8 轮的概率攻击,我们体验了从无到有,一步一步破解一个复杂算法的过程。相信认真看完并实践的读者也能跟我一样,有种经历了一段辛苦的旅途,最终抵达终点的感觉。但我们的密码学之旅才刚刚启程。这篇文章和上一篇介绍 DES 的文章是我写的头两篇密码学教程,我计划把他们写成一个系列,带领读者们继续认识 RSA 、AES 等好玩的算法,并尝试攻破它们(至少是一部分)。期望能和大家在以后的文章中再会!