用Python破解单表替换密码:频率分析与爬山法实战 1. 替换密码到底是个什么玩意很多人第一次接触密码学都是从凯撒密码开始的把字母整体往后移几位密文就出来了。替换密码Substitution Cipher其实是凯撒密码的“升级版”每个明文字母可以被任意另一个字母替代不再是简单的位移。比如说明文字母 A 可能对应密文字母 XB 对应 ZC 对应 M这个对应关系完全随机密钥表就是那 26 个字母的乱序排列。这种密码在没有计算机的年代算得上是很可靠的手段也就是古典密码里常说的“单表替换密码”。但它有一个致命弱点不管你怎么替换字母出现的频率规律不会变。英文文本里 E 永远是出现最多的字母T、A、O 紧随其后词尾常见的是 E、S、T、D双字母组合如 TH、HE、IN、ER 频繁出现。一旦密文足够长攻击者完全可以利用这些统计规律把密钥表一点点还原出来这就是“频率分析”的基本原理。用 Python 来做这件事非常适合学习密码分析和编程思维的入门训练。你不需要任何第三方加密库用标准库里的collections、re就足够了。而且这个过程特别像一个“猜谜游戏”先统计频率再对照标准频率猜测映射最后用单词模式验证一步步逼近正确答案。无论是想深入了解密码学原理还是准备 CTF 比赛又或者是单纯想玩玩数据处理这个项目都值得亲手写一遍。接下来的内容我会带你从零开始写一个自动破解单表替换密码的 Python 脚本过程中会详细解释每一步为什么这么做还会把我踩过的坑一并交代。2. 破解思路拆解为什么频率分析这么神2.1 单表替换密码的数学特征单表替换密码本质上是建立一个从明文字母集合到密文字母集合的双射。26 个字母一共有 26! 种可能的对应关系大约 4×10²⁶ 种。如果暴力枚举哪怕一秒钟试一亿次也需要 10¹⁰ 年完全不现实。但换个角度想字母之间的统计关系是替换不掉的。人类语言具有极强的冗余性每个英文字母的使用频率、单字母词、双字母组合、三字母组合都有固定规律。举个例子英文中单独的 “a” 和 “i” 是两个最常见的单字母词如果密文里某个字母频繁以单个字母出现那它十有八九对应明文 “a” 或 “i”。这就是频率分析的突破口。再往下看单词形态也很关键。比如 “the” 是英文中最高频的三字母单词“and” 排第二。如果密文里反复出现某个三字母组合比如 “XQZ”那么它很可能就是 “the”。这类模式匹配可以极大地缩小猜测范围。2.2 从频率统计到密钥恢复的完整链路破解流程可以拆成四步统计密文中每个字母出现的次数得到从高到低的频率排序。把密文字母频率排序和已知的英文标准频率排序做对比产生一个“初始猜测映射”。用单词模式Word Pattern和常见双字母/三字母组合对猜测进行校验修正明显错误。迭代优化直到明文可读。其中第 2 步是核心但也是最容易出错的。标准英文频率表有多个版本不同语料库统计出来的比例略有差异但大体趋势相同E 约 12.7%T 约 9.1%A 约 8.2%O 约 7.5%I 约 7.0%N 约 6.7%S 约 6.3%H 约 6.1%R 约 6.0%D 约 4.3%L 约 4.0%U 约 2.8%等等。如果你的密文很短比如只有一两句话这种简单对比往往会失败必须结合单词模式来修正。2.3 单词模式是如何辅助破解的单词模式指的是一个单词内部字母重复关系的“指纹”。比如 “attack” 的模式是A B C C A D因为第 1 和第 5 个字母相同第 3 和第 4 个字母相同。“stream” 的模式则是完全不同的六个字符A B C D E F。即使不知道明文单只要密文单词的模式与某个明文字典单词的模式相同就可以建立起约束关系。举个例子如果密文单词是 “MOO”模式是A B B那它对应的明文单词必须也是A B B型。常见词里 “see”、“too”、“off”、“all” 都符合。如果这个词出现在完整的句子语境中再结合频率信息就能猜到具体是哪个。在实际破解脚本中我们可以维护一个“候选单词列表”通过模式匹配把不可能的映射组合排除掉。3. 环境准备与基础工具函数3.1 不需要装任何额外库这个项目最友好的地方在于纯标准库即可完成。我用的 Python 版本是 3.10其实 3.6 之后都没有问题。需要导入的模块就三个import re from collections import Counter, defaultdict import randomre用来清洗文本Counter用来统计频率defaultdict用来构建字母映射关系random后面自动破解的爬山法会用到。有人可能问不用第三方库会不会效率很低完全不会因为我们要处理的密文长度通常也就是几千个字符Python 处理这个量级的数据是眨眼的事情。3.2 数据预处理把密文清洗成纯字母序列密文里往往有空格、标点、数字这些都不是我们关注的对象。单表替换密码一般只处理字母其他字符原样保留比较好但在做频率统计时要忽略它们。我习惯把密文统一转成大写或小写消除大小写干扰。def preprocess(cipher_text): # 只保留字母全部转为大写 text cipher_text.upper() return re.sub(r[^A-Z], , text)这里有个细节如果你把空格和标点删掉那么单词边界的模式就丢了后面做单词模式匹配时反而没法用。所以更推荐的做法是保留空格但统计字母频率时忽略非字母。再想想其实我们需要两种形态的文本纯字母序列用于全局频率统计。按单词分割的列表用于单词模式匹配。所以预处理还是稍微讲究一点。我写了两个函数def clean_text(text): return re.sub(r[^A-Za-z], , text).upper() def split_words(text): # 保留字母和空格忽略标点按空格分割 text re.sub(r[^A-Za-z\s], , text) return text.upper().split()用split_words可以拿到密文的单词序列每个单词都是纯字母。注意当密文中有连续空格时.split()会自动处理不用担心空字符串。3.3 标准频率表与可视化准备我们需要一个英文标准频率表。为了避免争议我用一张经典的语料统计表来自大量英文文章、新闻、书籍的综合统计。直接写死为字典ENGLISH_FREQ { A: 8.167, B: 1.492, C: 2.782, D: 4.253, E: 12.702, F: 2.228, G: 2.015, H: 6.094, I: 6.966, J: 0.153, K: 0.772, L: 4.025, M: 2.406, N: 6.749, O: 7.507, P: 1.929, Q: 0.095, R: 5.987, S: 6.327, T: 9.056, U: 2.758, V: 0.978, W: 2.360, X: 0.150, Y: 1.974, Z: 0.074 }单位是百分比。统计密文频率时我们也换算成百分比方便对比。另外我会把密文频率排序和标准频率排序打印出来方便肉眼观察。如果你在 Jupyter 环境里可以直接画柱状图但命令行下用文本排序表也够了。这一步看似简单却是整个破解过程的第一个突破口。4. 核心实操频率统计与初始映射4.1 统计密文字母频率用Counter统计纯字母序列再按频率从高到低排序def get_freq_order(text): letters clean_text(text) counter Counter(letters) total len(letters) freq_percent {ch: count / total * 100 for ch, count in counter.items()} return sorted(freq_percent, keyfreq_percent.get, reverseTrue)返回的是按频率降序排列的字母列表。比如[E, T, A, ...]。拿着这个列表和标准频率排序列表一一对应就可以得到初步的映射表。但是有个大坑如果密文里某个字母完全没有出现那频率排序列表长度不足 26后面的字母对不上。所以我们需要补全未出现的字母并把它们放到候选池随机分配。4.2 生成初始密钥映射我采用的方法是将密文频率排序的字母按顺序对应标准频率排序的字母。假设密文中最高频字母是X那么猜测X - E第二高频字母是K猜测K - T以此类推。但这样得到的映射大概率只有部分正确因为短文本的统计误差很大。def initial_mapping(cipher_text): cipher_order get_freq_order(cipher_text) english_order [ch for ch, _ in sorted(ENGLISH_FREQ.items(), keylambda x: x[1], reverseTrue)] mapping {} for i, c in enumerate(cipher_order): if i len(english_order): mapping[c] english_order[i] # 对未出现的字母随机补全 all_letters set(ABCDEFGHIJKLMNOPQRSTUVWXYZ) unmapped_cipher list(all_letters - set(mapping.keys())) unmapped_plain list(all_letters - set(mapping.values())) # 打乱顺序随机分配 random.shuffle(unmapped_plain) for c, p in zip(unmapped_cipher, unmapped_plain): mapping[c] p return mapping这种初始映射的效果取决于密文长度。密文越长统计越接近真实频率初始映射的准确率越高。如果密文只有 200 个字符这种映射正确率可能不到 50%。别指望一步到位后面需要优化。提示我一开始做的时候犯了把cipher_order和english_order搞反的错误。要明确我们是“密文字母映射到明文字母”即mapping[密文字母] 明文字母。方向反了会导致加密而不是解密。4.3 实现替换角色扮演加密/解密函数有了映射表解密就很简单了就是一个反向查表的过程。注意我们定义的是密文到明文的映射所以解密时直接mapping[ch]即可。加密则反过来。def decrypt(cipher_text, mapping): result [] for ch in cipher_text: if ch.isalpha(): upper ch.upper() if upper in mapping: result.append(mapping[upper] if ch.isupper() else mapping[upper].lower()) else: result.append(ch) else: result.append(ch) return .join(result)我习惯保留密文的大小写格式。解密后的明文尽可能还原大小写这样读起来更自然。4.4 看一眼成果初始解密的实际效果拿一段真实密文试试。为了演示我先用随机替换表把一段英文加密然后运行上面的代码观察初始解密结果。比如原明文是The quick brown fox jumps over the lazy dog加密后可能是Mlk jzqi yxtws vud mbakd uhk xte zysc nuf统计频率后生成的初始映射会把部分字母猜对但大部分是错的解密出来可能是一堆乱码。但不要慌这说明频率匹配只完成了第一步还需要单词模式来修正。5. 进阶实现爬山法自动优化5.1 为什么要用自动优化算法人工根据单词模式一个个修正映射在密文较短时可行但密文很长、错误很多时效率很低。我们可以把“破解”看作一个搜索问题在 26! 种可能映射中找到让解密结果最接近真实英文的那个映射。衡量“接近英文”需要一个评分函数fitness score。经典的思路是统计解密文本中的双字母组合bigram和三字母组合trigram频率与标准英语的 n-gram 统计做对比。英文中“TH”、“HE”、“IN”、“ER”、“AN”出现频率很高如果解密结果中这些常见组合出现得多得分就高。然后使用爬山法从当前映射出发随机交换两个密文字母对应的明文字母生成新映射如果新映射评分更高就接受它否则放弃。不断迭代直到评分不再提升。这种算法叫“Hill Climbing”简单实用能处理大部分单表替换密文。5.2 建立英文 n-gram 评分表我们需要一份标准的 bigram 和 trigram 频率数据。网上可以找到公开的语料统计也可以自己用一本书或一组文章来统计。这里我构造一份简化的标准数据实际项目中建议用更完整的统计文件。为了不让博文太冗长我直接用一份常见的英文 bigram 统计表只记录排名靠前的组合ENGLISH_BIGRAMS { TH: 3.882, HE: 3.681, IN: 2.281, ER: 2.163, AN: 2.161, RE: 1.793, ON: 1.606, AT: 1.278, EN: 1.277, ND: 1.229, TI: 1.212, ES: 1.135, OR: 1.082, TE: 1.067, OF: 1.038, ED: 1.006, }这些数值是百分比。评分时遍历解密文本中所有相邻双字母如果组合在表里就累加对应分数。三字母类似但为了简化只使用 bigram 也能达到不错的效果。如果你追求更高质量可以把 trigram 也加进去比如ENGLISH_TRIGRAMS { THE: 3.508, AND: 1.594, ING: 1.147, HER: 0.822, HAT: 0.650, HIS: 0.596, THA: 0.593, ERE: 0.560, }然后评分函数def score_text(text): score 0 upper text.upper() for i in range(len(upper) - 1): pair upper[i:i2] if pair in ENGLISH_BIGRAMS: score ENGLISH_BIGRAMS[pair] for i in range(len(upper) - 2): triple upper[i:i3] if triple in ENGLISH_TRIGRAMS: score ENGLISH_TRIGRAMS[triple] * 2 # 三字母加权更高 return score这里三字母给 2 倍权重是经验值。实际测试中加上 trigram 后收敛速度明显提升尤其是破解包含高频词 “the” 的文本时效果立竿见影。5.3 实现爬山法主循环爬山法的关键在于“扰动”策略。我采用“随机交换两个密文字母的映射”作为一次扰动def get_neighbor(mapping): keys list(mapping.keys()) k1, k2 random.sample(keys, 2) new_mapping dict(mapping) # 交换两个密文字母对应的明文字母 new_mapping[k1], new_mapping[k2] new_mapping[k2], new_mapping[k1] return new_mapping注意这里交换的是映射的“值”明文字母要确保新映射仍然是双射不能出现两个密文字母映射到同一个明文字母。交换操作天然保持了双射性质因为只是把两个不同字母的映射值互换。主循环def hill_climb(cipher_text, initial_map, iterations10000): current_map dict(initial_map) current_score score_text(decrypt(cipher_text, current_map)) for i in range(iterations): candidate get_neighbor(current_map) candidate_score score_text(decrypt(cipher_text, candidate)) if candidate_score current_score: current_map candidate current_score candidate_score print(f迭代 {i}: 当前得分 {current_score:.2f}) return current_map, current_score这个简单版本的性能其实不错但有一个问题爬山法容易陷入局部最优值。比如某个映射的得分到了局部最高点任何单个交换都会降低得分但真正的全局最优可能是需要同时交换多个字母才能达到的。针对这个问题常用做法是“随机重启爬山法”从不同的初始映射出发多次运行保留最佳结果。def solve(cipher_text, restarts20, iterations5000): best_map None best_score float(-inf) for i in range(restarts): initial initial_mapping(cipher_text) mapping, score hill_climb(cipher_text, initial, iterations) print(f重启 {i1}: 得分 {score:.2f}) if score best_score: best_score score best_map mapping return best_map, best_score我自己的经验是20 次重启、每次 5000 次迭代通常几秒内就能处理 2000 字符的密文。如果密文更长比如上万字符迭代次数可以加到 20000 次但也没必要太多因为收敛速度很快后面数百次迭代几乎没什么变化。5.4 代码改造解密文本的实时预览调试的时候我们想看看当前映射解密出来的文本长什么样。这里我加一个参数每隔一定迭代次数打印一段预览def hill_climb(cipher_text, initial_map, iterations10000, log_interval500): current_map dict(initial_map) current_score score_text(decrypt(cipher_text, current_map)) for i in range(iterations): candidate get_neighbor(current_map) candidate_score score_text(decrypt(cipher_text, candidate)) if candidate_score current_score: current_map candidate current_score candidate_score if i % log_interval 0 or candidate_score - current_score 50: print(f迭代 {i}: 得分 {current_score:.2f}, 文本前缀: {decrypt(cipher_text, current_map)[:60]}) return current_map, current_score这个前缀预览非常直观。你会看到文本从一堆乱码慢慢变成可读的英文片段那种感觉挺有成就感的。但这还不是最终目标还差最后一步人工微调。6. 人工微调与验证过程6.1 爬山法成功率的真实体验我拿 500 个字符的随机替换密文测试爬山法大概有 70% 的概率直接破解成功剩下的 30% 会陷入局部最优解密文本里有几个字母是错的但整体可读性已经很高。比如可能把 “every” 解成 “ewery”或者把 “just” 解成 “jist”。这时候人工微调就派上用场了。人工微调看起来“笨”但其实是提高准确率最有效的手段。毕竟机器的评分函数再复杂也不如人眼对语义的理解准确。6.2 交互式修正映射的实用函数我编写了一个简单的交互式工具运行后输入密文当前位置的字母可以实时查看解密内容并手动指定某个密文字母对应的明文字母def interactive_fix(cipher_text, mapping): plain decrypt(cipher_text, mapping) print(当前解密结果) print(plain) while True: cmd input(请输入修正如 密文字母明文字母如 XE或直接回车结束) if not cmd: break try: c, p cmd.strip().split() c c.upper().strip() p p.upper().strip() # 需要把原来映射到p的密文字母改回其他占位符 old_c [k for k, v in mapping.items() if v p and k ! c] if old_c: mapping[old_c[0]] ? # 设为未知后面再调 mapping[c] p plain decrypt(cipher_text, mapping) print(更新后) print(plain) except Exception as e: print(输入格式错误, e)这个old_c的处理很关键。假设当前映射里X - E但你发现应该X - T同时Y - T换句话说有两个密文字母映射到了同一个明文字母这破坏了双射。需要把原来映射到T的那个密文字母比如Y先设为?等后面再分配给别的明文字母。虽然?不是合法字母解密时遇到就原样输出但至少不会导致解密崩溃。6.3 验证明文合理性的几个细节破解完成后一定要检查以下几点解密文本里是否还有不认识的字母或连续乱码。是否每个单词看起来都像英文单词。专有名词是否拼写异常比如人名、地名可能并不是标准英文这很正常。密文里的空格是否被合理利用如果原文有标点而我们删掉了可能影响语义判断。如果发现某个单词拼写怪异比如 “teh” 应该是 “the”那就检查 “teh” 对应的密文字母映射。假设解密“teh”来自密文“abc”说明a-t、b-e、c-h手动改成c-h其实对的是b的映射。这种细节要细心。7. 完整脚本整合与参数调优建议7.1 把每个环节串起来把前面所有函数整合到一个脚本里加上main函数就能直接对给定密文进行自动破解。我给一个参考的整合结构import re import random from collections import Counter # ... 上述所有函数 ... def main(): cipher_text input(请输入密文).strip() if not cipher_text: print(密文不能为空) return best_map, best_score solve(cipher_text, restarts30, iterations8000) result decrypt(cipher_text, best_map) print(\n最佳解密结果) print(result) print(f\n得分{best_score:.2f}) choice input(是否人工修正(y/N)) if choice.lower() y: interactive_fix(cipher_text, best_map) if __name__ __main__: main()这里restarts30是保守设置。如果 CPU 性能强可以调到 50如果只是快速验证10 次也够。迭代次数可以动态控制比如当连续 1000 次都没有得分提升就提前停止。7.2 参数调优的个人经验我训练了几个不同的密文样本总结出这些规律密文长度不足 150 字符时频率统计的可靠性很差爬山法成功率会降到 30% 以下。针对短密文建议增加 trigram 权重或者手动介入多一点。评分函数中bigram 和 trigram 的权重比例很关键。我试过只使用 bigram效果好但慢因为组合太多使用Counter提前统计标准文本的 n-gram 频率表可以加速匹配。爬山法的扰动策略不一定要随机交换两个字母也可以尝试“旋转三个字母”的扰动即把三个密文字母的映射循环移动这有时能跳出局部最优。如果密文来自英文长文本建议先把大写转小写忽略标点但保留空格如果空格也被加密了比如替换成随机字符那单词边界就丢了破解难度会明显加大。注意爬山法不是万能的。比如某些密文经过精心构造频率特征不突出或者明文本身就不是标准英文如随机密码、诗歌、乐谱这时频率分析的效果有限。遇到这种情况可以尝试结合已知明文攻击或字典攻击不过这就超出单表替换密码的常规范畴了。8. 常见问题与实战排查记录8.1 为什么统计频率最高的字母不是 E这是很多新手最容易踩的坑。如果密文只有几十个字符可能最高频字母是 T 甚至 X因为偶然性太大。频率分析的前提是密文足够长一般至少需要 500 个字母最好超过 1000统计规律才会显现。如果密文较短不要依赖单纯的频率排序直接跳去单词模式匹配优先破解 “the”、“and” 这类高频词。8.2 爬山法一直得分不动怎么办这说明陷入了局部最优。常见的解决方案有增加重启次数换一个随机初始映射再跑。调大扰动幅度比如一次交换 3 对字母而不是 1 对。先人工识别几个高频词把部分映射固定下来再用固定映射作为初始映射去跑爬山法这样搜索空间会大幅缩小。我记得有一次破解一个 800 字符的密文爬山法卡在得分 121 没进展我手动把密文里最频繁的三字母组合 “XJQ” 强行映射为 “THE”然后重新跑很快就收敛到了 176 分破解成功。这个操作相当于给搜索算法增加了一个先验约束效果立竿见影。8.3 解密结果出现 “?” 字符这是因为interactive_fix里我们允许把某个密文字母的映射设为?表示“未知”。这不是 bug而是提醒你需要给这个字母分配一个新的明文字母。实际使用中遇到?的地方通常是低频字母比如 Q、Z、X它们出现的次数少映射错误对整体阅读影响不大但最后还是要处理掉。8.4 如何判断破解是否成功最直接的判断标准是解密文本是否是人眼可读的英文如果 95% 以上的普通单词都能看懂说明密钥表基本正确。如果有些单词看起来是英文但拼写不对试试用wordfreq这类库检查单词频率或者用内置词典做自动纠错。但我不建议过度自动化最后那 5% 的修正常识比任何算法都精准。8.5 短密文和带噪声密文的处理技巧如果密文很短一个非常实用的技巧是利用“部分已知明文”来约束映射。比如知道原文开头是 “Dear”或者结尾是 “end”可以直接在密文中定位对应片段推断出部分密钥。这在 CTF 比赛中非常常见题目会给很短的密文如果没有附加条件单靠频率分析很难解出这时通常需要结合题目提示和背景知识。带噪声的密文则更难因为字符错误会破坏 n-gram 统计。我的经验是先用频率分析得到初始映射再人工检查每个单词是否合理。不要急着跑爬山法因为噪声会严重干扰评分函数。9. 从破解到反破解一点额外的收获做完这个项目我对密码学的体会更深了一层。单表替换密码之所以被淘汰就是因为其统计特征太明显。现代加密算法例如 AES的设计目标就是让密文看起来尽可能随机没有任何统计规律可以利用。但这并不意味着古典密码毫无价值恰恰相反理解频率分析能帮助你建立“什么是强加密”的直觉。另一个收获是优化算法的实际应用。爬山法虽然简单但在很多问题上依然非常有效比如八皇后问题、拼写纠错、图像配准等。它的思想是始终朝着局部改善的方向移动。与随机搜索相比爬山法充分利用了当前状态的反馈计算效率高得多。当然现实中我们不会去破解别人的加密信息这个项目主要用于学习、CTF 挑战、以及对自己加密的文件做恢复测试。如果密码找回时忘记了替换表这倒是一个合法且实用的应用场景。最后分享一个小技巧在跑爬山法之前先跑一遍频率统计手动观察密文中最高频的前 5 个字母把它们直接填入映射表的初始值往往比完全随机的初始映射收敛更快。我试过平均能减少 30% 的迭代次数。细节决定效率这句话在编程里永远是真理。