文章总结: 文章深度解析Vigenere密码Kasiski攻击原理及CrypTool-2源码实现。重点阐述Kasiski测试估计密钥长度、Friedman测试交叉验证及自相关函数检测方法,揭示源码采用整除检测替代质因数分解的工程巧思,详述爬山法恢复密钥流程,并指出Autokey模式攻击局限。 综合评分: 95 文章分类: 代码审计,逆向分析,CTF
「古典密码学」破解”不可破解的密码”:Vigenere 密码的 Kasiski 攻击全解析
原创
XueMian XueMian
雪面科技
2026年3月8日 12:47 澳大利亚
基于 CrypTool-2 源码的密钥长度估计与密钥恢复机制深度剖析
摘要
一个密码被叫了三百年”不可破解”,然后被一个退役军官用纸和笔干掉了——这就是 Vigenere 密码的故事。本文翻了一遍 CrypTool-2 的源码,把整个攻击链条拆开来看:Kasiski 测试怎么猜密钥长度、Friedman 测试和自相关函数怎么帮忙交叉验证、爬山法怎么把密钥内容一个字母一个字母地恢复出来。顺便聊聊这些招数什么时候会翻车,以及 CrypTool-2 在工程实现上一些有意思的设计选择。
1. 引言:从凯撒到 Vigenere 的演进
凯撒密码是最简单的单表替换——所有字母用同一个移位值加密。弱点一目了然:密文里字母的出现频率跟明文一模一样。英语中 E 占 12.7%,密文里 H 出现得最多?那移位值八成是 3。
Vigenere 的改进思路很暴力:不用一个移位值,用一组移位值轮着来。密钥 KEY 意味着第 1 个字母移 10(K=10),第 2 个移 4(E=4),第 3 个移 24(Y=24),第 4 个又回到 10……同一个明文字母在不同位置会变成不同的密文字母,频率分析直接废了——至少表面上是这样。
就这么个东西,从 16 世纪被提出后,三百年没人能破。直到 1863 年,退役的普鲁士军官 Friedrich Kasiski 在《Die Geheimschriften und die Dechiffrir-kunst》里提了一招:盯着密文里重复出现的 n-gram 片段,算它们之间的距离,反推密钥长度。密钥长度一旦暴露,多表替换就退化成了多个独立的凯撒密码——逐个击破就完事了。
2. Vigenere 密码的数学定义
2.1 加密公式
设明文为 ,密钥为 (长度为 ),密文为 :
**Vigenere 加密:****Vigenere 解密:**
其中 为字母表大小(标准英文字母表 )。
2.2 CrypTool-2 实现:四种密码模式
CrypTool-2 的 Vigenere.cs 不只实现了标准 Vigenere,还塞了三种变体进去。内部枚举定义了 8 种操作模式(第 37-47 行):
private enum CipherMode
{
VigenereEncrypt,
VigenereDecrypt,
VigenereAutokeyEncrypt,
VigenereAutokeyDecrypt,
BeaufortEncrypt,
BeaufortDecrypt,
BeaufortAutokeyEncrypt,
BeaufortAutokeyDecrypt
};
四种密码模式的数学关系:
| 模式 | 加密公式 | 解密公式 | 密钥循环方式 | | — | — | — | — | | Vigenere | | | 密钥循环重复 | | Vigenere Autokey | 同上(但密钥用尽后用明文续接) | 同上 | 密钥 + 明文 | | Beaufort | | 与加密相同(对合) | 密钥循环重复 | | Beaufort Autokey | 同上(但密钥用尽后用明文续接) | 同上 | 密钥 + 明文 |
核心 Crypt() 方法(第 188-395 行)用一个大 switch 搞定所有 8 种模式。来看标准 Vigenere 加密的部分(第 219-231 行):
case CipherMode.VigenereEncrypt:
cpos = (ppos + cfg.ShiftKey[shiftPos]) % alphabet.Length;
shiftPos++;
if (shiftPos >= cfg.ShiftKey.Length)
{
shiftPos = 0; // 密钥循环:回到开头
}
break;
Beaufort 有个好玩的地方:加密和解密是同一个公式——数学上叫对合(involution)运算(第 247-259 行):
case CipherMode.BeaufortEncrypt:
case CipherMode.BeaufortDecrypt:
cpos = Mod((cfg.ShiftKey[shiftPos] - ppos), alphabet.Length);
break;
注意这里用的是自定义 Mod() 方法(第 397-405 行),而非 C# 原生的 % 运算符——因为 C# 的 % 对负数返回负数,而密码学需要非负模结果:
private static int Mod(int number, int module)
{
int result = number % module;
if ((result < 0 && module > 0) || (result > 0 && module < 0))
{
result += module;
}
return result;
}
2.3 Autokey 模式
Autokey 的关键区别:密钥不循环。用完了怎么办?拿明文字母接着当密钥用。来看 Vigenere Autokey 加密的代码(第 261-283 行):
case CipherMode.VigenereAutokeyEncrypt:
if (shiftPos < cfg.ShiftKey.Length)
{
// 密钥尚未用尽:正常 Vigenere 加密
cpos = (ppos + cfg.ShiftKey[shiftPos]) % alphabet.Length;
shiftPos++;
}
else
{
// 密钥用尽:用明文字母作为密钥
int pkey = alphabet.IndexOf(char.ToUpper(_inputString[autopos]));
while (pkey < 0)
{
autopos++;
pkey = alphabet.IndexOf(char.ToUpper(_inputString[autopos]));
}
cpos = (ppos + pkey) % alphabet.Length;
autopos++;
}
break;
先记住这个模式——后面聊 Kasiski 攻击什么时候会翻车,它是个关键角色。
3. Kasiski 攻击:第一步——确定密钥长度
3.1 核心洞察
Kasiski 攻击的出发点其实很朴素:
“
明文里两个一样的片段,如果恰好碰上密钥的同一位置来加密,密文里也会出现一模一样的片段。而这两个重复密文之间的距离,一定是密钥长度的整数倍。
举个例子就清楚了:
明文: THE SUN AND THE MOON
密钥: KEY KEY KEY KEY KEYK
密文: DLC QSX KXH DLC WSKX
“THE” 在第 1 位和第 13 位各出现一次,两次都恰好对齐密钥的 “KEY”,所以密文中 “DLC” 也重复出现。距离 = 13 – 1 = 12,而 12 = 3 × 4,密钥长度 3 是 12 的因子。
当然也可能撞巧——两段不同的明文被不同密钥位置加密,碰巧搞出了一样的密文。但概率说了算:真正的密钥长度因子,在所有距离的因子里出现频率一定最高。
3.2 CrypTool-2 实现:KasiskiTest.cs
源文件:CrypPlugins/KasiskiTest/KasiskiTest.cs(293 行)
整个流程三步走:清洗文本 → 找重复子串 → 距离因子分解。
Step 1:文本预处理(第 109-140 行)
先把文本收拾干净——要不要丢掉非字母字符、要不要统一大小写,都看用户设置:
string workString = stringInput;
string workstring2 = "";
if (settings.unknownSymbolHandling == 1) // 丢弃未知符号
{
char[] validchars = newchar[] { 'a', 'b', 'c', ... 'Z' };
string strValidChars = newstring(validchars);
StringBuilder workstring1 = new StringBuilder();
foreach (char c in workString.ToCharArray())
{
if (strValidChars.IndexOf(c) >= 0)
{
workstring1.Append(c);
}
}
workstring2 = workstring1.ToString();
}
if (settings.caseSensitivity == 0)
{
workstring2 = workstring2.ToUpper();
}
Step 2:重复子串搜索(第 154-174 行)
这才是干活的部分。三层嵌套循环暴力搜:外层扫 n-gram 长度(从 3 到用户设的最大值),中层扫起始位置,内层往后找有没有一样的 n-gram:
for (int d = 3; d <= settings.grammLength; d++) // n-gram 长度
{
for (int i = 0; i <= workstring2.Length - settings.grammLength; i++) // 起始位置
{
grammToSearch = workstring2.Substring(i, d); // 取出当前 n-gram
for (int n = i + settings.grammLength; n <= workstring2.Length - settings.grammLength; n++)
{
if (grammToSearch == workstring2.Substring(n, d)) // 找到重复
{
distances.Add(n - i); // 记录距离
checkedGramms.Add(grammToSearch); // 记录 n-gram
break; // 只记第一个匹配
}
}
}
}
注意那个 break——找到第一个匹配就跳出了。也就是说,如果同一个 n-gram 出现了三次(位置 A、B、C),只会记录 A→B 的距离,A→C 和 B→C 就被扔掉了。简单粗暴,少算了一些信息但也减少了噪声。
Step 3:距离因子分解(第 190-212 行)
这里有个有意思的地方——CrypTool-2 不做标准的质因数分解,而是做整除检测:
for (int z = 0; z <= distances.Count - 1; z++) // 遍历每个距离
{
int numberToFactorize = Convert.ToInt32(distances[z]);
x = 0;
for (int y = 2; y <= settings.factorSize; y++) // 从 2 到最大因子
{
if (numberToFactorize == 0) { break; }
if (numberToFactorize % y == 0) // 能整除就算一个因子
{
factors[z, x] = y;
x++;
factorCounter[y]++; // 该因子计数 +1
}
}
}
留意这段被注释掉的代码(第 204-207 行):
// while (numberToFactorize % y == 0)
// {
// numberToFactorize = numberToFactorize / y;
// }
标准做法是找到因子后反复除,直到除不动为止。但 CrypTool-2 把这段注释掉了,改成了单纯的整除检测。拿距离 12 来说:
- 质因数分解得到:2, 2, 3(12 = 2² × 3)
- 整除检测得到:2, 3, 4, 6(12 能被这几个数整除)
为什么整除检测反而更好?因为密钥长度又不一定是质数。假设密钥长度是 6,那距离 12、18、24 都能被 6 整除,”6″ 这个因子会在 factorCounter 里不断攒分。要是只做质因数分解,分解出来的是 2 和 3,你还得再组合一步才能猜到密钥可能是 6——多此一举。
最后,factorCounter 里得分最高的那个因子,就是最可能的密钥长度。结果以直方图输出(第 226-231 行),在 CrypTool-2 界面上各因子的得分高低一目了然。
4. Friedman 测试:统计学来帮忙
4.1 数学原理
Friedman 测试(也叫 Kappa 测试),1922 年 William Friedman 提出的,思路跟 Kasiski 完全不同——用重合指数(Index of Coincidence, IC)来估密钥长度。
所谓重合指数,就是从文本里随机摸两个字母出来,它们恰好一样的概率:
- 自然语言英文:
- 随机均匀分布:(26 字母)
密钥长度为 的 Vigenere 密文,IC 值大概介于自然语言和随机分布之间。从这个关系可以反推密钥长度:
密文
4.2 CrypTool-2 实现:FriedmanTest.cs
源文件:CrypPlugins/FriedmanTest/FriedmanTest.cs(186 行)
多语言 Kappa 值(第 110-118 行)——直接硬编码了 6 种语言的 IC 参考值:
switch (settings.Kappa)
{
case 1: Kp = 0.0762; break; // 德语
case 2: Kp = 0.0778; break; // 法语
case 3: Kp = 0.0770; break; // 西班牙语
case 4: Kp = 0.0738; break; // 意大利语
case 5: Kp = 0.0745; break; // 葡萄牙语
default: Kp = 0.0667; break; // 英语
}
核心计算(第 129-136 行)——公式直接套:
double cipherTextLength = arrayInput.Sum();
double countDoubleCharacters = arrayInput.Sum(i => i * ((double)i - 1));
double Keqdist = 1.0 / 26; // 随机等概分布的 IC
kappaCiphertext = countDoubleCharacters / (cipherTextLength * (cipherTextLength - 1));
keyLength = ((Kp - Keqdist) * cipherTextLength) /
((cipherTextLength - 1) * kappaCiphertext - (Keqdist * cipherTextLength) + Kp);
单表/多表判定(第 138 行):
string ciphermode = (Math.Abs(Kp - kappaCiphertext) > 0.01)
? "polyalphabetic"
: "monoalphabetic/cleartext";
逻辑很粗暴:密文 IC 跟目标语言 IC 差了超过 0.01?那就是多表替换。没差那么多?单表替换或者根本就是明文。0.01 这个阈值纯经验值。
4.3 Friedman 测试的定位
Friedman 给出的是一个连续的估计值——可能蹦出个 4.73 这样的小数。而 Kasiski 给的是离散的候选集——2、3、6 这种。两个放一起交叉验证,靠谱程度就上去了:Friedman 说”大概在 5 附近”,Kasiski 说”6 的得分最高”,那密钥长度大概率就是 5 或 6。
但 Friedman 测试也有它的软肋:它假设密钥字母均匀分布、明文各位置的字母频率一致。密文一短,估计值就飘得厉害,参考价值大打折扣。
5. 自相关函数:第三条路
5.1 算法原理
自相关函数的思路比前两个都直觉:把密文跟自己的移位版本对齐,数有多少个位置字母一样。
为什么管用?如果移位量刚好等于密钥长度(或其倍数),对齐的两段文本在每个位置用的是同一个密钥字母——等于在比较两段单表替换密文。字母相同的概率会回到自然语言的 IC 水平(约 0.067),比随机情况(约 0.038)高出一大截。
5.2 CrypTool-2 实现:AutocorrelationFunction.cs
源文件:CrypPlugins/AutocorrelationFunction/AutocorrelationFunction.cs(227 行)
最大测试移位量硬编码为 31(第 32 行):
private const int MAX_TESTED_SHIFTS = 31;
文本预处理(第 183-196 行)——老规矩,扔掉非字母字符、转大写:
private string PrepareForAnalysis(string text)
{
StringBuilder stringBuilder = new StringBuilder();
text = text.ToUpper();
for (int offset = 0; offset < text.Length; offset++)
{
if (Alphabet.Contains(text.Substring(offset, 1)))
{
stringBuilder.Append(text[offset]);
}
}
return stringBuilder.ToString();
}
移位匹配计算(第 103-124 行):
for (int shift = 0; shift < MAX_TESTED_SHIFTS; shift++)
{
int match = 0;
for (int offset = 0; offset < _inputText.Length - shift; offset++)
{
if (_inputText[offset] == _inputText[offset + shift])
{
match++;
}
}
_autocorrelationValues[shift] = match;
}
找最大峰值(第 128-136 行)——从 shift=1 开始找(shift=0 是自己跟自己比,100% 匹配,没意义):
for (int i = 1; i < MAX_TESTED_SHIFTS; i++)
{
if (_autocorrelationValues[i] > _probablekorr)
{
_probablekorr = _autocorrelationValues[i];
_probableLength = i;
}
}
匹配数最高的移位量就是估计的密钥长度。画成直方图,峰值在哪儿一眼就看出来了。
5.3 局限性
最大移位量写死了 31,也就是说密钥长度超过 30 就检测不到了。大多数教学场景够用,但碰上长密钥就没辙了。
6. 密钥恢复:爬山法搜索
密钥长度拿到了,接下来就是把密钥具体内容挖出来。CrypTool-2 的 VigenereAnalyzer 用的是爬山法(Hill Climbing)。
6.1 爬山法概览
源文件:CrypPlugins/VigenereAnalyzer/VigenereAnalyzer.cs(816 行)
核心逻辑在 Hillclimb() 方法里(第 246-390 行)。思路很直白:对每个候选密钥长度,随机生成一把初始密钥,然后一个位置一个位置地试——把第 i 位换成字母 j,解密一下,用 N-gram 打分。分数变高了就留着,没变高就换回去。所有字母都试完一轮还能找到更好的就继续,找不到就停。这个过程重复好多次(每次换个随机起点),最好的结果全部丢进一个排行榜。
6.2 初始密钥生成
第 263-267 行——每个位置随机从字母表里摸一个字母出来:
for (int i = 0; i < runkey.Length; i++)
{
runkey[i] = numalphabet[random.Next(alphabetlength)];
}
6.3 逐位优化
第 296-363 行——两层循环,外层扫密钥位置,内层扫字母表:
do
{
foundbetter = false;
for (int i = 0; i < keylength; i++) // 密钥的每个位置
{
for (int j = 0; j < alphabetlength; j++) // 字母表的每个字母
{
int oldLetter = runkey[i];
runkey[i] = j;
plaintext = decryptFunction(plaintext, runkey, numvigalphabet, i, ciphertext);
double costvalue = 0;
if(_settings.KeyStyle == KeyStyle.Random)
{
costvalue = _grams.CalculateCost(plaintext);
}
else
{
var plaintext_and_key = AppendIntegerArrays(plaintext, runkey);
costvalue = _grams.CalculateCost(plaintext_and_key);
}
if (costvalue > bestkeycost)
{
bestkeycost = costvalue;
bestkey = (int[])runkey.Clone();
foundbetter = true;
}
else
{
runkey[i] = oldLetter; // 恢复原密钥
if (j == alphabetlength - 1)
{
// 所有字母都试完了没有改善,恢复原始解密结果
plaintext = decryptFunction(plaintext, runkey, numvigalphabet, i, ciphertext);
}
}
}
}
runkey = (int[])bestkey.Clone();
} while (foundbetter);
6.4 “InPlace” 优化:别全部重算
这里有个挺精巧的优化。你改了密钥的第 位,难道要把整段密文重新解密一遍?没必要——只有明文中第 这些位置会受影响,其他位置纹丝不动。
来看标准 Vigenere 的 InPlace 解密(第 542-557 行):
public staticint[] DecryptVigenereOwnAlphabetInPlace(
int[] plaintext, int[] key, int[] alphabet, int offset, int[] oldciphertext)
{
int plaintextlength = plaintext.Length;
int keylength = key.Length;
int alphabetlength = alphabet.Length;
int[] lookup = newint[alphabetlength];
for (int position = 0; position < alphabetlength; position++)
{
lookup[alphabet[position]] = position;
}
// 关键:只更新 offset, offset+keylength, offset+2*keylength, ... 位置
for (int position = offset; position < plaintextlength; position += keylength)
{
plaintext[position] = alphabet[
(lookup[oldciphertext[position]] - lookup[key[position % keylength]] + alphabetlength) % alphabetlength];
}
return plaintext;
}
跟完整解密方法(第 490-506 行)比一下就明白了:循环步长从 1 变成了 keylength。密文 1000 个字符、密钥长度 5,InPlace 每次只算 200 个位置,完整版要算 1000 个。爬山法内层循环要跑”密钥位置 × 字母表大小”这么多轮,每轮都省 80% 的解密量——效果相当可观。
6.5 密钥也是”人话”?KeyStyle 的妙用
VigenereAnalyzerSettings.cs 第 38-42 行定义了两种密钥风格:
public enum KeyStyle
{
Random = 0,
NaturalLanguage = 1
}
当 KeyStyle 设为 NaturalLanguage 时,评分的对象就不只是明文了——密钥也得一起打分(第 315-319 行):
var plaintext_and_key = AppendIntegerArrays(plaintext, runkey);
costvalue = _grams.CalculateCost(plaintext_and_key);
为什么这么做?很多时候密钥本身就是个有意义的词——”LEMON”、”SECRET” 之类的。把明文和密钥拼到一起打分,相当于多了一个信号源。短密文场景下尤其有用:光看明文的统计量可能飘来飘去不够稳定,加上密钥的语言特征,爬山法更容易找对方向。
6.6 多次重启与 BestList
爬山法的老毛病:容易卡在局部最优出不来。怎么办?多跑几次呗。第 261 行的 while (restarts > 0) 循环,每次换个随机起点重新爬。
所有跑出来的好结果丢进一个有序列表(第 412-481 行),最多留 100 个(MaxBestListEntries = 100,第 41 行)。按 N-gram 评分从高到低排好,用户在界面上翻一翻,自己挑对的。
插入逻辑(第 461-462 行)保持有序:
int insertIndex = _presentation.BestList.TakeWhile(e => e.Value > entry.Value).Count();
_presentation.BestList.Insert(insertIndex, entry);
7. 什么时候这套东西会翻车?
Kasiski 攻击看着挺漂亮,但它能干活是有前提条件的。一旦这些条件不满足,整套攻击链就废了。
7.1 密文太短——统计量撑不住
Kasiski 测试得靠密文里出现足够多的重复 n-gram。密文就几十个字符?3-gram 重复出现的概率低得可怜,根本凑不够样本做因子分析。
Friedman 也一样——IC 值需要统计量撑着。短密文算出来的 IC 飘来飘去,估出的密钥长度可能差好几倍。
自相关函数在短密文下更惨,到处都是”峰值”,真信号和噪声混在一起分不清。
7.2 密钥跟明文一样长——那就是 One-Time Pad
密钥长度 等于明文长度 ?恭喜,这就是 One-Time Pad(一次性密码本)——Shannon (1949) 证明过的,信息论意义上不可破解的完美保密系统。
密钥根本不会循环,所以”相同密钥位置加密相同明文”这个前提就不存在了。Kasiski 找不到有意义的重复 n-gram,Friedman 的公式在 时直接退化成不确定形式。
哪怕密钥只是”接近”明文长度(比如 ),也够呛。每个密钥位置只覆盖 1-2 个字符,频率分析完全没戏。
7.3 明文不是”人话”——压缩数据/随机数据
这些统计攻击——Kasiski、Friedman、自相关、N-gram 爬山——全都有一个隐含假设:明文是自然语言,字母分布不均匀,N-gram 模式可预测。
要是明文本身就是:
-
压缩数据
(近似随机分布,IC ≈ 0.038)
-
已加密数据
(先用别的算法加密过一轮再套 Vigenere)
-
随机字节序列
——那就尴尬了。就算你知道了密钥长度,爬山法也分不出正确密钥和错误密钥。因为不管用什么密钥解密,出来的东西在统计上都一样”均匀”。
7.4 Autokey 模式——密钥不循环了
还记得前面说的 Autokey 吗(Vigenere.cs 第 261-283 行)?密钥用完之后拿明文字母接着当密钥。有效密钥序列变成了 ——没有周期性了。
Kasiski 的命根子——”同一个明文碰上同一个密钥位置会产生重复密文”——在 Autokey 下直接断了。第 个字母之后的”密钥”就是明文本身,没有循环可言。
CrypTool-2 的 VigenereAnalyzer 还是可以用爬山法试着攻击 Autokey(有专门的 DecryptVigenereAutokeyOwnAlphabet 方法),但 Kasiski 和 Friedman 估密钥长度这一步就彻底没用了。
7.5 非标准字母表——大字母表把频率特征冲淡了
CrypTool-2 支持自定义字母表(Vigenere.cs 第 96-108 行的 AlphabetSymbols 属性),用户想传啥字符都行。
但要是用了一个 256 字符的字母表(比如完整 ASCII),每个字符的出现频率就被摊得很薄了。IC 趋近 ,跟随机分布几乎没区别。N-gram 统计更要命——数组大小是字母表长度的 N 次方, 直接炸内存,高阶 N-gram 评分根本跑不起来。
7.6 密钥跟明文等长且随机
哪怕不是严格的 One-Time Pad(密钥可能有点结构),只要密钥长度等于明文长度、内容又是随机的,攻击就基本没戏了。说白了就是 7.2 的一般化版本。
7.7 语言搞错了或者混着来
N-gram 评分必须对上正确的语言模型。CrypTool-2 的 FriedmanTest 提供了 6 种语言的 IC 参考值,LanguageStatisticsLib 支持 15 种语言的 N-gram 数据。但明文要是用的语言不在里面(阿拉伯语、中文之类),或者混了好几种语言,评分函数就会给出误导性的结果。
最典型的翻车场景:你用英语 N-gram 去评分一段实际上是德语的密文,爬山法兴高采烈地收敛到一个”看起来像英语”的密钥——然后完全是错的。
8. CrypTool-2 的工程细节
8.1 四种密码模式,一套代码搞定
Vigenere.cs 用一个 Crypt() 方法加一个 CipherMode 枚举就搞定了 8 种加密/解密操作。分析器这边也是同一个思路——VigenereAnalyzer.cs 用 DecryptFunction 委托(第 33 行)加 Mode 枚举(VigenereAnalyzerSettings.cs 第 23-29 行)实现统一调度:
// 委托定义(VigenereAnalyzer.cs 第 33 行)
public delegate int[] DecryptFunction(
int[] plaintext, int[] key, int[] alphabet, int offset, int[] oldciphertext);
// 枚举(VigenereAnalyzerSettings.cs 第 23-29 行)
public enum Mode
{
Vigenere = 0,
VigenereAutokey = 1,
Beaufort,
BeaufortAutokey,
};
爬山法里,解密函数通过委托动态绑定(第 275-294 行),同一套爬山逻辑直接切换四种密码模式,不用改一行代码——典型的策略模式(Strategy Pattern)。
8.2 自定义字母表——置换字母表也能打
VigenereAnalyzer 接受外部传入的字母表(第 78-79 行的 VigenereAlphabet 属性),Execute() 里做去重排序(第 152 行):
Alphabet = string.Join("", VigenereAlphabet.Distinct().OrderBy(q => q).ToArray());
_grams.ReduceAlphabet(Alphabet);
_grams.Normalize(10_000_000);
这就意味着:如果 Vigenere 用了置换字母表(比如 QWERTYUIOPASDFGHJKLZXCVBNM 而不是标准的 ABCDEFGHIJKLMNOPQRSTUVWXYZ),只要把正确的字母表喂进去,分析器照样能跑。
8.3 15 种语言的 N-gram 统计库
VigenereAnalyzer.cs 第 147 行加载目标语言的 N-gram 数据:
_grams = LanguageStatistics.CreateGrams(
_settings.Language,
DirectoryHelper.DirectoryLanguageStatistics,
(GramsType)(_settings.GramsType + 1),
false);
覆盖了大部分欧洲语言(英、德、法、西、意、葡、荷、瑞典、波兰、捷克、匈牙利、拉丁、俄、希腊、土耳其),每种语言都有独立的 N-gram 频率文件,从 1-gram 到 6-gram 都有。
8.4 InPlace 解密优化
前面 6.4 节已经聊过了。每种密码模式都有两套解密方法:
-
完整版
(如
DecryptVigenereOwnAlphabet,第 490-506 行):把整段密文解一遍 -
InPlace 版
(如
DecryptVigenereOwnAlphabetInPlace,第 542-557 行):只动密钥变更影响到的位置
完整版管初始化和最终输出,InPlace 版给爬山法内层循环用。两个版本逻辑完全一样,就是循环步长不同(1 vs keylength),不会算出不同结果。
8.5 BestList:只留 Top 100
MaxBestListEntries = 100(第 41 行)卡住了列表大小。AddNewBestListEntry 方法(第 412-481 行)负责插入和裁剪:
// 跳过不够好的结果
if (_presentation.BestList.Count > 0 && value <= _presentation.BestList.Last().Value)
{
return;
}
// 有序插入
int insertIndex = _presentation.BestList.TakeWhile(e => e.Value > entry.Value).Count();
_presentation.BestList.Insert(insertIndex, entry);
// 裁剪
if (_presentation.BestList.Count > MaxBestListEntries)
{
_presentation.BestList.RemoveAt(MaxBestListEntries);
}
列表始终按 N-gram 评分从高到低排着,超过 100 条就把最差的踢掉。用户在界面上翻 Top 100,自己挑哪个是对的。
8.6 Kasiski 因子分解的设计选择
前面 3.2 节说过了:CrypTool-2 用整除检测而不是质因数分解。从被注释掉的代码(KasiskiTest.cs 第 204-207 行)来看,开发者试过标准做法,最后有意识地选了整除检测——更适合密钥长度估计这个场景。
另外那个 break(第 167 行)也不是偷懒。每个 n-gram 只匹配第一次出现,实际上起到了降噪的效果——假如 “THE” 在密文里出现了 10 次(可能大部分是巧合重复),不 break 的话会产生 45 个距离值,把因子统计搞得乱七八糟。
8.7 Lookup 数组——用空间换时间
VigenereAnalyzer 所有解密方法都用了 lookup 数组来加速字母索引查询。来看标准 Vigenere 的版本(第 496-503 行):
int[] lookup = new int[alphabetlength];
for (int position = 0; position < alphabetlength; position++)
{
lookup[alphabet[position]] = position;
}
for (int position = 0; position < plaintextlength; position++)
{
plaintext[position] = alphabet[
(lookup[ciphertext[position]] - lookup[key[position % keylength]] + alphabetlength) % alphabetlength];
}
lookup 数组把字母值到索引的映射预先算好,循环里直接查数组就行,不用反复调 IndexOf。O(1) 的数组访问 vs O(n) 的线性搜索——在爬山法的热循环里,这个差距可不小。
9. 完整攻击流程总结
把前面这些零件拼到一起,CrypTool-2 攻击 Vigenere 密码的完整流程大概是这样的:
拿到密文之后,先同时扔给三个工具跑:Kasiski 测试输出因子分布直方图,Friedman 测试给出一个连续的密钥长度估计值,自相关函数画出移位匹配峰值。三个结果交叉验证,定下密钥长度。
密钥长度一确定,就把密文和候选长度交给 VigenereAnalyzer。爬山法开始干活:随机起点、逐位试字母、N-gram 打分、保留最优、多次重启。跑完之后 BestList 里攒着 Top 100 的候选结果,用户翻一翻就能找到正确的密钥和明文。
但这套流程能跑通,有几个前提条件必须同时满足:
-
密文足够长
——给 Kasiski/Friedman 提供统计样本
-
密钥循环重复
——Autokey 和 One-Time Pad 不适用
-
明文是自然语言
——N-gram 评分才有区分度
-
语言得猜对
——成本函数需要正确的语言模型
-
字母表是标准的
——N-gram 统计数据得能用上
10. 关键源文件索引
| 文件路径 | 行数 | 核心功能 |
| — | — | — |
| CrypPlugins/Vigenere/Vigenere.cs | 502 | 四种 Vigenere 变体的加密/解密实现 |
| CrypPlugins/KasiskiTest/KasiskiTest.cs | 293 | Kasiski 测试:重复 n-gram 搜索 + 整除因子检测 |
| CrypPlugins/FriedmanTest/FriedmanTest.cs | 186 | Friedman 测试:IC 计算 + 密钥长度估计(6 种语言) |
| CrypPlugins/AutocorrelationFunction/AutocorrelationFunction.cs | 227 | 自相关函数:移位匹配计数(最大移位 31) |
| CrypPlugins/VigenereAnalyzer/VigenereAnalyzer.cs | 816 | 爬山法密钥恢复:InPlace 优化 + BestList Top 100 |
参考文献
- Friedman, W. F. (1922). The index of coincidence and its applications in cryptography (Riverbank Publication No. 22). Riverbank Laboratories.
- Kasiski, F. W. (1863). Die Geheimschriften und die Dechiffrir-Kunst. E. S. Mittler und Sohn.
- Kopal, N. (2018). Solving classical ciphers with CrypTool 2. In Proceedings of the 1st International Conference on Historical Cryptology (HistoCrypt 2018) (Vol. 149, pp. 29–38). Linköping University Electronic Press.
- Shannon, C. E. (1949). Communication theory of secrecy systems. Bell System Technical Journal, 28(4), 656–715.
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:雪面科技 XueMian XueMian《「古典密码学」破解”不可破解的密码”:Vigenere 密码的 Kasiski 攻击全解析》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论