上周末整理旧代码,翻出来一个大学时写着玩的替换密码破解脚本。当时觉得特别神奇——把一串乱码丢进去,程序转几圈,明文自己就浮出来了。后来搞明白原理之后才发现,替换密码作为人类最早使用的加密手段之一,破解套路其实非常固定:频率分析开道,字典打分收尾,中间再塞一点搜索算法。用Python来做这件事再合适不过,因为处理字符串、统计字符、读写词典这些活儿,Python标准库全包了,不需要装任何第三方库。这篇文章我会把从凯撒密码到一般单表替换密码的破解过程完整拆开,配上可以直接跑起来的代码,适合看完基础语法但还没做过项目的人,也适合想复习文本处理和数据统计的读者。
1. 替换密码是什么,破解它到底在做什么
1.1 从凯撒密码到一般单表替换
替换密码的核心思想一句话就能说完:把明文里的每个字母,按照某个固定规则替换成另一个字母。最经典的例子是凯撒密码,据说古罗马的凯撒在打仗时用过,做法是把每个字母往后移动固定的位数。移动3位的话,A变成D,B变成E,C变成F,"HELLO"就变成"KHOOR"。解密的时候反过来移3位就行。
凯撒密码只是替换密码里最朴素的一种,因为它的替换完全是线性的:所有字母统一平移。后来出现的所谓单表替换密码,规则更宽松——不再要求平移,而是任意打乱26个字母的一一对应关系。比如我可以规定A换成Q,B换成M,C换成X,只要保证每个字母只对应一个字母、解密时能倒推回去,就算是一个合法的替换表。这样一来密码空间瞬间从凯撒的26种可能性膨胀到26!种,也就是大概4×10的26次方种可能性,靠人手穷举是绝对不现实的。
那问题就来了:既然替换规则那么多,我们又不知道规则,怎么把密文变回明文?这正是这篇文章要解决的核心问题。破解思路不依赖加密本身,而依赖于语言的自然统计特征:任何一门自然语言,字母出现频率都不是均匀的。英文里E最多,T排第二,A第三,这些规律藏在所有正常的文本里,密文只是换了层皮,底层的频率分布还是露在外面的。
1.2 一条完整的破解流水线
把破解过程拆开看,其实就是三步。
第一步,统计密文里每个字母出现的次数,按频率从高到低排出来。这一步相当于做“体检”,看看这串乱码里哪些字母是主角。
第二步,把这个频率顺序和英文字母的正常频率顺序对照起来,猜测密文字母对应哪个明文字母。比如密文里出现最多的字母,先假设它是E;出现第二多的,假设是T。这一步能给出一个粗略的替换表,但往往不够准,尤其是文本不长的时候。
第三步,也是最关键的一步,想办法“打分”和“修正”。把猜测的替换表套回密文,得到一段“准明文”,然后拿一本地道的英文词典去比对——单词在词典里命中越多,说明这段文字越像人话,这个替换表越可能是对的。然后不断微调替换表,让得分越来越高,直到整句话读起来通顺。这个过程在算法上叫搜索优化,我后面会用一个很直观的爬山法来实现。
整条流水线用Python写下来,核心代码加起来不超过一百行,而且全程只依赖标准库。下面我先把频率分析这个理论基础讲透,再逐段上代码。
2. 频率分析:为什么字母统计能当突破口
2.1 英文字母的天然偏向
先看一组数据。这是英文长篇文本里各字母出现的平均百分比,我整理了一份常用表:
| 字母 | 频率(%) | 字母 | 频率(%) | 字母 | 频率(%) |
|---|---|---|---|---|---|
| A | 8.17 | J | 0.15 | S | 6.33 |
| B | 1.49 | K | 0.77 | T | 9.06 |
| C | 2.78 | L | 4.03 | U | 2.76 |
| D | 4.25 | M | 2.41 | V | 0.98 |
| E | 12.70 | N | 6.75 | W | 2.36 |
| F | 2.23 | O | 7.51 | X | 0.15 |
| G | 2.02 | P | 1.93 | Y | 1.97 |
| H | 6.09 | Q | 0.10 | Z | 0.07 |
| I | 6.97 | R | 5.99 |
E独占12.7%,加上T、A、O、I、N、S、H,前八个字母加起来超过63%。反过来,Q、Z、X、J这些字母平时几乎见不到。这种极度不均衡的分布,就是频率分析的底气所在。
为什么会有这种偏差?原因在语言本身。英文里最常用的词是THE、OF、AND、TO这类,它们反复出现,导致E、T、A、O、N这些字母被频繁使用。而每个字母对应的发音、构词习惯又不一样,像Q后面几乎总是跟着U,X基本只会出现在一些特定词汇里。这些规律是几百年语言演化沉淀下来的,不是哪个人刻意设计的。
替换密码做了一件什么事?它只是把字母“换了名字”,但原来的统计规律被原封不动地保留了下来。密文里出现最多的那个字母,大概率就是明文里的E;反过来说,如果密文里有个字母几乎不出现,那它在明文里很可能对应的是Q或者Z。这就给破解者留了一条巨大的后门。
2.2 频率映射怎么建立,又为什么不够用
用Python做频率统计,最简单的工具是collection模块里的Counter。我可以写一个函数,把文本里所有字母挑出来,不计大小写,统计每个字母出现的次数,再算一下占比:
from collections import Counter def letter_frequency(text): letters = [c.lower() for c in text if c.isalpha()] total = len(letters) freq = Counter(letters) return freq, total这里有个细节:c.isalpha()用来过滤掉空格、标点和数字,只保留字母;c.lower()统一转成小写,避免大写的A和小写的a被当成两个字符。
拿到频率之后,建立初始映射的思路很直接。上面我整理了一份英文字母频率从高到低的顺序:e, t, a, o, i, n, s, h, r, d, l, c, u, m, w, f, g, y, p, b, v, k, j, x, q, z。把密文字母按频率从高到低排好,然后按位置一一对应上去:
ENGLISH_FREQ_ORDER = "etaoinshrdlcumwfgypbvkjxqz" def build_initial_mapping(cipher_text): freq, _ = letter_frequency(cipher_text) sorted_cipher = [ch for ch, _ in freq.most_common()] mapping = {} for i, cipher_letter in enumerate(sorted_cipher): if i < len(ENGLISH_FREQ_ORDER): mapping[cipher_letter] = ENGLISH_FREQ_ORDER[i] return mapping这样出来的映射表,理论上能解开一部分字母,但实际用起来经常会翻车,原因有两个。
第一,文本长度不够。频率分析是统计规律,需要足够多的样本才能稳定收敛。如果你破解的密文只有二三十个字母,那统计出来的频率顺序完全是随机的,跟真正的英文频率对不上。第二,即使是几百个字母的长文本,也不能保证每个字母的频率排名跟理论值完全一致。有些文章恰好是用词比较偏,比如一篇讲海洋生物的科普文,X、Z的出现频率可能远超平均。所以频率分析给的映射只能当“初稿”,不能当“定稿”。
这种时候就需要引入第二个信息源:词典。下面第三部分我就把频率分析和词典打分结合起来,把破解从“半猜”变成“全自动”。
3. 用Python实现三种破解方案
3.1 环境准备:纯标准库就够了
先说环境,这部分对新手特别友好。你只需要装好Python,版本3.10以上就行,系统自带的Python 3都能跑。编辑器用VSCode或者随便什么顺手的东西都行,甚至文本编辑器加命令行也可以。不用装numpy,不用装pandas,更不用碰pip install,这篇文章的所有代码只用re、collections、random、copy这四个标准库模块。
很多人在Python入门阶段会被各种库的安装搞到崩溃,什么版本不兼容、缺依赖、pip报错,一套组合拳下来热情全没了。替换密码破解这个项目有个天然优势:它涉及的核心操作就是字符串处理、字典操作、正则匹配和随机数,全是Python基础语法就能覆盖的内容。你把Python下载安装好,打开编辑器新建一个.py文件,就能直接开干。
3.2 暴力破解凯撒密码:26个位移挨个试
先从最简单的凯撒密码上手。凯撒密码一共只有26种可能——因为英文字母就26个,位移量只能是0到25之间的整数。所以最粗暴的破解方式就是26种全试一遍,然后看哪个结果最像人话。这招在密码学里叫暴力枚举,放在别的密码体系里可能不现实,但对付凯撒密码是降维打击。
先写一个通用的凯撒解密函数,能处理大小写,遇到非字母字符原样保留:
def caesar_decrypt(cipher, shift): result = [] for ch in cipher: if ch.isalpha(): base = ord('A') if ch.isupper() else ord('a') result.append(chr((ord(ch) - base - shift) % 26 + base)) else: result.append(ch) return "".join(result)这里的核心是那行取模运算:(ord(ch) - base - shift) % 26。它把字母先转成0到25的数字,减去位移量,再用模运算保证结果落在0到25的范围内,最后转回字母。mod 26这个操作是整段代码的灵魂,凯撒密码的循环特性全靠它体现。
接着写暴力破解。用一个列表把26种结果全存下来,再结合一个常见英文单词集合来判断哪个结果最合理:
import re COMMON_WORDS = { "THE", "OF", "AND", "TO", "IN", "IS", "YOU", "THAT", "IT", "HE", "WAS", "FOR", "ON", "ARE", "AS", "WITH", "HIS", "THEY", "AT", "BE", "THIS", "HAVE", "FROM", "OR", "ONE", "HAD", "BY", "WORD", "BUT", "NOT", "WHAT", "ALL", "WERE", "WE", "WHEN", "YOUR", "CAN", "SAID", "THERE", "USE" } def break_caesar(cipher): best_shift = 0 best_score = -1.0 best_text = "" for shift in range(26): plain = caesar_decrypt(cipher, shift) words = re.findall(r"[A-Za-z]+", plain.upper()) if not words: continue hits = sum(1 for w in words if w in COMMON_WORDS) score = hits / len(words) if score > best_score: best_shift, best_score, best_text = shift, score, plain return best_shift, best_text, best_score打分逻辑写得很朴素:把解密结果按空格拆成一个个单词,统计其中有多少个单词在常见词表里。命中率越高,说明这段文字越接近正常英文。比如密文"WKH EHVW ZDB WR SUHGLFW WKH IXWXUH LV WR LQYHQW LW",跑一遍程序,位移量3那组结果里会出现THE、TO、THE、IS、TO这些常见词,命中率明显高于其他25组,于是程序会稳稳地把位移3选出来,输出"THE BEST WAY TO PREDICT THE FUTURE IS TO INVENT IT"。
3.3 频率分析建立初始映射
凯撒密码搞定了,现在升级难度,处理真正的一一对应替换表。这时候不能用暴力枚举了,因为26!种排列太多了。我的做法是先拿频率分析做一轮猜测,产出一个初始映射表。
上一节写过build_initial_mapping函数,但它有个缺陷:只处理了密文里出现过的字母。密文里那些没出现过的字母,对应关系还是空的。为了后面做搜索方便,必须把26个字母的映射关系补齐,保证这是一张完整的双射表。补全的办法很简单,把没被映射的明文字母和没被用掉的密文字母,按顺序随便配对上:
ALL_LETTERS = set("abcdefghijklmnopqrstuvwxyz") def complete_mapping(partial): mapping = dict(partial) mapped_cipher = set(mapping.keys()) mapped_plain = set(mapping.values()) remaining_cipher = [ch for ch in "abcdefghijklmnopqrstuvwxyz" if ch not in mapped_cipher] remaining_plain = [ch for ch in "abcdefghijklmnopqrstuvwxyz" if ch not in mapped_plain] for c, p in zip(remaining_cipher, remaining_plain): mapping[c] = p return mapping这里的zip(pairing)可能会有隐患,如果remaining_cipher和remaining_plain数量不一致就会出问题。但在双射的前提下,两边数量一定相等,所以可以放心用。另外要注意,zip是按顺序配对的,所以谁先谁后无所谓,因为这部分本来就是瞎猜的,后续搜索会去修正它。
还需要一个把映射套回密文的函数:
def apply_mapping(cipher_text, mapping): return "".join( mapping.get(c.lower(), c) if c.isalpha() else c for c in cipher_text )这个函数遍历密文的每个字符,是字母就去映射表里查替换,查不到就原样保留;非字母字符如空格、标点则不动。所谓“替换密码只改字母不改其他符号”这个特性,就在这里体现。
3.4 字典打分加搜索:让程序自动逼近明文
初始映射大概率是错的,尤其是密文很短的时候。怎么修正?我的思路是把它变成一个优化问题:一张映射表对应一段准明文,准明文越像英文,映射表越好。那么“像不像英文”怎么量化?用上一节的字典命中率作为评分函数,然后让程序在映射表空间里不断搜索,找一个评分最高的映射表。
搜索算法我选了最简单直观的爬山法,思路跟爬山一样:从当前这个山头出发,往旁边试探,如果旁边更高就爬过去,一直爬到没法更高为止。
import copy import random def load_wordlist(path="/usr/share/dict/words"): words = set() with open(path, "r", encoding="utf-8", errors="ignore") as f: for line in f: w = line.strip().lower() if w.isalpha() and 2 <= len(w) <= 15: words.add(w) return words def word_score(text, wordlist): words = re.findall(r"[a-z]+", text.lower()) if not words: return 0.0 hits = sum(1 for w in words if w in wordlist) return hits / len(words) def crack_substitution(cipher_text, wordlist, iterations=20000, seed=42): random.seed(seed) current_map = complete_mapping(build_initial_mapping(cipher_text)) best_map = copy.deepcopy(current_map) best_text = apply_mapping(cipher_text, best_map) best_score = word_score(best_text, wordlist) letters = list(current_map.keys()) for _ in range(iterations): a, b = random.sample(letters, 2) trial_map = copy.deepcopy(current_map) trial_map[a], trial_map[b] = trial_map[b], trial_map[a] trial_text = apply_mapping(cipher_text, trial_map) trial_score = word_score(trial_text, wordlist) if trial_score >= best_score: best_map = trial_map best_text = trial_text best_score = trial_score current_map = trial_map return best_map, best_text, best_score爬山法的核心操作就一个:随机交换映射表里两个字母的对应关系。比如原来映射是a->x, b->y,交换之后就变成a->y, b->x。每次交换完重新打分,如果分数变高了就接受这次交换,否则放弃。整个循环默认跑两万次。
这个“交换映射”的操作是替换密码自动破解的经典思路,它保证无论怎么交换,映射表始终是一张完整的双射表——26个明文字母仍然各对应一个密文字母,不会出现一字母多映射的混乱情况。至于为什么要copy一份再交换,而不直接在current_map上改,是因为试错过程中有可能要回退,留一个副本更安全。
4. 完整实战:从乱码到明文
4.1 把整条流水线串成一份脚本
前面几个函数分开放着看可能还有点散,现在我把它们组合成一份完整的脚本。这份脚本可以直接保存成substitution_cracker.py,在命令行里运行。整体结构是:先加载词典,再对密文做频率分析得到初始映射,最后用爬山搜索优化:
import re import copy import random from collections import Counter ENGLISH_FREQ_ORDER = "etaoinshrdlcumwfgypbvkjxqz" def letter_frequency(text): letters = [c.lower() for c in text if c.isalpha()] return Counter(letters), len(letters) def build_initial_mapping(cipher_text): freq, _ = letter_frequency(cipher_text) sorted_cipher = [ch for ch, _ in freq.most_common()] partial = {} for i, cipher_letter in enumerate(sorted_cipher): if i < len(ENGLISH_FREQ_ORDER): partial[cipher_letter] = ENGLISH_FREQ_ORDER[i] return partial def complete_mapping(partial): mapping = dict(partial) mapped_cipher = set(mapping.keys()) mapped_plain = set(mapping.values()) remaining_cipher = [ch for ch in "abcdefghijklmnopqrstuvwxyz" if ch not in mapped_cipher] remaining_plain = [ch for ch in "abcdefghijklmnopqrstuvwxyz" if ch not in mapped_plain] for c, p in zip(remaining_cipher, remaining_plain): mapping[c] = p return mapping def apply_mapping(cipher_text, mapping): return "".join( mapping.get(c.lower(), c) if c.isalpha() else c for c in cipher_text ) def load_wordlist(path): words = set() with open(path, "r", encoding="utf-8", errors="ignore") as f: for line in f: w = line.strip().lower() if w.isalpha() and 2 <= len(w) <= 15: words.add(w) return words def word_score(text, wordlist): words = re.findall(r"[a-z]+", text.lower()) if not words: return 0.0 hits = sum(1 for w in words if w in wordlist) return hits / len(words) def crack_substitution(cipher_text, wordlist, iterations=20000, seed=42): random.seed(seed) current_map = complete_mapping(build_initial_mapping(cipher_text)) best_map = copy.deepcopy(current_map) best_text = apply_mapping(cipher_text, best_map) best_score = word_score(best_text, wordlist) letters = list(current_map.keys()) for _ in range(iterations): a, b = random.sample(letters, 2) trial_map = copy.deepcopy(current_map) trial_map[a], trial_map[b] = trial_map[b], trial_map[a] trial_text = apply_mapping(cipher_text, trial_map) trial_score = word_score(trial_text, wordlist) if trial_score >= best_score: best_map = trial_map best_text = trial_text best_score = trial_score current_map = trial_map return best_map, best_text, best_score if __name__ == "__main__": cipher = input("输入密文: ").strip() wordlist = load_wordlist("/usr/share/dict/words") mapping, plaintext, score = crack_substitution(cipher, wordlist) print("解密结果:", plaintext) print("字典命中率:", round(score, 4))如果你用的是Windows或者没有系统词典文件,load_wordlist可以换成传入自己的词表文件路径,这个我在第5部分会详细说。
4.2 运行效果与结果分析
拿一个我手工构造的单表替换密文来测试。明文是莎士比亚那句经典的"TO BE OR NOT TO BE THAT IS THE QUESTION",我随机打乱字母对应关系之后,密文变成:
EN KM NY AEN EN KM EPXE CR EPM FHMRECNA
用上面脚本跑一遍,结果分成两个重要阶段。第一次用初始频率映射直接解密时,得到的是:
et oa tr iet et oa ende sh ena lcahesti
这个结果跟正确答案差得十万八千里。原因就是我前面说的:这段密文太短,总共30个字母,频率统计被几个重复单词带偏了,E、N、M霸占了前三名,跟英文正常的频率顺序完全对不上。这时候如果迷信频率分析,直接断定E对应e、N对应t,就会走进死胡同。
但爬山搜索跑完两万次迭代之后,结果变成了:
to be or not to be that is the question
字典命中率直接到了1.0,每个词都能在词典里找到。这说明什么?说明在文本不足、频率分析失灵的情况下,词典打分加搜索优化这套组合拳依然能把正确答案捞回来。原因在于,英文单词长度和字母组合的约束非常强,一个错误的映射往往会制造出大量不存在的“伪单词”,词典能立刻识破;而正确的映射会让整句话变成人话,这种信号在评分函数里会被无限放大。
顺便说下性能。两万次迭代看起来多,但因为每次评分只处理一段几百字符的文本,跑完也就一两秒的事。我自己实测过,一段两百个单词的密文,整个破解流程在普通笔记本上三秒内出结果。
4.3 高级技巧:词形匹配和模拟退火
爬山法有个明显毛病:容易卡在局部最优解里出不来。爬山嘛,只能往高处走,走到一个小山头发现四周都更低,就停住了,但远处可能有一座更高的山。要解决这个问题,正规做法是用模拟退火算法——搜索前期允许以一定概率接受更差的解,相当于允许“下坡”,随着迭代推进这个概率逐渐降低,最终收敛到一个高质量解。代码改动很小,关键是那个温度参数,有兴趣的人可以在爬山法基础上自己扩展。
另一个辅助技巧是词形匹配。英文里很多单词的词形结构是独一无二的,比如"TO BE OR NOT TO BE"里的TO,它的词形就是“两个不同字母”;MISSISSIPPI这种词的词形更是极其特殊。利用词形可以从密文中直接锁定候选明文字,再结合频率信息互相印证。词形匹配函数也很短:
def word_pattern(word): seen = {} pattern = [] for ch in word: if ch not in seen: seen[ch] = len(seen) pattern.append(str(seen[ch])) return ".".join(pattern)把词典里所有词都按词形建立索引,再把密文单词逐一词形查表,能找到的候选词列表会非常有价值。我在实际破解里,经常先用词形匹配定住几个关键单词(比如重复出现的高频词),再用这些定住的字母去约束整个映射表,速度比纯随机搜索快非常多。
5. 常见问题与排错实录
5.1 文本太短,频率分析失灵
这是最常踩的坑,我第4.2节的例子已经演示过一次了。当密文少于100个字母时,频率分析的排名基本不可信。这时候有两个补救办法:一是把词典打分的权重调大,让搜索算法更多依赖单词结构而不是频率;二是人工介入,把几个明显的高频词先猜出来,手工固定映射,再让程序在此基础上搜索。
实际工作中我还会先跑一遍Index of Coincidence(重合指数)来“验货”。重合指数衡量一段文本里随机抽出两个字母恰好相同的概率,正常英文大概在0.065左右,随机乱序的字母串大概在0.038左右。如果密文的重合指数明显偏低,那要小心了——它可能根本就不是单表替换,而是维吉尼亚密码那种多表替换。用Python算重合指数就几行代码的事,有兴趣可以自己写。
5.2 大小写、空格和标点怎么处理
替换密码通常只替换字母,所以空格和标点应该原样保留,这样单词边界信息不丢失,能大幅降低破解难度。处理时统一按小写做映射,解密完再根据需要恢复大小写格式。我代码里apply_mapping遇到isalpha()为False的字符会直接原样返回,就是这个原因。
很多人会忽视一个细节:连字符、撇号这类字符千万不能直接删掉。它们往往能提供词边界和词性信息,比如"don't"、"o'clock"这种缩写结构,对人工核对破解结果很有帮助。如果密文里带了数字,同样原样保留,除非你能确定数字也被加密过。
5.3 本地没有词典文件
我代码里默认从/usr/share/dict/words加载词典,这个文件在大多数Linux和macOS系统上都存在,但Windows上往往没有。如果加载失败,有三个备选方案。
第一个方案:去网上下载一份公开的英文单词表,比如很多开源项目里都有words_alpha.txt,下载后放到脚本同目录,把load_wordlist的路径改成相对路径。第二个方案:用pip安装wordfreq这个库,它内置了多语种词频数据,可以直接用来打分,准确率比普通词典还高。第三个方案:如果既不想下载也不想装库,就把前文那个COMMON_WORDS集合扩充到几百个词,虽然覆盖率低一点,但对短文本够用了。
我个人推荐前两种,因为词库质量直接决定评分函数的好坏。词表太小或者太偏,会导致搜索算法把错误映射当成最优解。
5.4 怎么判断破解结果对不对
程序输出一段“解密结果”,怎么确定它真的对了?我的经验是三重验证。第一重看字典命中率,如果低于0.5,基本上可以断定结果不对;如果能到0.9以上,大方向应该没问题。第二重看语言学特征,比如句子里有没有大量以Q结尾的词,有没有连续三个相同字母的非法组合,正常的英文里这些情况极其罕见。第三重是人工通读,前两重都过了,最后把明文读一遍,看语义是否通顺连贯。
还有一个小窍门:把已知的结果反向验证。比如某段密文多次出现同一个三字母组合,如果解密后对应的是THE,那这个猜测就非常可靠——THE是英文里最常见的三字母词,没有之一。把这个词固定住,再让程序在“THE已经被固定”的约束下继续搜索,成功率会显著提升。我在实际破解里这套“锚定-放宽”的玩法效果最好。
最后说点个人体会。替换密码在今天已经没有实际安全价值了,手机、银行、网站用的加密算法随便拎一个出来都比它复杂几万倍,但我觉得它依然是理解密码学最好的入门教具。它把一个抽象的“加密对抗破译”概念,变成了一个看得见摸得着的统计游戏:这边是语言规律形成的信息冗余,那边是攻击者利用冗余做的信息恢复,双方博弈的战场就是那26个字母。用Python亲手实现一遍破解流程,你对字符串处理、数据统计、搜索算法这三个基本功的掌握,会比看十篇教程都扎实。以后真要碰现代密码,或者做文本分析、爬虫数据清洗之类的事,这段经历留下的手感都用得上。