从零实现 SPN 密码体制并完成差分攻击:C++ 实验详解
从零实现 SPN 密码体制并完成差分攻击:C++ 实验详解
相信大家在信息安全学习过程中,绕不开密码体制基础的学习。那作为初学者最先接触的加密体制应该就是 SPN 密码体制了,在本文中将围绕一个 4 轮、16 位分组的 SPN(Substitution-Permutation Network,替换-置换网络)密码体制展开,先实现基础加密算法,再构造 S 盒差分分布表(DDT),最后利用 3 轮差分特征对最后一轮子密钥进行统计恢复。
目录
1. SPN 密码体制简介
SPN 的全称是 Substitution-Permutation Network,中文一般称为 替换-置换网络。
它是很多现代分组密码的重要设计思想,基本结构由三类操作反复组成:
| 操作 | 作用 | 本实验中的体现 |
|---|---|---|
| 轮密钥异或 | 引入密钥相关性 | 明文或中间状态与子密钥异或 |
| S 盒替换 | 提供非线性混淆 | 4 bit 输入映射到 4 bit 输出 |
| P 盒置换 | 提供扩散 | 将 16 位状态重新排列 |
通俗地说:
-
S 盒让输入和输出之间的关系变得非线性;
-
P 盒让某一小块上的差异扩散到更多位置;
-
轮密钥异或让攻击者不能只看公开算法,还必须恢复密钥。
2. 实验参数与加密流程
2.1 实验参数
| 参数 | 取值 |
|---|---|
| 分组长度 | 16 位 |
| 主密钥长度 | 80 位 |
| 轮数 | 4 轮 |
| S 盒规模 | 4 × 4 |
| 每轮 S 盒数量 | 4 个 |
| 子密钥数量 | 5 个,记为 K1 ~ K5 |
2.2 加密流程
本实验中的 SPN 加密流程如下:
明文 x(16 bit)
│
├── ⊕ K1
│
├── 第 1 轮:S 盒替换 → P 盒置换
│
├── ⊕ K2
│
├── 第 2 轮:S 盒替换 → P 盒置换
│
├── ⊕ K3
│
├── 第 3 轮:S 盒替换 → P 盒置换
│
├── ⊕ K4
│
├── 第 4 轮:S 盒替换
│
├── ⊕ K5
│
密文 y(16 bit)
需要注意的是:最后一轮不再执行 P 盒置换。这也是差分攻击中常见的设计切入点,因为攻击者可以从密文端对最后一轮进行部分逆推。
2.3 S 盒
本实验采用的 4 bit S 盒如下:
| 输入 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 输出 | E | 4 | D | 1 | 2 | F | B | 8 | 3 | A | 6 | C | 5 | 9 | 0 | 7 |
S 盒负责提供非线性,是差分攻击分析的重点。
2.4 P 盒
P 盒定义为:
P = {0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15}
如果第 i 位为 1,则它会被移动到第 P[i] 位。
3. SPN 加密代码实现
下面是完整的 SPN 加密代码。
代码结构主要分为四部分:
-
定义 S 盒和 P 盒;
-
从主密钥中截取子密钥;
-
实现 S 盒替换与 P 盒置换;
-
按轮函数执行 SPN 加密。
#define _CRT_SECURE_NO_WARNINGS 1
#include <bits/stdc++.h>
using namespace std;
// S-box
const int S_BOX[16] = {
0xE, 0x4, 0xD, 0x1, 0x2, 0xF, 0xB, 0x8,
0x3, 0xA, 0x6, 0xC, 0x5, 0x9, 0x0, 0x7
};
// P-box
const int P_BOX[16] = {
0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15
};
string clean(string s) {
s.erase(remove(s.begin(), s.end(), ' '), s.end());
return s;
}
// 获取子密钥Kr
uint16_t getSubKey(const string& fullKey, int r) {
string sub = fullKey.substr(4 * r - 4, 16);
return (uint16_t)bitset<16>(sub).to_ulong();
}
// S-box 替换
uint16_t applySBox(uint16_t u) {
uint16_t v = 0;
for (int i = 0; i < 4; ++i) {
int temp = (u >> (4 * (3 - i))) & 0xF;
v |= (S_BOX[temp] << (4 * (3 - i)));
}
return v;
}
// P-box 置换
uint16_t applyPBox(uint16_t v) {
uint16_t w = 0;
for (int i = 0; i < 16; ++i) {
if ((v >> (15 - i)) & 1)
w |= (1 << (15 - P_BOX[i]));
}
return w;
}
// SPN 加密主函数
uint16_t encryptSPN(string x_str, string K_str, int Nr) {
uint16_t w = (uint16_t)bitset<16>(x_str).to_ulong();
for (int r = 1; r <= Nr - 1; ++r) {
uint16_t u = w ^ getSubKey(K_str, r);
uint16_t v = applySBox(u);
w = applyPBox(v);
}
uint16_t u_last = w ^ getSubKey(K_str, Nr);
uint16_t v_last = applySBox(u_last);
uint16_t y = v_last ^ getSubKey(K_str, Nr + 1);
return y;
}
int main() {
string raw_x, raw_K;
if (!getline(cin, raw_x)) return 0;
if (!getline(cin, raw_K)) return 0;
string clean_x = clean(raw_x);
string clean_K = clean(raw_K);
uint16_t result = encryptSPN(clean_x, clean_K, 4);
bitset<16> outBits(result);
string outStr = outBits.to_string();
for (int i = 0; i < 16; ++i) {
cout << outStr[i];
if ((i + 1) % 4 == 0) cout << " ";
}
cout << endl;
return 0;
}
代码阅读重点
这段代码里最关键的是 encryptSPN() 函数:
for (int r = 1; r <= Nr - 1; ++r) {
uint16_t u = w ^ getSubKey(K_str, r);
uint16_t v = applySBox(u);
w = applyPBox(v);
}
前三轮都执行:
异或子密钥 → S 盒替换 → P 盒置换
最后一轮则执行:
异或 K4 → S 盒替换 → 异或 K5
这使得攻击者可以对密文异或候选 K5 后,再通过逆 S 盒回推最后一轮 S 盒输入差分。
4. 差分攻击基本思想
差分密码分析的核心是研究:
输入差分 Δx 会以什么概率传播成输出差分 Δy?
其中差分一般使用异或定义:
Δ x = x ⊕ x ′ \Delta x = x \oplus x' Δx=x⊕x′
Δ y = y ⊕ y ′ \Delta y = y \oplus y' Δy=y⊕y′
攻击者会选择大量明文对:
(x, x')
并让它们满足固定输入差分:
x ⊕ x' = Δx
然后观察对应密文对:
(y, y')
如果某条差分路径概率明显高于随机情况,那么正确密钥候选在统计计数中会更容易出现高票。
5. S 盒差分分布表 DDT
5.1 DDT 的定义
对于一个 4 bit 输入、4 bit 输出的 S 盒,差分分布表定义为:
D D T [ Δ x ] [ Δ y ] = ∣ { x ∈ { 0 , 1 } 4 : S ( x ) ⊕ S ( x ⊕ Δ x ) = Δ y } ∣ DDT[\Delta x][\Delta y]=\left|\{x \in \{0,1\}^4 : S(x) \oplus S(x \oplus \Delta x) = \Delta y\}\right| DDT[Δx][Δy]= {x∈{0,1}4:S(x)⊕S(x⊕Δx)=Δy}
也就是说,DDT 统计的是:
当输入差分为
Δx时,有多少个输入值会产生输出差分Δy。
5.2 本实验 S 盒的关键差分
| 输入差分 Δx | 输出差分 Δy | 出现次数 | 概率 |
|---|---|---|---|
| 0x0 | 0x0 | 16 | 1 |
| 0xB | 0x2 | 4 | 1/4 |
| 0xB | 0x6 | 4 | 1/4 |
| 0x4 | 0x6 | 4 | 1/4 |
除去平凡差分 0x0 → 0x0 之外,本实验 S 盒最大差分概率为:
4 16 = 1 4 \frac{4}{16} = \frac{1}{4} 164=41
这个概率并不低,因此可以用来构造差分攻击路径。
6. 本实验的差分路径设计
6.1 攻击目标
本实验不直接恢复完整 80 位主密钥,而是恢复最后一轮子密钥 K5 中的部分 nibble。
在程序中,攻击目标是:
K5 的 nibble1 和 nibble3
也就是 16 位 K5 中两个 4 bit 分组。
6.2 选择输入差分
选择的明文输入差分为:
Δx = 0x0B00
它表示只有第 2 个 nibble 被激活,其他 nibble 差分为 0。
6.3 目标差分
经过前三轮差分传播后,期望到达第四轮输入位置的差分为:
Δu4 = 0x0606
也就是两个目标 nibble 的差分都为 0x6。
6.4 差分路径示意
输入差分:
Δx = 0x0B00
│
▼
第 1 轮:
S 盒中使用高概率差分 0xB → 0x2
│
▼
P 盒扩散:
差分被扩散到其他 nibble
│
▼
第 2 轮:
多个活跃 S 盒继续传播差分
│
▼
第 3 轮:
形成目标差分 Δu4 = 0x0606
│
▼
最后一轮:
枚举 K5 的目标 nibble,逆 S 盒验证差分
6.5 差分特征概率估计
如果路径中一共使用了 5 次概率为 1/4 的 S 盒差分,则整体概率约为:
p = ( 1 4 ) 5 = 1 1024 = 2 − 10 p = \left(\frac{1}{4}\right)^5 = \frac{1}{1024} = 2^{-10} p=(41)5=10241=2−10
这意味着平均每 1024 对明文中,大约会有 1 对符合该差分特征。
为了让统计结果更稳定,通常需要更多明文对:
N ≈ c p N \approx \frac{c}{p} N≈pc
其中 c 是统计显著性常数,取 4 到 8 时,大约需要:
4000 ~ 8000 对明文
实验代码中使用了 10000 对明文对。
7. 差分攻击代码实现
下面是完整差分攻击程序。
程序主要做了 5 件事:
-
构建 S 盒差分分布表 DDT;
-
随机生成满足
x2 = x1 ⊕ 0x0B00的明文对; -
调用 SPN 加密得到密文对;
-
枚举 K5 的两个目标 nibble;
-
对最后一轮做部分逆推,统计符合
Δu4 = 0x0606的候选密钥。
#define _CRT_SECURE_NO_WARNINGS 1
#include <bits/stdc++.h>
using namespace std;
// SPN 密码体制参数
// S-BOX
const int SBOX[16] = {
0xE, 0x4, 0xD, 0x1, 0x2, 0xF, 0xB, 0x8,
0x3, 0xA, 0x6, 0xC, 0x5, 0x9, 0x0, 0x7
};
// SBOX 的逆
const int SBOX_INV[16] = {
0xE, 0x3, 0x4, 0x8, 0x1, 0xC, 0xA, 0xF,
0x7, 0xD, 0x9, 0x6, 0xB, 0x2, 0x0, 0x5
};
// P-BOX
const int PBOX[16] = {
0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15
};
//找出来本轮要用的子密钥
uint16_t setsubkey(const string& fullKey, int r) {
string sub = fullKey.substr(4 * r - 4, 16);
return (uint16_t)bitset<16>(sub).to_ulong();
}
//对16位输入的每4位应用SBOX,增加非线性的混淆
uint16_t applySBox(uint16_t u) {
uint16_t v = 0;
for (int i = 0; i < 4; ++i) {
int nibble = (u >> (4 * (3 - i))) & 0xF;
v |= (SBOX[nibble] << (4 * (3 - i)));
}
return v;
}
//S盒的逆操作,用于解密或攻击
uint16_t applySBoxInv(uint16_t v) {
uint16_t u = 0;
for (int i = 0; i < 4; ++i) {
int nibble = (v >> (4 * (3 - i))) & 0xF;
u |= (SBOX_INV[nibble] << (4 * (3 - i)));
}
return u;
}
//对16位输入的每1位应用PBOX,增加位置上的扩散
uint16_t applyPBox(uint16_t v) {
uint16_t w = 0;
for (int i = 0; i < 16; ++i) {
if ((v >> (15 - i)) & 1)
w |= (1 << (15 - PBOX[i]));
}
return w;
}
uint16_t encryptSPN(uint16_t x, const string& K_str, int Nr) {
uint16_t w = x;
for (int r = 1; r <= Nr - 1; ++r) {
uint16_t u = w ^ setsubkey(K_str, r); //异或
uint16_t v = applySBox(u); //S盒代换
w = applyPBox(v); //P盒置换
}
uint16_t u_last = w ^ setsubkey(K_str, Nr);//最后一轮没有P盒,直接异或子密钥后输出
uint16_t v_last = applySBox(u_last);
uint16_t y = v_last ^ setsubkey(K_str, Nr + 1);
return y;
}
// 差分分布表DDT
void buildDDT(int ddt[16][16]) {
memset(ddt, 0, sizeof(int) * 16 * 16);
for (int x = 0; x < 16; ++x)
for (int dx = 0; dx < 16; ++dx)
ddt[dx][SBOX[x] ^ SBOX[x ^ dx]]++;
}
void printDDT(int ddt[16][16]) {
printf("\nSBox 差分分布表 DDT \n");
printf("Δx\\Δy |");
for (int i = 0; i < 16; ++i) printf(" %2X", i);
printf("\n------+");
for (int i = 0; i < 16; ++i) printf("---");
printf("\n");
for (int dx = 0; dx < 16; ++dx) {
printf(" %2X |", dx);
for (int dy = 0; dy < 16; ++dy)
printf(" %2d", ddt[dx][dy]);
printf("\n");
}
}
// 对密文做逆S盒,得到第4轮输入的对应nibble
int partialDecrypt(uint16_t y, uint16_t k5, int nibble_idx) {
// nibble_idx: 0最高 3最低
int shift = 4 * (3 - nibble_idx);
int y_nibble = (y >> shift) & 0xF;
int k_nibble = (k5 >> shift) & 0xF;
return SBOX_INV[y_nibble ^ k_nibble];//用S逆查出来S盒的输入
}
// 差分攻击函数
void differentialAttack(const string& K_str, int Nr) {
printf("\n=== SPN 差分攻击 ===\n");
printf("目标:恢复最后一轮子密钥 K5\n\n");
// 差分路径:Δx=0x0B00,3轮后目标差分 Δu4=0x0606
// u4 中活跃的 nibble 是 nibble1(0x6) 和 nibble3(0x6)
uint16_t delta_x = 0x0B00;
uint16_t target_diff = 0x0606;
int count[256] = { 0 }; // count[k]: k的高4位=K5_nibble1候选,低4位=K5_nibble3候选
int num_pairs = 10000;
mt19937 rng(42); //用梅森旋转算法找随机数
uniform_int_distribution<int> dist_rng(0, 65535);
printf("差分路径: Δx=0x%04X -> Δu4=0x%04X\n", delta_x, target_diff);
printf("攻击活跃nibble: nibble1 和 nibble3\n");
printf("生成 %d 对明文对...\n\n", num_pairs);
for (int i = 0; i < num_pairs; ++i) {
uint16_t x1 = (uint16_t)dist_rng(rng);
uint16_t x2 = x1 ^ delta_x;
uint16_t y1 = encryptSPN(x1, K_str, Nr);
uint16_t y2 = encryptSPN(x2, K_str, Nr);
for (int k = 0; k < 256; ++k) {
int k5_n1 = (k >> 4) & 0xF;
int k5_n3 = k & 0xF;
// 对最后一轮的nibble1和nibble3做逆S盒
int y1_n1 = (y1 >> 8) & 0xF; // nibble1 = bits[11:8]
int y2_n1 = (y2 >> 8) & 0xF;
int y1_n3 = y1 & 0xF;
int y2_n3 = y2 & 0xF;
int u4_1_n1 = SBOX_INV[y1_n1 ^ k5_n1];
int u4_2_n1 = SBOX_INV[y2_n1 ^ k5_n1];
int u4_1_n3 = SBOX_INV[y1_n3 ^ k5_n3];
int u4_2_n3 = SBOX_INV[y2_n3 ^ k5_n3];
// 检验活跃nibble的差分是否符合目标
if ((u4_1_n1 ^ u4_2_n1) == 0x6 && (u4_1_n3 ^ u4_2_n3) == 0x6)
count[k]++;
}
}
// 排序
vector<pair<int,int>> results;
for (int k = 0; k < 256; ++k)
results.push_back({ count[k], k });
sort(results.rbegin(), results.rend());
printf("候选 K5 统计(前10名nibble1和nibble3):\n");
printf(" %-12s %-12s %s\n", "K5_nibble1", "K5_nibble3", "计数");
printf(" %-12s %-12s %s\n", "----------", "----------", "----");
for (int i = 0; i < 10; ++i) {
int k = results[i].second;
printf(" 0x%X 0x%X %d\n",
(k>>4)&0xF, k&0xF, results[i].first);
}
uint16_t realk5 = setsubkey(K_str, Nr + 1);
int real_n1 = (realk5 >> 8) & 0xF;
int real_n3 = realk5 & 0xF;
int real_k = (real_n1 << 4) | real_n3;
printf("\n真实 K5: 0x%04X (nibble1=0x%X, nibble3=0x%X, 计数=%d)\n",realk5, real_n1, real_n3, count[real_k]);
printf("攻击恢复: nibble1=0x%X, nibble3=0x%X (计数=%d)\n",(results[0].second>>4)&0xF,results[0].second&0xF,results[0].first);
if (results[0].second == real_k)
printf("\n[成功] 差分攻击成功恢复 K5 的活跃 nibble!\n");
else {
int rank = (int)(find_if(results.begin(),results.end(),[&](auto&r){return r.second==real_k;})-results.begin())+1;
printf("\n[提示] 真实K5活跃nibble排名第%d,可增加明文对数量。\n", rank);
}
}
int main() {
int ddt[16][16];
buildDDT(ddt);
printDDT(ddt);
// K = 0x3A94D2B7E1F05C68,截取80位
const string K_str = "00111010100101001101001010110111111000011111000001011100011010001010010110110111";
printf("使用密钥 K = %s\n", K_str.c_str());
for (int i = 1; i <= 5; ++i) {
printf("K%d=0x%04X%s", i, setsubkey(K_str, i), (i == 5 ? "\n" : ", "));
}
// 执行差分攻击
differentialAttack(K_str, 4);
return 0;
}
8. 运行结果如何理解
运行程序后,会先打印 S 盒的 DDT,然后输出候选 K5 的统计排名。
输出中比较关键的部分如下:
候选 K5 统计(前10名nibble1和nibble3):
K5_nibble1 K5_nibble3 计数
---------- ---------- ----
...
判断攻击是否成功时,主要看两点:
-
真实 K5 的目标 nibble 排名是否靠前;
-
排名第一的候选是否等于真实 K5 的目标 nibble。
如果程序输出:
[成功] 差分攻击成功恢复 K5 的活跃 nibble!
就说明在当前明文对数量下,差分统计已经把正确密钥候选筛选出来了。
为什么只恢复部分 K5?
因为差分路径只让某些 nibble 处于活跃状态。
没有被差分路径覆盖的 nibble,在这次攻击中不会产生有效统计偏差,因此无法通过这条路径直接恢复。
如果想恢复更多位密钥,可以继续设计其他差分路径,分别攻击其他 nibble。
9. 当前 SPN 的安全性问题
这个 16 位 SPN 是教学实验模型,不适合作为真实密码系统使用,主要问题如下。
9.1 S 盒最大差分概率偏高
本实验 S 盒的最大非平凡差分概率为:
1/4
对于差分攻击来说,这个概率比较高,容易构造出可用的多轮差分路径。
9.2 轮数较少
本实验只有 4 轮,其中前三轮可以构造差分路径,最后一轮用于猜测子密钥。
轮数越少,差分特征概率越高,攻击所需样本量越少。
9.3 分组长度太短
16 位分组空间只有:
2 16 = 65536 2^{16} = 65536 216=65536
这意味着即使不用差分攻击,穷举和统计实验也非常容易完成。
9.4 子密钥生成方式简单
子密钥直接从主密钥中截取,缺少复杂的非线性密钥扩展。
这会导致不同轮子密钥之间结构关系较强,不利于抵抗相关密钥攻击等更复杂的分析方法。
10. 如何提升安全性
10.1 优化 S 盒设计
一个更安全的 S 盒应尽量满足:
-
最大差分概率更低;
-
非线性度更高;
-
代数次数更高;
-
DDT 中非零项分布更均匀。
简单来说,就是不要让某个输入差分过于稳定地对应某个输出差分。
10.2 增加轮数
差分路径概率会随着轮数增加而快速下降。
| 轮数 | 差分特征概率估计 | 攻击难度 |
|---|---|---|
| 4 | 约 2^-10 | 很容易实验 |
| 6 | 约 2^-20 | 明文对需求明显增加 |
| 8 | 约 2^-30 | 实验成本大幅提高 |
| 10 | 约 2^-40 | 对玩具模型也开始不现实 |
10.3 增大分组长度
将 16 位分组扩展到 64 位或 128 位,可以显著提高攻击成本。
更大的分组意味着:
-
状态空间更大;
-
可利用的简单统计偏差更难收集;
-
穷举和全空间遍历不再现实。
10.4 使用非线性密钥扩展
不要简单地从主密钥中截取子密钥。
可以参考现代分组密码的设计,引入:
-
S 盒;
-
轮常数;
-
字循环;
-
非线性变换;
-
扩散操作。
这样可以降低轮密钥之间的直接相关性。
11. 总结
本文通过一个 4 轮 16 位 SPN 玩具密码,完整演示了差分攻击的基本流程:
-
实现 SPN 加密算法;
-
计算 S 盒差分分布表 DDT;
-
选择高概率差分路径;
-
生成大量固定输入差分的明文对;
-
枚举最后一轮子密钥候选;
-
通过逆 S 盒和统计计数恢复部分 K5。
说明了一个重要结论:
分组密码的安全性不仅取决于密钥长度,还取决于 S 盒差分性质、P 盒扩散能力、轮数设计和密钥扩展方式。
对于学习密码学的同学来说,SPN 差分攻击是理解现代分组密码设计原则的经典入门实验。
更多推荐
所有评论(0)