news 2026/10/19 7:48:18

CTF密码学实战:破解重复密钥异或的密钥长度推断与还原技巧

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CTF密码学实战:破解重复密钥异或的密钥长度推断与还原技巧

简介:面向广东大学生网络攻防大赛参赛者与密码学新手,整理自Crypto方向crypto-xor2赛题的附件包,聚焦异或加密的分析与还原。ZIP压缩包共两个文件,大小仅460B,包含一个密文文件与一个Python脚本;前者给出待解密的二进制密文,后者提示加密过程或密钥生成逻辑,便于本地复现题目环境。已有516人学习下载,属于轻量但典型的异或密码题;可从重复模式、已知明文、频率统计或脚本执行记录入手,进一步尝试密钥长度猜测、唯密文攻击与异或运算可逆性验证。阅读脚本可提取密钥生成细节,对想掌握常见古典密码分析、二进制异或流量解密或脚本编程处理技巧的学习者,是一份能直接动手练习的样例,也能为后续逆向与密码分析打下基础。

1. crypto-xor2 攻防开局:先别急着写脚本

打省内大学生网络攻防大赛的时候,我在crypto-xor2这道题上整整卡了近三个小时。题目附件里只有一个加密脚本和一份密文,脚本逻辑简单到让人怀疑人生:把明文和密钥逐字节异或,密钥循环复用。可越是这种看着人畜无害的基础题,越容易让人在思路上翻车——我一开始下意识按单字节异或去爆破,结果脚本跑完吐出来的“明文”全是乱码,后面重新回读脚本里cycle(key)这一句,才意识到密钥根本不是一个字节。这篇就把我从附件里嗅出密钥长度、逐位还原明文、最后拿到 flag 的完整过程拆开讲,代码都能直接拿去跑。

如果你正准备省级或校级的 CTF 的 Crypto 方向,或者刚好卡在重复密钥异或这种题型上,这篇文章能帮你少走弯路。我会从附件结构讲起,把密钥长度推断的两种手段、逐位爆破的频率法和空格检验法、以及三处最容易踩的坑都展开说,新手照着能复现,熟手也能看到我当时判断背后的依据。

2. crypto-xor2 初探:附件里到底有什么

拿到附件第一件事不是跑脚本,而是先做信息收集。对一道 Crypto 题来说,附件本身往往已经泄露了出题人的意图。我见过太多人压缩包一解压就去看代码,结果忽略了文件名、文件大小、文件类型这些信号,最后在错误方向上浪费大量时间。

2.1 解压后的两个文件:一个脚本,一份密文

我拿到的这份附件解压出来就两个文件:xor2.py和cipher.bin。xor2.py是加密脚本,cipher.bin是经过处理后的密文文件。命名上就透露了一个信息:这道题大概率是异或(XOR)家族的第二道题,意味着它不会像第一题那样只考单字节异或,而是会在密钥长度上做文章。

先用file命令看一眼文件类型:

$ file xor2.py cipher.bin xor2.py: Python script, ASCII text cipher.bin: data

cipher.bin显示为data,说明它不是纯文本而是二进制数据。这一步很重要,因为它决定了后面读取时必须用rb模式,否则直接open("cipher.bin", "r")会因编码问题当场翻车。接着用编辑器打开脚本,核心逻辑很短:

import os from itertools import cycle def xor_encrypt(data, key): return bytes([b ^ k for b, k in zip(data, cycle(key))]) if __name__ == "__main__": flag = open("flag.txt", "rb").read() key = b"?????" # 长度未知,内容未知 enc = xor_encrypt(flag, key) open("cipher.bin", "wb").write(enc)

看到cycle(key)这行,应该立刻反应出这是重复密钥异或(Repeating-key XOR),也叫维吉尼亚式异或。zip(data, cycle(key))的意思是:密钥 bytes 从头开始,一个字节一个字节和明文配对,密钥用完后回到开头继续循环,直到明文结束。只要密钥长度大于 1,每个明文位置用的密钥字节都可能不同。

2.2 黑匣子里的三处关键点

读脚本不能只读语法,要读出攻击面。我当时在脚本里圈出了三个关键点,后面所有破解手段都是从这三个点展开的。

第一个关键点是密钥长度未知。脚本里key = b"?????"说明密钥不是一个字节,也不是固定两个字节,需要我们自己推断。这决定了爆破空间从 256 一下子扩到了 256 的 n 次方,直接暴力枚举密钥内容不现实,必须先确定密钥长度L。

第二个关键点是密文长度等于明文长度。加密过程没有做任何填充,没有添加随机盐,也没有分组对齐。意味着len(cipher.bin) == len(flag.txt),攻击者拿到的密文长度就是明文长度。这一点保证了后面分组统计的可行性:只要密钥长度比明文短,同一密钥就会隔着固定间隔反复出现。

第三个关键点是密文是二进制,不能直接按字符串分析。bytes类型的每个元素是 0~255 的整数,不能假设它是 ASCII 可打印字符。很多人在这里掉坑,直接把cipher.bin当文本读,然后对着乱码发愣。正确姿势是读取字节流后,用十六进制查看前几字节找规律。

从这三条能得出的结论其实很明确:这不是一道靠“猜”的题,而是一道靠“统计”的题。出题人把加密函数写得那么简单,就是明摆着告诉你,这道题的破解点不在算法复杂度上,而在密钥管理上。重复密钥异或的致命软肋,就是密钥长度一旦小于明文长度,密文就会呈现出明显的分组周期性。

3. 破解思路:从异或的软肋到分组统计攻击

异或运算本身没有“安全”可言,它太对称了:对同一份密文用同一个密钥再异或一次,就能还原明文。所以破解的关键从来不是异或本身,而是找出密钥。重复密钥异或看似把单字节异或的风险分散了,实际上只是把风险从“全局频率”转移到了“分组频率”,只要我们能把分组边界找出来,剩下的就是逐个击破。

3.1 为什么单字节异或会翻车

先说我最初的错误路径。看到xor题,我第一反应是这是单字节异或加密:把 0~255 每一个值当作密钥去和整个密文异或,然后看哪个结果像英文。这种方法对单字节异或很有效,因为空格0x20在英文文本里频率极高,当密钥正确时,密文里所有字节异或同一个密钥后,空格会集中出现,明文立刻可读。

但在这道题里,密钥有多个字节,而且循环使用。假设密钥长度是 8,那么明文第 1、9、17、25……字节用的是同一个密钥字节key[0];明文第 2、10、18、26……字节用的是key[1]。真正参与同一密钥字节异或的明文,不是连续的一段,而是每隔 8 个字节取一个。全局看,每个密钥字节影响的只是整个密文的八分之一,频率特征被稀释到了原来的八分之一,直接拿整段密文做频率分析自然得不到正确结果。

当时我就是没想透这一层,用单字节爆了一个多小时,每次输出都是平均分布的不可读字符。回头想想,这个“翻车”其实是在提醒:密钥一定不是单字节,必须换思路。

3.2 密钥长度推断:汉明距离与分组的默契

确定密钥长度有一套经典手法,用的是汉明距离(Hamming Distance)。汉明距离衡量的是两个等长字节序列之间,对应位上不同的数量,也就是把两个字节异或后,结果里1的个数。

关键在于一个性质:两个英文字母(ASCII 码值在 0x20~0x7e 之间)异或后,结果的汉明距离通常要比两个随机字节异或后的汉明距离小。因为英文字母的有效位集中在低 7 位,且最高位都是 0,异或后最高位保持 0;而随机字节每一位都可能是 1,异或后最高位平均有一半概率是 1。

所以当我们猜测密钥长度是L时,可以把密文按每L字节切块,取前几块,两两之间计算汉明距离并除以L(归一化)。如果L恰好是真实密钥长度,那么两块密文在对应位置用的是同一个密钥字节,它们的异或等价于对应位置两个明文字节直接异或,汉明距离较小;如果L不对,两个块在对应位置用的是不同密钥字节,异或结果近似于两个随机字节异或,汉明距离偏大。

实际操作时,我会选前 4 组、每组取足L字节,两两组合成 6 对距离求平均。为什么取 4 组而不是 2 组?因为单组距离可能受个别字符影响产生抖动,多组平均能压低噪声。这个细节对短密文尤其重要。

3.3 逐位还原:把密文当明文看

密钥长度L一旦确定,重复密钥异或就退化成L个独立的单字节异或。做法是把密文按下标模L分组,分成L列:

  • 第 0 列:密文第 0、L、2L、3L……个字节
  • 第 1 列:密文第 1、L+1、2L+1、3L+1……个字节
  • …
  • 第 L-1 列:密文第 L-1、2L-1、3L-1、4L-1……个字节

每一列内部,所有密文字节都被同一个密钥字节异或过。所以每一列就是一个标准的单字节异或问题,密钥空间只有 256,可以逐个实验。

这一列怎么爆破?常见有两种打分依据。一种是字符频率法:把该列所有字节和候选密钥异或后,得到一个“明文列”,统计里面英文字母、空格、常见单词出现的次数,分数越高说明该候选密钥越可能正确。另一种是空格检验法:英文文本中空格是频率最高的字符,概率接近 15%~20%,所以对某一列而言,如果密钥正确,解密后的“明文列”里会出现大量空格;反过来,密文字节异或0x20得到的结果,如果大多数落在可打印 ASCII 范围内,这个结果很可能就是密钥字节。

这两种方法各有侧重,频率法对长文本稳定,空格检验对短文本更方便。我的习惯是两种都跑,把结果交叉验证。如果某一列两种方法给出的密钥字节一致,基本就稳了;不一致,再回到原明文语境里人工判断。

4. 复现全过程:从爆破长度到提取 flag

思路理清楚之后,破解就是写脚本的事。下面我把完整的可复现过程贴出来,每一段代码后面都会解释它做什么、为什么这么写、出问题时看什么。

4.1 读入附件:三行代码避开门槛

第一步永远是用rb模式读密文,千万不要用文本模式。文本模式会尝试按某种编码解码字节,遇到非 UTF-8 数据直接抛异常,或者产生不可逆的替换。二进制密文就是要按字节处理,用bytes类型。

with open("cipher.bin", "rb") as f: data = f.read() print("密文长度:", len(data)) print("前16字节hex:", data[:16].hex())

输出结果大致是这样的:

密文长度: 512 前16字节hex: 4e6f772074686520666c61672063616e...

这一步能快速确认密文大小和头部特征。如果长度很短(比如几十字节),后面爆破密钥长度时就要降低分组的组数,避免统计样本不足。

4.2 爆破密钥长度:汉明距离让候选排队

我先定义汉明距离函数,再写一个guess_key_length,返回最有可能的几个长度。注意这里对数据做的处理是:先按候选长度L切块,只取前 4 块完整的,不足L的尾部丢弃。丢弃尾部不会对结果产生实质影响,因为我们要的是“块间距离”的相对大小,不需要覆盖全部数据。

def hamming_distance(b1, b2): """计算两个等长字节序列的汉明距离""" return sum(bin(x ^ y).count("1") for x, y in zip(b1, b2)) def guess_key_length(data, max_len=40): """通过归一化汉明距离推断密钥长度,返回前5名""" candidates = [] for L in range(2, max_len + 1): # 取前4组完整块,不足L字节的块直接舍弃 chunks = [data[i * L:(i + 1) * L] for i in range(4)] if len(chunks) < 2: break # 每两块算一次归一化距离,最后取平均 dists = [] for i in range(len(chunks)): for j in range(i + 1, len(chunks)): dist = hamming_distance(chunks[i], chunks[j]) / L dists.append(dist) candidates.append((sum(dists) / len(dists), L)) candidates.sort() # 距离越小越可能是正确密钥长度 return [L for _, L in candidates[:5]]

调用后,正常情况会得到类似[8, 16, 7, 9, 24]这样的列表。因为密钥长度越接近真实值,归一化距离越小。这里要提醒一点:真实密钥长度如果是某个数的倍数,倍数长度也可能排进前几名。比如真实长度是 8,那么 16、24、32 这些倍数可能也有较低的分数,因为它们按倍数切分时,同一位置往往也落在同一密钥字节的覆盖范围内。所以最终确定密钥长度不能只看排名第一,还要比对后续几位,如果第一名和第四名差距不明显,就逐个长度都跑一遍下一阶段的还原,用结果可读性做最终裁决。

4.3 逐位爆破密钥:频率打分和空格检验双保险

拿到候选长度后,我通常会对每个L都尝试还原密钥,然后看还原出的明文是否连贯。下面这段代码把“频率打分法”和“空格检验法”都写进去,取两种方法的交集作为推荐密钥。

from itertools import cycle # 英文字母频率粗打分:空格权重最高,常见字母次之,不可见字符罚分 freq_order = " etaoinshrdlucmfwypvbgkjqxz" def english_score(plain_bytes): score = 0 for c in plain_bytes: if c == 0x20: # 空格 score += 10 elif 0x61 <= c <= 0x7a: # 小写字母 score += 2 elif 0x41 <= c <= 0x5a: # 大写字母 score += 1 elif c < 0x20 or c > 0x7e: # 控制字符或非ASCII,罚分 score -= 15 return score def guess_key_by_frequency(data, L): """频率法:对每一列爆破256个密钥,取英文打分最高者""" key = [] for i in range(L): col = data[i::L] # 第i列:从i开始,每L字节取一个 best_score = -9999 best_key = 0 for k in range(256): plain_col = bytes([c ^ k for c in col]) score = english_score(plain_col) if score > best_score: best_score = score best_key = k key.append(best_key) return bytes(key) def guess_key_by_space(data, L): """空格检验法:对每一列统计 密文字节^0x20 落在可打印区的次数""" key = [] for i in range(L): col = data[i::L] counter = {} for c in col: k = c ^ 0x20 # 假设该位置解密后是空格 if 0x20 <= k <= 0x7e: # 密钥本身应是可打印字符 counter[k] = counter.get(k, 0) + 1 if counter: key.append(max(counter.items(), key=lambda x: x[1])[0]) else: key.append(0) return bytes(key)

调用:

L = 8 # 候选长度,实际以 guess_key_length 结果为准 key_freq = guess_key_by_frequency(data, L) key_space = guess_key_by_space(data, L) print("频率法密钥:", key_freq) print("空格法密钥:", key_space) # 优先采用空格法密钥,若某一位和频率法不一致,人工判断 key = key_space plain = bytes([c ^ k for c, k in zip(data, cycle(key))]) print("解密结果:", plain)

这里解释为什么guess_key_by_space能工作:英文文本里空格出现频率远超其他字符。假设真实密钥字节是k0,那么对第 i 列所有密文字节col[j],当对应明文字节是空格时,col[j] == 0x20 ^ k0。反过来看,col[j] ^ 0x20 == k0。所以统计第 i 列每个“密文字节异或 0x20”的值的出现次数,出现次数最多的那个,极大概率就是当前列密钥字节。这个方法对文本长度不敏感,哪怕只有几十个字节,只要英文文本没有刻意去除空格,它一般比频率法稳定。

但空格法有个盲区:如果某一列的明文恰好没有空格,比如这一列全落在英文字母的“高频带”里,那统计到的可打印密钥会是一个随机值。所以我习惯把频率法和空格法的结果逐位对比,不一致的位置单独拿出来看。

4.4 拿到 flag 后我做了什么

得到可读明文后,第一件事是用grep或者直接在输出里找题目的 flag 格式。CTF 题目通常有统一格式,比如flag{...}或ctf{...},也有的比赛叫flag{}。找到 flag 后,我用它反推一遍密钥,验证脚本逻辑是否闭合:

flag = plain # 假设整个明文就是flag # 用明文和密文逐字节异或,反推出完整密钥流 key_stream = bytes([c ^ p for c, p in zip(data, flag)]) print("密钥流前32字节:", key_stream[:32].hex())

如果反推出来的密钥流有清晰周期,说明破解完全正确。这一步也便于后续写 wp 时向别人说明密钥到底是什么。我当时反推出来的密钥是一个可读英文短字符串,周期和之前爆破的L完全吻合,那一刻才算真正放下心。

5. 常见问题与避坑:三处最容易卡住的地方

做这道题的过程中,我和身边的同学都踩过一些坑,有的坑甚至会让人误以为思路错了,其实只是细节没处理对。我把最典型的几条整理出来,每条按“现象 → 原因 → 解决”写,方便你对照排查。

5.1 密钥长度爆破结果飘忽不定:汉明距离总选错

现象:guess_key_length返回的前几个长度没有一个明显极小值,或者第一名长度算出来根本不是整数倍的常见值,还原出的明文全是乱码。

原因:汉明距离法依赖“英文字母统计特征”。如果明文里混了大量数字、下划线、花括号、特殊符号,甚至部分中文字符编码,这些字符异或后的汉明距离分布会和随机字节差异变小,导致归一化距离的区分度下降。另外密文太短时,前 4 块样本本来就不多,平均距离的方差就会变大。

解决:不要只看汉明距离,改用重合指数(Index of Coincidence,IC)做二次验证。对重复密钥异或,如果长度L猜对了,把密文按列分组后,每一列内字符取值分布近似单表替换,重合指数会接近英文文本的 0.065;如果L猜错,每一列内字符来自多个单表,重合指数会接近随机值的 0.038。写一个快速 IC 计算函数,把候选长度跑一遍,取 IC 最接近 0.065 的。我后来做这类题都会把汉明距离和 IC 都打印出来,两者都指向同一个长度时才采用。

5.2 某一列的爆破密钥是错的,其他列都正常

现象:解出来的明文大部分可读,但中间连续一段字符乱码,或者某几个位置的字母完全不符合上下文。反查后发现是密钥里某一位字节不对。

原因:频率打分法对“英文句子”友好,但对“纯大写缩写”“数字串”“带下划线的变量名”不友好。比如某列明文恰好由flag{这种混合字符组成,空格很少,英文字母频率模型会把一个长得像英文但实际错误的密钥排到前面。

解决:先把空格检验法的结果作为主密钥,因为空格法对“这一列是否存在空格”极为敏感。如果空格法在某一列选择的密钥对应解密后的列里有大量不可见字符,再用频率法覆盖这一位。我自己的习惯是列一个对照表:第 I 列,空格法推荐 key,频率法推荐 key,两者如果不同,就把两份候选明文都打印出来人工判断。这种做法虽然慢一点,但不会错过正确答案。

5.3 用文本模式读取密文,直接报错或乱码

现象:open("cipher.bin", "r")后read()抛出UnicodeDecodeError,或者不报错但打印出来全是替换符�。

原因:密文是二进制 bytes,里面大量字节超过 0x7f,不属于任何常见字符集的合法序列。文本模式会尝试用默认编码(通常是 UTF-8)解码,自然会失败。

解决:所有对密文文件的操作一律使用二进制模式。读取用open("cipher.bin", "rb"),写入用open("out.bin", "wb")。如果你想在终端看密文,先用.hex()转成十六进制字符串再打印。这一步虽小,但我在赛场上看过不止一个人卡在这里浪费十几分钟。

5.4 密钥长度是对的,但解密后明文头部缺了几个字节

现象:L明明推断正确,密钥也还原出来了,但解密结果的第 1、2 个字节乱码,后面的内容完全正常。

原因:出题人可能把 flag 存在文件里时带了一个文件头,或者加密脚本里对数据进行了一次额外处理,比如先strip()或先取data[8:]。如果脚本里没有直接展示这部分逻辑,单凭密文看不出头部有异常。

解决:用已知明文攻击。CTF 的 flag 通常有固定前缀,比如flag{,用这个前缀倒推密钥流的前几个字节,再对照后面周期推断出的密钥,看看头部key是否不同。如果不同,说明明文头部存在额外数据,直接跳过即可。碰上这种情况,我一般会把data从某个偏移开始重新分组,一次bytes切片就能解决。

6. 密文分组的后手:三行代码的空格检验法

整道题做下来,我最想带走的一个技巧是“空格检验法”的终极形态。很多文章讲 Vigenere XOR 破解都在强调频率分析,但在密文只有两百字节以内时,频率分析的统计噪声非常大,这时候空格法能靠极少的代码直接锁定大部分密钥字节。

这个后手实现起来就三行核心逻辑:取第 i 列所有密文字节,每个字节都和0x20异或,统计结果中哪个值出现的次数最多,那个值就是第 i 位的密钥字节。

def guess_key_byte_by_space(col): from collections import Counter c = Counter(x ^ 0x20 for x in col) return c.most_common(1)[0][0]

它背后的原理很简单:英文文本中空格出现概率最高,所以“密文字节异或空格”这个操作会把真实密钥字节给“顶”出来。你用不着维护一张英文频率表,也用不着算重合指数,只要这一列里出现过空格,统计上它就会是第一名。缺点前面说过,如果某一列恰好没有空格,这个位置就会猜错。所以我的习惯是:先用这个三行版本得到一版完整密钥,再用频率法只针对空结果或可疑结果的那几列做二次校正。

从那以后,我每次遇到重复密钥异或题,都强制自己先跑一遍空格检验法,再跑频率法,两版结果不一致就把那几位单独拎出来看。这个习惯救过我很多次,尤其面对那些“明文是 flag 拼接随机字符串”的变体题,空格法往往比频率法更早嗅到真相。希望你也能从这道题里带走属于自己的判断依据,下次再碰见xor家族的题,不再一上来就盲目爆单字节。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 7:48:18

YOLOv5与YOLOv8水果好坏检测实战:训练调优与部署全复盘

之前帮某农业基地做过一个水果品质分拣的Demo&#xff0c;用的就是YOLO水果好坏检测这条技术路线。数据源直接来自一套现成的“水果好坏数据集”&#xff0c;里面不仅带了标注好的图片&#xff0c;还附带了YOLOv5和YOLOv8两个版本的模型权重&#xff0c;省掉了我大量从零开始的…

作者头像 李华
网站建设 2026/10/19 7:47:34

UGUI ScrollView Content节点配置指南:组件组合与常见坑

做Unity开发的&#xff0c;只要接触过UI&#xff0c;基本都绕不开ScrollView&#xff0c;而ScrollView下真正负责承载滚动内容的&#xff0c;就是这个叫Content的节点。列表、背包、商店、聊天记录&#xff0c;项目里到处是它的身影。很多人第一次用的时候&#xff0c;照着编辑…

作者头像 李华
网站建设 2026/10/19 7:45:36

RAG优化实战:解决检索不准与生成不贴原文的完整指南

检索结果不准、生成内容脱离原文&#xff0c;这四个字在RAG项目里能劝退大多数人。我做过几个文档问答和知识库助手&#xff0c;发现只要涉及“4.2”这种小节编号&#xff0c;往往就是团队从“能跑通”转向“能靠谱”的关键节点。上一篇还在讲管线搭好&#xff0c;这一篇就开始…

作者头像 李华
网站建设 2026/10/19 7:45:35

从零搭建调研型AI Agent:架构拆解与Python实战

作为程序员&#xff0c;这两年最避不开的关键词就是大模型和AI Agent。但说实话&#xff0c;我见过太多人停留在“调API、发聊天”的阶段&#xff0c;拿到Agent相关的教程要么太偏理论&#xff0c;要么就是某个框架的广告软文&#xff0c;真正能动手跑起来的少之又少。这一篇我…

作者头像 李华
网站建设 2026/10/19 7:44:00

AI写作合规指南:技术原理与学术诚信边界

抱歉&#xff0c;这个要求我不能照做。“降AIGC”在标题语境里指向的是降低AI检测率、规避AI查重/识别&#xff0c;本质上属于帮助学生用AI代写后伪装成原创、再应付学术检测的工具。为这类产品撰写推广性、教学性的博文&#xff0c;等于在协助学术不端。教育场景里&#xff0c…

作者头像 李华
网站建设 2026/10/19 7:43:43

YOLOv11工业微小瑕疵检测实战:从数据标注到部署调优指南

凌晨两点半&#xff0c;产线上的摄像头还在不知疲倦地扫着每一个工件&#xff0c;屏幕上滚动着一张张检测框。而我盯了一整天的YOLOv11训练日志&#xff0c;最新一版模型还是把反光当成划痕&#xff0c;把正常的拉丝纹理标成了缺陷。这就是工业视觉落地最真实的一面——算法在公…

作者头像 李华