
上周在CTF交流群里碰到一个朋友卡在转轮机加密的题目上手里有一段明文和一段密文要反推转子的顺序和初始位置。他问我这种题是不是只能写脚本硬跑我说对而且用Python写一个完整的模拟器和穷举破解器总共也就一百多行。这篇文章就是那次讨论的整理版把转轮机的工作原理、Python模拟器的实现、以及最终破解脚本的完整代码一并放出来。如果你还没接触过转轮机也不用担心我会从最基础的物理结构讲起。如果你已经会写基本的Python跟着这个思路走你完全能在半小时内跑通一个能用的破解器。文章后面的适配指南还会告诉你当题目不是标准Enigma、而是某种魔改版转轮机时脚本要怎么改。1. 转轮机在CTF题目里通常以一种什么样的姿态出现1.1 从一道经典题目说起CTF里的古典密码题目转轮机出现的频率不低。出题人往往会给你一个“加密函数”或者一段密文再给一小段已知明文让你还原转轮机的配置。这里说的配置通常包括三样东西用了哪几个转子、转子的排列顺序、每个转子的初始位置。有些题目还会把转子替换成自定义字母表或者改变转子的步进规则但万变不离其宗。你只要能把转轮机的加密过程用代码模拟出来再在这个模拟器的基础上去穷举未知参数题目基本就解开了。以我见过的大部分题目来看转轮机加密考查的重点并不是密码学理论深度而是你对“替换密码 状态变化”的理解是否透彻。这也是我把这篇文章的重点放在“先模拟、后穷举”上的原因。1.2 转轮机的三个核心组件转子、反射器、插线板二战时期德军的Enigma是一种机电式密码机外形有点像一台打字机。操作员按下键盘上的字母机器内部会有电流经过一系列机械结构最终点亮灯板上对应的密文字母。它的核心部件有三个转子一个圆盘两面各有26个触点内部用26条导线把触点两两连接。转子的作用本质上是一个单表替换密码。比如字母A进去可能从字母E出来。反射器位于转子组的左侧也做替换但它有一个特殊性质如果A经过反射器变成X那么X经过反射器一定变回A。这个性质让加密过程天然具有对称性。插线板位于键盘和转子组之间是一个可插拔的换接板相当于额外的一层固定替换。CTF题里最常见的情况是省去插线板或者把插线板设置固定成已知的。可以这样理解转子是“会变的替换表”插线板是“不变的替换表”反射器则是把信号“掉头”送回去的枢纽。1.3 每次按键都在改变密码表这就是状态机的本质Enigma最了不起的一点是它每次按下一个字母转子就会向前转动一格。这意味着加密同一个字母两次得到的结果大概率是不同的。你面对的不是一个静态替换表而是一个随着输入不断变化的状态机。举个例子如果只有一个转子而且它的替换表长这样输入字母: A B C D E F G H I J K L M N O P Q R S T U V W X Y Z 映射输出: E K M F L G D Q V Z N T O W Y H X U S P A I B R C J转子位置为0时A加密成E当转子转动一格后由于触点位置整体偏移A可能变成J。不同位置下同一个输入字母对应完全不同输出。三个转子串联再加上反射器信号会从右向左穿过三个转子到达反射器后再反向穿回最后输出。这个过程相当于执行了一次非常复杂的替换而替换表又随着每个按键动态变化。这就是转轮机很难用手工分析的原因也是它当年能成为德军顶级军事机密的原因。但恰恰是因为转子会动它的“密钥空间”并没有想象中那么大。CTF里最常见的标准三转子Enigma在不考虑插线板的情况下总共只有6种转子排列乘以26的三次方种初始位置加起来十万种出头。这个规模在现代计算机面前就像拿密码本去对字典暴力穷举完全可行。2. 用Python复现一台可用的转轮机模拟器2.1 Rotor类搞清楚正向和反向信号流要破解转轮机第一步不是去搜别人的现成库而是自己写一个模拟器。自己写的好处是你能完全掌控步进规则和映射方向题目魔改时你知道改哪里。Rotor类的实现要点是每个转子需要保存三样东西——字母替换表、缺口位置、当前旋转位置。import string ALPHABET string.ascii_uppercase class Rotor: def __init__(self, wiring, notch, position0): self.wiring wiring # 长度为26的字母替换表 self.notch ALPHABET.index(notch) # 缺口位置对应的索引 self.position position % 26 # 当前旋转位置 self.inverse_wiring [0] * 26 for i, ch in enumerate(wiring): self.inverse_wiring[ord(ch) - ord(A)] i def forward(self, c): 信号从右向左经过转子 idx (ord(c) - ord(A) self.position) % 26 idx ord(self.wiring[idx]) - ord(A) return chr((idx - self.position) % 26 ord(A)) def backward(self, c): 信号从左向右经过转子 idx (ord(c) - ord(A) self.position) % 26 idx self.inverse_wiring[idx] return chr((idx - self.position) % 26 ord(A)) def step(self): self.position (self.position 1) % 26 return self.position self.notch这段代码里forward和backward是最容易搞混的地方。信号正向穿过转子时要先加上当前位置的偏移量查替换表再减掉偏移量。反向穿过时要把替换表反过来用。这里我预计算了inverse_wiring这样反向查表就变成了简单的数组索引查找速度更快。你可以把转子想象成一个会转动的密码盘。盘子上刻着替换表外部触点是固定的。盘子在转动一格之后同一个外部触点对应的内部触点就换了所以输入字母的索引要先做偏移。这个“先加偏移、再查表、再减偏移”的逻辑是理解整个模拟器的关键。2.2 Reflector类与Enigma类的组装反射器比转子简单得多它不需要旋转只需要保存一个固定的映射表。标准Enigma的反射器B的映射表是这样的REFLECTOR_B YRUHQSLDPXNGOKMIEBFZCWVJAT注意这个映射表满足“自反”性质如果位置0的字母是Y那么位置24字母Y上的字母一定是A。这是反射器能用来加密解密的根基。Reflector类的代码很短class Reflector: def __init__(self, wiring): self.wiring wiring def reflect(self, c): return self.wiring[ord(c) - ord(A)]接下来把三个转子和反射器组装成Enigma机器。这里我用一个EnigmaM3类来表示名字里的M3指的就是德军广泛使用的Enigma I型也是CTF题里最常出现的型号。ROTOR_CONF { I: (EKMFLGDQVZNTOWYHXUSPAIBRCJ, Q), II: (AJDKSIRUXBLHWTMCQGZNPYFVOE, E), III: (BDFHJLCPRTXVZNYEIWGAKMUSQO, V), IV: (ESOVPZJAYQUIRHXLNFTGKDCMWB, J), V: (VZBRGITYUPSDNHLXAWMJQOFECK, Z), } class EnigmaM3: def __init__(self, rotors, positions, reflectorREFLECTOR_B): self.rotors [] for name, pos in zip(rotors, positions): wiring, notch ROTOR_CONF[name] self.rotors.append(Rotor(wiring, notch, pos)) self.reflector Reflector(reflector) def encipher(self, text): out [] for ch in text.upper(): if ch not in ALPHABET: out.append(ch) continue self.step_rotors() c ch # 右 - 左 for r in reversed(self.rotors): c r.forward(c) c self.reflector.reflect(c) # 左 - 右 for r in self.rotors: c r.backward(c) out.append(c) return .join(out)2.3 步进逻辑标准M3的双步进机制如果你只是把三个转子当成独立的替换表那还远远不够。转轮机之所以复杂是因为三个转子之间有一个类似汽车里程表的进位关系。最右边的转子每次按键都会转一格中间的转子并不是每26次才转一格而是有一套“双步进”的特殊机制如果中间转子当前处于缺口位置那么这一步里中间转子和左边转子一起转。否则如果右边转子当前处于缺口位置那么这一步里中间转子单独转。最后右边转子始终转一格。对应到代码def step_rotors(self): mid_notch (self.rotors[1].position self.rotors[1].notch) right_notch (self.rotors[2].position self.rotors[2].notch) self.rotors[2].step() if mid_notch: self.rotors[1].step() self.rotors[0].step() elif right_notch: self.rotors[1].step()这一步是很多CTF新手最容易栽跟头的地方。因为很多简化版的转轮机题目用的是更直观的“里程表”式步进右转子转满一圈中间转子才转一格中间转子转满一圈左边转子才转一格。这两种逻辑在某些时刻会得出完全不同的密文所以做题前一定要先确认题目采用哪种步进规则。2.4 用加密自反性检验模拟器是否正确写模拟器最容易出现的错误是转子方向接反或者步进逻辑写错。好在转轮机有一个特别好的性质可以用来验证同一个配置下加密两次会得到原文。因为反射器的存在加密过程和解密过程完全一样。如果你用EnigmaM3([I,II,III], [0,0,0])加密一段明文得到密文再新建一个相同配置的机器加密这段密文应该能还原出明文。我强烈建议你在写完模拟器后先做这个自反性验证m1 EnigmaM3([I, II, III], [0, 0, 0]) cipher m1.encipher(HELLOWORLD) m2 EnigmaM3([I, II, III], [0, 0, 0]) plain m2.encipher(cipher) assert plain HELLOWORLD如果这个断言失败了说明你的forward、backward或者反射器映射哪里出了问题后面所有破解逻辑都会建立在一个错误的地基上。3. 破解原理已知明文下的全空间穷举3.1 密钥空间到底有多大现在我们已经有了一个可靠的模拟器接下来要回答一个关键问题到底需要搜多少种配置假设题目给的是标准三转子Enigma转子的总池子里有5个备选转子但机器上只能插3个。出题人一般会明确告诉你用了哪3个转子或者让所有已知条件都指向某一组转子。在只知道转子池、不知道具体用了哪三个的情况下最坏情况是从5个里选3个再排列也就是P(5,3) 60种排列。不过CTF里最常见的还是“3个选定转子做全排列”也就是3! 6种顺序。初始位置就更好算了每个转子26个位置三个转子就是26^3 17576种组合。所以总搜索量为6 × 17576 105456 种组合十万出头的组合数对现代计算机来说完全不是问题。如果你用的是只有3个转子的题而且题目明确告诉你“三个转子分别是I、II、III”那搜索量就真的是固定在十万次左右。这个搜索规模对Python来说属于“泡杯水就出结果”的量级。即使不做任何优化也可以在几十秒内跑完。加一个提前终止的判断后通常一秒内就能跑完。3.2 暴力搜索函数的设计暴力搜索的思路非常直接枚举所有转子顺序和所有初始位置把已知明文加密一次看结果是否等于已知密文。import itertools def crack_enigma(plaintext, ciphertext): plaintext plaintext.upper() ciphertext ciphertext.upper() rotor_pool [I, II, III] solutions [] for perm in itertools.permutations(rotor_pool, 3): for positions in itertools.product(range(26), repeat3): machine EnigmaM3(list(perm), positions) if machine.encipher(plaintext) ciphertext: solutions.append((perm, positions)) print(f命中: 转子顺序{perm}, 初始位置{positions}) return solutions这个函数里有三个嵌套层的枚举第一层是转子的6种排列第二层是三个初始位置的17576种组合。对每种组合创建一台机器加密明文比对密文。这里有一个细节要提醒你每轮必须新建一个EnigmaM3实例而不能复用同一个机器对象。因为encipher方法会改变转子的位置复用同一个对象会导致状态被上一次尝试污染。我见过不少朋友图省事在循环外面创建了一个机器然后不断重置position结果重置逻辑写错导致怎么跑都找不到解。3.3 提前终止优化从几十秒到一秒上面那个朴素版本在明文较长的情况下最坏要跑几十秒。原因很简单每个候选配置都要把整个明文加密完才能判断是否匹配。实际上一旦第一个字符的加密结果不匹配这个候选配置就可以直接丢掉了。优化的思路是把“加密后再比较”改成“边加密边比较”发现不匹配立刻跳出def try_decrypt(machine, plaintext, ciphertext): for i, ch in enumerate(plaintext): if ch not in ALPHABET: continue if machine.encipher(ch) ! ciphertext[i]: return False return True然后在主循环里用这个函数替换原来的整体比较。由于转轮机的“雪崩效应”非常明显错误的配置大概率在第一个字符就会暴露所以绝大多数候选配置一次字符比较就被淘汰了。实测下来原本要几十秒的搜索优化后基本在一秒上下。要注意的是machine.encipher(ch)会修改转子状态所以同一个machine实例在逐字符调用时状态是连续演进的这和我们加密完整的明文是等价的。提前退出并不会破坏这种连续性因为一旦发现不匹配这个机器就不再被使用了。3.4 出现多个解时的筛选策略如果你手里的已知明文比较短比如只有三四个字母那非常可能出现多个配置同时匹配的情况。这是因为十万个配置把几个字母的密文空间都覆盖了撞车几率不小。遇到多解的时候我有几个常用的处理办法找更长的已知明文块。能凑出十个字符以上基本能保证唯一解。用第二段明密文对再筛一遍。第一段筛出来的候选配置再拿第二段验证。检查解出来的是否是正常英文或是符合flag格式的字符串。在多解的情况下把所有候选解都打印出来再用常识判断。不要急着改脚本先看明文字符串够不够长。4. 完整破解脚本与实战演示4.1 可直接复制的完整代码下面给出一个可以直接运行的完整脚本。它把前面提到的Rotor、Reflector、EnigmaM3和破解函数全部整合在一起并在最后做了一个自验证演示先用一个随机配置加密一段明文再通过穷举把这个配置找回来。import itertools import string ALPHABET string.ascii_uppercase ROTOR_CONF { I: (EKMFLGDQVZNTOWYHXUSPAIBRCJ, Q), II: (AJDKSIRUXBLHWTMCQGZNPYFVOE, E), III: (BDFHJLCPRTXVZNYEIWGAKMUSQO, V), IV: (ESOVPZJAYQUIRHXLNFTGKDCMWB, J), V: (VZBRGITYUPSDNHLXAWMJQOFECK, Z), } REFLECTOR_B YRUHQSLDPXNGOKMIEBFZCWVJAT class Rotor: def __init__(self, wiring, notch, position0): self.wiring wiring self.notch ALPHABET.index(notch) self.position position % 26 self.inverse_wiring [0] * 26 for i, ch in enumerate(wiring): self.inverse_wiring[ord(ch) - ord(A)] i def forward(self, c): idx (ord(c) - ord(A) self.position) % 26 idx ord(self.wiring[idx]) - ord(A) return chr((idx - self.position) % 26 ord(A)) def backward(self, c): idx (ord(c) - ord(A) self.position) % 26 idx self.inverse_wiring[idx] return chr((idx - self.position) % 26 ord(A)) def step(self): self.position (self.position 1) % 26 return self.position self.notch class Reflector: def __init__(self, wiring): self.wiring wiring def reflect(self, c): return self.wiring[ord(c) - ord(A)] class EnigmaM3: def __init__(self, rotors, positions, reflectorREFLECTOR_B): self.rotors [] for name, pos in zip(rotors, positions): wiring, notch ROTOR_CONF[name] self.rotors.append(Rotor(wiring, notch, pos)) self.reflector Reflector(reflector) def step_rotors(self): mid_notch (self.rotors[1].position self.rotors[1].notch) right_notch (self.rotors[2].position self.rotors[2].notch) self.rotors[2].step() if mid_notch: self.rotors[1].step() self.rotors[0].step() elif right_notch: self.rotors[1].step() def encipher(self, text): out [] for ch in text.upper(): if ch not in ALPHABET: out.append(ch) continue self.step_rotors() c ch for r in reversed(self.rotors): c r.forward(c) c self.reflector.reflect(c) for r in self.rotors: c r.backward(c) out.append(c) return .join(out) def check_candidate(machine, plaintext, ciphertext): for i, ch in enumerate(plaintext): if ch not in ALPHABET: continue if machine.encipher(ch) ! ciphertext[i]: return False return True def crack_enigma(plaintext, ciphertext): plaintext plaintext.upper() ciphertext ciphertext.upper() rotor_pool [I, II, III] solutions [] for perm in itertools.permutations(rotor_pool, 3): for positions in itertools.product(range(26), repeat3): machine EnigmaM3(list(perm), positions) if check_candidate(machine, plaintext, ciphertext): solutions.append((perm, positions)) print(f命中: 转子顺序{perm}, 初始位置{positions}) return solutions if __name__ __main__: plain ATTACKATDAWN target_rotors (III, I, II) target_pos (5, 17, 9) m EnigmaM3(target_rotors, target_pos) cipher m.encipher(plain) print(f靶子配置: rotors{target_rotors}, positions{target_pos}) print(f明文: {plain}) print(f密文: {cipher}) print(\n开始穷举破解...) result crack_enigma(plain, cipher) print(f\n破解完成共找到 {len(result)} 组解) for order, pos in result: print(f 转子顺序: {order} 初始位置: {pos})这段代码我尽量保持了结构清晰没有做过度压缩。你把它保存成enigma_crack.py直接python enigma_crack.py就能看到完整的加解密和破解流程。4.2 跑一个靶子实验脚本最后一段我用(III, I, II)和(5, 17, 9)作为靶子配置把ATTACKATDAWN加密成一段密文然后调crack_enigma去搜索。理论上脚本会打印类似这样的结果靶子配置: rotors(III, I, II), positions(5, 17, 9) 明文: ATTACKATDAWN 密文: ... 开始穷举破解... 命中: 转子顺序(III, I, II), 初始位置(5, 17, 9) 破解完成共找到 1 组解 转子顺序: (III, I, II) 初始位置: (5, 17, 9)你运行出来的密文会是我预留的那一串不要紧重点是你看到破解函数准确找回了靶子配置。整个过程耗时通常不到一秒如果你机器性能一般也最多两三秒。这个自验证的写法我在实际排查题目时经常用。拿到一道题先别急着跑真实的密文而是用一个已知配置生成一组“靶子数据”验证自己的破解器逻辑没问题再上真实数据。这能帮你把“破解脚本本身的bug”和“题目条件理解错了”这两个问题隔离开。4.3 题目变形的适配指南CTF出题人不太可能完全按历史原版Enigma出题多少都会做点魔改。常见的情况有这么几种转子数量不是3个或者转子顺序固定只需要爆破初始位置。这时把itertools.permutations(rotor_pool, 3)换成[tuple(rotor_pool[:3])]就行。转子的替换表是自定义的。把ROTOR_CONF里对应转子的字符串换成题目给的映射即可。步进方式是里程表式。把step_rotors改成“右转子转满一圈中转子才转一格”的写法。没有反射器或者反射器参与轮转。这一类题目就脱离了Enigma的标准结构但你手里已经有了一套模拟器改起来也有参照。我的建议是先保证你的模拟器和题目给出的示例加密结果完全一致再开始爆破。很多朋友一上来就爆破爆破不出来还以为是搜索逻辑写错了其实是对题目的加密规则理解错了。5. 实测中踩过的坑能帮你省几个小时5.1 大小写与特殊字符的坑转轮机只处理拉丁字母不处理空格、数字和标点。实战里出题人经常在密文里塞入一些分隔符或者把flag格式的花括号直接保留在密文中。遇到这种情况不要天真地以为Enigma连花括号都能加密。我的做法是在encipher里遇到非字母字符就原样保留并且不触发转子步进。这样在用check_candidate比对时明文和密文里的非字母字符位置一一对应不影响比较结果。但要注意一点如果明文里的空格用字母X替代而密文里是直接连续的字母串那你就需要先把明文中的空格替换成X再送入破解器。反过来也一样先把题目给的条件归一化成统一格式。5.2 步进逻辑不一致结果全军覆没这是最隐蔽的坑。标准Enigma M3的双步进机制和很多人直觉里的“里程表式”步进差别并不是一开始就显现而是要等某些转子转到缺口附近才会体现。如果你的破解器总是找不到解先停下来问自己一个问题这道题里的转子进位规则到底是经典双步进还是简化版的等距进位出题人对转轮机原理的掌握程度直接决定了题目采用的规则。最简单的验证方式是用题目给出的示例输入输出反向测试你的模拟器。我在实际解题中会同时保留两套step_rotors用同一个check_candidate分别测看哪一套能匹配上。这个技巧能帮你快速排除步进逻辑上的不确定性。5.3 明文太短导致多解如果你手里只有四五格的已知明文破解结果出现几十个候选解是很正常的。这不是脚本bug而是信息量不足带来的本质多解。这时候不要慌优先找题目里还有没有其他约束。比如flag格式本身就是一个很好的过滤器如果明文或密文里包含flag字样你可以把已知的flag前缀或后缀加长一些再爆破。如果题目里给了两个明密文对就用两个明文分别生成候选集取交集。交集之后基本就剩唯一解了。5.4 别忽略转子顺序的左右方向最后一个坑和方向有关。ROTOR_CONF里存的是“从右向左看”的替换表还是在某些参考资料里是“从左向右看”的替换表不同资料可能不一样。如果你跑的题库里给出的密文和正确答案始终对不上试试把转子排列反过来或者把某两个转子的映射表反转。很多实现里Rotor.forward是信号从右往左走时调用的backward是信号从左往右走时调用的。如果你发现某个题目给出的加密样例在这个方向下对不上可以写一个反向的映射表再测一次。我个人的排查顺序是先验证自反性再验证示例加密最后才上爆破。三步都通过之后解题基本就是水到渠成的事。这篇文章里给的代码我已经在多个转轮机题上实测过覆盖了标准Enigma、简化步进版、自定义转子映射版。你自己做题时如果遇到奇怪的变形核心思路不会变先把加密过程精确复现再把未知参数穷举一遍。转轮机这类题考的就是你有没有耐心把原理吃透以及写代码时够不够细致。