维吉尼亚密码从实现到破解:卡西斯基与重合指数实战

发布时间:2026/10/29 14:54:33
维吉尼亚密码从实现到破解:卡西斯基与重合指数实战 维吉尼亚密码在各种密码学练习题里出现的频率高得离谱但真正动手写过一遍完整加解密、再亲手把它破掉的人并不多。多数人对它的印象停留在“多表替换、比凯撒难破”这两句话上遇到一段密文需要还原密钥时还是只能去翻现成脚本。这篇内容面向的正是这部分人写过凯撒、知道频率分析但还没把维吉尼亚从数学定义、代码实现到周期检测、分组还原这一整条链路走通的人。下面我会按照自己实际做题和写工具的路径把维吉尼亚密码的加密逻辑、代码落地细节、三种破解路径的完整推演以及不同密钥长度下的量化差异讲清楚中间穿插几个我在实现时踩过的坑比如负数取模、非字母字符处理、卡西斯基测试的误判。看完之后你应该能徒手算出一段短密文也能在没有密钥的情况下用一段几百字符的密文把密钥还原出来。1. 从单表替换到多表替换维吉尼亚到底改动了哪一处1.1 单表替换的死穴在于“每个字母永远变成同一个字母”凯撒密码的本质是一张固定的替换表位移 3 的时候所有的 E 都会变成 H一个不漏。这种一对一映射带来一个致命后果明文中字母的统计分布被完整地搬到密文里。英文明文里 E 占大约 12.7%T 占 9%A 占 8% 左右那么密文里出现最频繁的那个字符大概率就是 E 的影子。只要密文长度过了三四十个字符频率统计配合常见双字母组TH、HE、IN就能把明文猜个七八成。维吉尼亚的改动只有一句话位移量不再固定而是由密钥逐位给出。密钥是 LEMON第 1 位位移 11第 2 位位移 4第 3 位位移 12第 4 位位移 14第 5 位位移 13第 6 位又回到 11如此循环。于是明文中同一个字母出现在不同位置时会被推到不同的密文字母上频率分布被“摊平”了。这是它真正的价值所在不是变得更复杂而是把单一统计特征打散了。1.2 加密的数学定义只有一行把字母映射成 0 到 25 的整数A0、B1、……、Z25。设明文第 i 个字母为 p密钥第 i 位按周期取为 k则加密c (p k) mod 26解密p (c − k) mod 26密钥长度为 m 时k 取 key[i mod m]。整个过程没有非线性变换、没有扩散、没有混淆就是加法取模。理解这一点很重要因为它决定了后面所有的破解手段只要确定密钥长度 m密文就能按位置拆成 m 组每组内部就是一次普通的凯撒加密位移量固定。这就是所谓的“多表”其实可以还原成“多个单表”。我常拿它做一个类比凯撒像是把一叠牌整体平移了几张维吉尼亚则是有 m 副牌轮着用每副牌平移的张数不同。只要观察者发现你是在轮换用牌把同一副牌的牌面抽出来单独看规律立刻回来了。1.3 手算一遍 LEMON 加密 ATTACKATDAWN很多人看书看懂了公式一上手就错问题通常出在“密钥只对字母推进不对空格和标点推进”这个约定上。我用经典例子走一遍。明文ATTACKATDAWN密钥LEMON。先把密钥转成数字L11、E4、M12、O14、N13周期为 5。位置明文数字密钥位位移相加密文1A0L1111L2T19E423X3T19M1231→5F4A0O1414O5C2N1315P6K10L1121V7A0E44E8T19M1231→5F9D3O1417R10A0N1313N11W22L1133→7H12N13E417R密文是 LXFOPVEFRNHR。第 3 位和第 8 位都是 T但一个变成 F一个也变成 F这里恰好相同第 6 位的 K 变成 V。看第 1 位和第 7 位明文都是 A密钥分别是 L 和 E密文分别是 L 和 E同一个明文字母对应了两个不同密文字母这就是维吉尼亚的“摊平”效果在起作用。注意如果明文长度不是密钥长度的整数倍最后一轮密钥只用一部分这不算异常。真正需要注意的是密钥里不能有非字母字符否则位移量无定义。2. 二十行代码跑通加解密但四个约定必须先定死2.1 实现之前必须先明确边界条件写这类古典密码工具代码量从来不是问题问题在于约定不统一导致的“我的加密结果和标准答案对不上”。我在第一版实现时就吃过这个亏网上找的测试向量和我的输出差了整整一位排查半天发现是对方把空格也当字母推进了密钥。所以在动手之前有四件事必须先写清楚密钥推进规则只有明文字母消耗密钥位空格、数字、标点不消耗。大小写处理通常约定保留原文大小写密钥统一转小写参与计算。非字母字符原样透传不参与加解密也不推进密钥。多字节字符中文、emoji 这类直接透传不要尝试ord()做模运算否则会出现负索引或者越界。这四条看似琐碎但它们直接决定了两份实现能不能互相验证。如果你要跟别人的工具做交叉比对第一条对不上后面全错。2.2 加解密的 Python 实现下面这版是我目前一直在用的逻辑直白没有引入任何第三方库密钥校验也加上了。def _check_key(key: str) - str: if not isinstance(key, str) or not key: raise ValueError(密钥不能为空) k key.lower() if not k.isalpha() or not k.isascii(): raise ValueError(密钥只能由英文字母组成) return k def vigenere_encrypt(text: str, key: str) - str: k _check_key(key) out [] ki 0 # 只统计字母密钥指针独立推进 for ch in text: if a ch z or A ch Z: base ord(A) if ch.isupper() else ord(a) # 关键点一先把字符转成 0-25 的相对值再相加 shift ord(k[ki % len(k)]) - ord(a) out.append(chr((ord(ch) - base shift) % 26 base)) ki 1 else: out.append(ch) # 关键点二非字母原样透传不推进 ki return .join(out) def vigenere_decrypt(text: str, key: str) - str: k _check_key(key) out [] ki 0 for ch in text: if a ch z or A ch Z: base ord(A) if ch.isupper() else ord(a) shift ord(k[ki % len(k)]) - ord(a) # 关键点三减法后 Python 的 % 会返回非负结果不用额外修正 out.append(chr((ord(ch) - base - shift) % 26 base)) ki 1 else: out.append(ch) return .join(out)验证一下c vigenere_encrypt(ATTACKATDAWN, LEMON) print(c) # LXFOPVEFRNHR print(vigenere_decrypt(c, LEMON)) # ATTACKATDAWN # 带标点和大小写的用例 c2 vigenere_encrypt(Attack at dawn, 凌晨三点!, lemon) print(c2) # Lxfopv ef rnhr, 凌晨三点! print(vigenere_decrypt(c2, LEMON))2.3 两个必然踩到的坑坑一ASCII 偏移量忘了减 base。如果直接写(ord(ch) shift) % 26得到的是 0 到 25 之间的数字再chr()出来是一堆不可见控制字符。正确做法是先减去ord(A)或ord(a)归零运算完再加回来。这个错误在初学阶段几乎人人犯一次。坑二负数取模的跨语言差异。Python 里-3 % 26等于 23是我们要的结果但 C、Java、Go 里-3 % 26等于 -3。如果你的解密函数要移植到其他语言必须写成((c - k) % 26 26) % 26。我见过一份 Java 实现就是因为这个解密出来的结果里夹杂着大量下划线和问号作者还以为是校验逻辑写错了。坑三更隐蔽isalpha()会把中文也判为 True。Python 的str.isalpha()对“中文”返回 True如果拿它来做“是不是字母”的判断中文会被纳入加解密流程ord(凌)是 20940减去 65 再加位移模 26 之后会生成一个完全不相干的 ASCII 字符直接污染输出。正确的判断是显式的范围比较或者用ch.isascii() and ch.isalpha()。3. 它为什么不够安全三种攻击路径的完整推演3.1 卡西斯基测试重复片段暴露了周期维吉尼亚密码最直接的漏洞是密钥周期。密钥长度为 m 时相距 m 的整数倍的两个位置使用同一个位移量。如果明文中恰好出现重复的词比如“THE”在文中间隔 15 个字母出现两次而 15 又恰好是 m 的倍数那么这两处密文也会完全相同。1863 年提出的卡西斯基测试就是抓这一点统计密文中所有长度不小于 3 的重复片段记录它们出现位置之间的间距然后对这些间距求最大公约数公约数的约数里就有很大概率包含真实的密钥长度。为什么是“很大概率”而不是“一定”因为重复片段的间距可能刚好是 m 的倍数加上一点偏移也可能纯属巧合。所以实际使用时不能只看一个公约数要把所有间距的频率统计出来出现次数最多的那个因子才最可信。我在一段约 400 字符的密文上跑这套逻辑重复片段 37 组间距做质因数分解后5 出现了 21 次真实密钥长度正好是 5。但也遇到过密钥长度 8 的样本最高频因子是 4因为 8 的倍数必然也是 4 的倍数而样本量不足以把 8 顶上去。这就需要第二步验证。from collections import Counter from math import gcd from functools import reduce def kasiski_spacings(cipher: str, min_len: int 3): c .join(ch.lower() for ch in cipher if ch.isascii() and ch.isalpha()) seen {} for i in range(len(c) - min_len 1): seen.setdefault(c[i:i min_len], []).append(i) spacings [] for gram, pos in seen.items(): for a, b in zip(pos, pos[1:]): spacings.append(b - a) return spacings def factor_guess(spacings, top_k: int 5): cnt Counter() for d in spacings: for f in range(2, 21): # 只看 20 以内密钥通常不会太长 if d % f 0: cnt[f] 1 return cnt.most_common(top_k)3.2 弗里德曼重合指数用一个数字直接估出周期卡西斯基是“找重复”弗里德曼重合指数IC则是“看统计”。它的定义很朴素从文本中随机抽两个字符它们相同的概率。英文自然文本的 IC 大约在 0.065 到 0.068 之间因为 E、T、A 这些字母出现得多撞上的概率就高而完全均匀随机的 26 个字母IC 只有 1/26 ≈ 0.0385。维吉尼亚加密把频率摊平了整体 IC 会掉到 0.038 附近。但如果我们按假定的密钥长度 m 把密文分组每一组内部其实是同一位移的凯撒如果 m 猜对了每组单独算 IC 会明显回升到 0.06 以上如果 m 猜错了组分得不纯IC 就还在 0.04 徘徊。这个判据比卡西斯基稳定得多因为它用上了全部字符不依赖是否出现重复片段。def index_of_coincidence(s: str) - float: n len(s) if n 2: return 0.0 freq Counter(s) return sum(v * (v - 1) for v in freq.values()) / (n * (n - 1)) def guess_key_length(cipher: str, max_len: int 20): c .join(ch.lower() for ch in cipher if ch.isascii() and ch.isalpha()) result [] for m in range(1, max_len 1): groups [c[i::m] for i in range(m)] avg sum(index_of_coincidence(g) for g in groups) / m result.append((m, round(avg, 5), round(abs(avg - 0.066), 5))) result.sort(keylambda x: x[2]) return result实际跑下来正确长度的那一行 IC 通常在 0.062 到 0.072 之间而错误长度的 IC 大多低于 0.05。有一个例外当 m 是真实长度的整数倍时比如真实是 4你试 8每组内部仍然同位IC 也会偏高。所以候选列表里排前面的往往包含 m、2m、3m需要用真正的破解成功率来最终确定。3.3 分组卡方把多表拆回单表之后就是老熟人的地盘密钥长度确定之后剩下的工作就非常机械了。把密文按c[i::m]分成 m 组第 j 组的所有字母都只受到 key[j] 这一个位移的影响所以它等价于位移为 key[j] 的凯撒密文。对每一组枚举 26 种可能的位移把每种位移下的字母分布与英文字母标准频率做卡方检验取卡方值最小的那个位移。卡方统计量的含义是“观测频数与期望频数的偏差平方除以期望频数”越小说明分布越贴近英文。这里有个细节样本少的时候每组只有二三十个字母卡方非常不稳定经常解出一两个错误字符。解决办法是把候选结果按卡方值排序人工看前几名或者用已知的明文结构比如常见的开头的 THE做交叉验证。ENG_FREQ { a: .0817, b: .0149, c: .0278, d: .0425, e: .1270, f: .0223, g: .0202, h: .0609, i: .0697, j: .0015, k: .0077, l: .0403, m: .0241, n: .0675, o: .0751, p: .0193, q: .0010, r: .0599, s: .0633, t: .0906, u: .0276, v: .0098, w: .0236, x: .0015, y: .0197, z: .0007, } def chi_square(seg: str) - float: n len(seg) if n 0: return float(inf) freq Counter(seg) total 0.0 for ch, exp in ENG_FREQ.items(): obs freq.get(ch, 0) expect exp * n total (obs - expect) ** 2 / expect return total def crack_by_length(cipher: str, m: int) - str: c .join(ch.lower() for ch in cipher if ch.isascii() and ch.isalpha()) key [] for j in range(m): seg c[j::m] best_shift, best_score 0, float(inf) for s in range(26): shifted .join(chr((ord(ch) - 97 - s) % 26 97) for ch in seg) score chi_square(shifted) if score best_score: best_score, best_shift score, s key.append(chr(best_shift 97)) return .join(key)3.4 把三段拼起来跑一次完整破解准备一段足够长的密文先用 IC 找候选长度再用卡西斯基做交叉验证最后分组求解def full_crack(cipher: str, max_len: int 20): candidates guess_key_length(cipher, max_len)[:3] print(IC 候选密钥长度, candidates) print(卡西斯基因子统计, factor_guess(kasiski_spacings(cipher))) for m, avg_ic, _ in candidates: k crack_by_length(cipher, m) plain vigenere_decrypt(cipher, k) print(f长度 {m} - 密钥 {k} - 前 60 字{plain[:60]})这里有一个很关键的经验不要只信一次输出。三个候选长度都跑一遍人工扫一眼解出来的文本可读性几乎一眼就能定。机器判断分布人判断语义两者结合比任何单一指标都可靠。我早期的版本尝试用打分函数自动挑最优解结果在一段密钥长度 7、长度不到 300 字符的样本上把 7 和 14 弄反了最后还是人工看出来的。4. 密钥长度到底怎么选一份能对照的量化参考4.1 重合指数的判据表判断密钥长度时光看“接近 0.066”不够还要看样本量带来的波动范围。下面是我在若干测试样本上总结的经验区间供参考对照平均 IC 范围含义下一步动作0.060 以上极可能猜对或猜成了真实长度的整数倍直接分组求解并人工验证0.050 – 0.060长度可能对但样本偏短用卡西斯基交叉验证0.040 – 0.050基本可以排除跳过0.038 附近分错组或密钥长度超出搜索上限扩大搜索范围明显低于 0.036密文可能含大量干扰字符先清洗文本顺便说一句密文里如果夹杂着大量空格、数字或者人为插入的随机符号一定要先清洗。我曾经在一段“每隔几个字母插一个随机符号”的密文上折腾了半小时以为是密钥长度判断失败后来发现是脏数据把分组位置全部错位了。清洗规则很简单只保留英文字母并统一小写。4.2 密钥长度与破解难度的关系很多人直觉认为密钥越长越安全这对维吉尼亚来说方向正确但细节有讲究。安全性取决于两个因素的乘积密钥的未知信息量以及每个分组能拿到多少统计样本。密钥长度典型可破所需密文长度主要障碍实操难度130 字符左右无极低2 – 4100 字符左右分组样本少卡方抖动低5 – 10200 – 400 字符长度判断容易翻倍中11 – 20500 字符以上IC 峰值不突出中高20 以上视情况可能上千候选长度多逐个人工验证耗时高这里的规律可以概括成一句话密钥在变长但密文长度是固定的所以每个分组能分到的样本在以 m 的速度减少。当每组只剩十几个字母时频率分析就失效了因为统计规律需要样本量支撑。但如果密钥长度接近明文长度一次性密码本这种加密在信息论意义上就是不可破的这也是为什么后来的密码学强调“密钥不能复用”。5. 它在教学和实战里到底该怎么用5.1 练习题里它通常以什么形态出现在各类密码学练习题中维吉尼亚一般不会直接把密钥给你而是给出三种情况一种是有提示的比如“密钥是一个常见英文单词”这时可以拿字典对着候选长度做暴力匹配一种是只给密文那就要走完整的 IC 加卡西斯基流程还有一种更刁钻的密钥本身也是动态的比如密钥是明文的某个函数那已经偏离标准维吉尼亚了需要单独分析。我遇到最多的一种“变体”是密钥长度恰好等于明文长度的一部分导致分组极短IC 在各种长度上都看不出明显峰值。这时候可以反过来利用已知明文攻击如果能猜到明文里有某个高频词比如 “the”“and”把它在密文的每个可能位置平移一遍倒推密钥片段再拼出完整周期。5.2 作为教学工具它最值得讲的三点第一是多表替换的思路本身。维吉尼亚是第一个把“位置”引入加密的经典方案这个思想在后来的分组密码、流密码里都有影子只是实现方式从加减法变成了复杂的非线性运算。第二是统计特性的脆弱性。它看起来把频率摊平了但只要周期性存在摊平就是可逆的。这引出了现代密码学的一条基本原则好的密码必须把统计规律彻底打散靠的是充分的混淆和扩散而不是简单的轮换。第三是已知明文攻击的价值。在维吉尼亚的破解中一段已知明文能大幅缩短破解时间因为可以同时确定密钥片段的位移。这一点在实际攻防里同样成立任何能泄露明文结构的信息都是突破口。5.3 别把它当成“能用的加密工具”这一点我必须强调维吉尼亚密码在今天只能作为教学模型任何真实场景下的数据保护都不能用它。它的密钥空间是 26 的 m 次方看似很大m10 时已经超过 1.4e14但攻击者根本不需要穷举密钥因为统计攻击的复杂度远低于密钥空间。用更直白的说法它的“安全强度”不是由密钥空间决定的而是被结构漏洞压得很低。如果你在做一个需要隐藏内容的趣味小工具比如给朋友发一段谜题用它没问题但要在说明里讲清楚这只是个游戏。真实的敏感数据交给经过公开评审的现代对称或非对称算法这是底线。最后分享一个我在调试这类脚本时养成的小习惯每次写完加解密先拿 LEMON 和 ATTACKATDAWN 这组标准向量跑一遍结果对不上八成是密钥推进规则或者 ASCII 偏移写错了两分钟就能定位问题不用从头到尾读代码。这个小检查帮我省下的时间比任何调试器都多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询