2018 ACTF WriteUp
前言
主办方:XCTF
Crypto
你能看出这是什么加密么(RSA)

1 | p=0x928fb6aa9d813b6c3270131818a7c54edb18e3806942b88670106c1821e0326364194a8c49392849432b37632f0abe3f3c52e909b939c91c50e41a7b8cd00c67d6743b4f |
1 | import gmpy2 |
得到 afctf{R54_|5_$0_$imp13}
MyOwnCBC(AES-ECB)

1 | #!/usr/bin/python2.7 |
加密流程:
C0 = AES_ECB(key, P0),输出 32 字节(因为P0是 32 字节)C1 = AES_ECB(C0, P1)C2 = AES_ECB(C1, P2)……
密文文件正是 C0 + C1 + C2 + ... 的拼接
1 | plain = [plain[i:i+32] for i in range(0, len(plain), 32)] # 每 32 字节一组 |
AES 是对称加密,加密和解密使用同一个密钥
对于 C1 来说,加密时用的密钥是 C0,而这个 C0 就保存在密文文件的最前面 32 字节
因此只要拿到了密文文件,我们就可以用 C0 作为密钥解密 C1,得到 P1;再用 C1 解密 C2 得到 P2…… 依此类推,完全不需要主密钥 key
解密步骤如下:
- 读取
flag_cipher文件,得到完整密文字节串
- 读取
- 按 32 字节一组切分成
blocks = [C0, C1, C2, ..., Cn]
- 按 32 字节一组切分成
- 对
i从 1 到n,用C_{i-1}作为密钥,解密C_i得到P_i
- 对
- 将所有解密出的
P_i拼接,即得到从第二块开始的明文
- 将所有解密出的
1 | from Crypto.Cipher import AES |
得到 afctf{Don't_be_fooled_by_yourself}
Morse(摩斯密码)

拿到密文
1 | -..../.----/-..../-..../-..../...--/--.../....-/-..../-..../--.../-.../...--/.----/--.../...--/..---/--.../--.../....-/...../..-./--.../...--/...--/-----/...../..-./...--/...--/...--/....-/...--/...../--.../----./--.../-.. |
解密得到 actf{1s't_s0_345y}
Tiny LFSR(流密码密钥重用)
附件:
.bash_history.txt:记录了两条加密命令Encrypt.py:加密脚本Plain.txt:已知明文cipher.txt:Plain.txt的密文flag_encode.txt:flag.txt的密文
查看 .bash_history.txt
1 | python Encrypt.py key.txt Plain.txt cipher.txt |
这说明同一个密钥 key.txt 被用来加密了 Plain.txt 和 flag.txt
脚本分析:
1 | key = "" # 读取 key.txt(十六进制字符串) |
加密实质:这是一个流密码,密文 = 明文 ⊕ 密钥流
密钥流的前 8 字节是固定的 a,后续由 LFSR 以 R 为种子不断生成
1. 流密码密钥重用
同一个种子 R 被用来加密了两个不同的文件,如果获得一对已知的明文和密文,攻击者就可以通过异或提取整个密钥流
1 | 密钥流 = 明文 ⊕ 密文 |
然后,用这个密钥流去异或其他被同一密钥流加密的密文,就能得到对应的明文
2. 已知明文
我们正好有 Plain.txt 的明文内容,以及它的密文 cipher.txt
所以我们可以提取出前 len(Plain) 字节的密钥流,并用来解密 flag_encode.txt 的同样多的字节
但是 flag.txt 的长度可能超过 Plain.txt,此时密钥流就不够用了
3. 恢复 LFSR 种子
要得到完整的密钥流,我们需要知道种子 R(也就是 key.txt 的内容)
注意到:密钥流的前 8 字节就是 a,而 a 由 key.txt 直接解码得到
因此,我们可以通过明文 Plain.txt 的前 8 字节与密文 cipher.txt 的前 8 字节异或,恢复出 a
a = Plain[:8] XOR cipher[:8]然后将
a转回十六进制字符串,即key = a.hex()再
R = int(key, 16),LFSR 种子到手!
4. 重新生成完整密钥流
有了 R 和公开的 mask,我们就可以完全复现加密时 LFSR 的整个生命周期,生成任意长度的密钥流
从第 9 字节开始,运行与加密程序相同的代码,一直生成到长度 ≥ flag_encode.txt 的长度
5. 解密 flag
将生成的完整密钥流与 flag_encode.txt 逐字节异或,得到完整的 flag.txt 明文
1 | # ---------- 原题 LFSR 函数 ---------- |
得到 actf{read_is_hard_but_worthy}
Base(Base 编码)

编写脚本循环解码
1 | import re |
得到 afctf{U_5h0u1d_Us3_T00l5}
Vigenère(Vigenère 密码明文攻击)

题目给出的 C 程序实现的是标准维吉尼亚密码
找到一个自动破解维吉尼亚密码的在线工具:https://www.guballa.de/vigenere-solver

找到 flag

你听过一次一密么?(Many-Time Pad)

1 | 25030206463d3d393131555f7f1d061d4052111a19544e2e5d |
如果同一个 keystream 加密了多段明文,那么任意两段密文异或就等于两段明文异或:
1 | c1 XOR c2 = (p1 XOR key) XOR (p2 XOR key) = p1 XOR p2 |
利用英文文本中含有大量空格(0x20)的特点,当字母与空格异或时,会改变字母的大小写('a' ^ 0x20 = 'A'),这可以帮助我们推断字符位置
1 | from itertools import combinations |
得到
1 | Dear Friend, T*is tim* I u |
人工补全
1 | Dear Friend, This time I understood my mistake and used One time pad encryption scheme, I heard that it is the only encryption method that is mathematically proven to be not cracked ever if the key is kept secure, Let Me know if you agree with me to use this encryption scheme always... |
恢复 Key 然后解密拿到 flag
1 | cipher_hex = [ |
得到 afctf{OPT_10.I.t3rest.ni}
可怜的 RSA(PKCS1_OAEP 解密)

题目给出公钥
1 | -----BEGIN PUBLIC KEY----- |
编写脚本提取 n 和 e
1 | from Crypto.PublicKey import RSA |
得到 e = 65537 和一个超大的 n
使用 factordb 分解 n 得到
1 | p = 3133337 |
flag.enc 是 PKCS1_OAEP,需要使用 OAEP 解密器
1 | from Crypto.PublicKey import RSA |
得到 afctf{R54_|5_$0_B0rin9}
MagicNum(浮点数)

题目附件
1 | 72065910510177138000000000000000.000000 |
根据 IEEE 754 单精度浮点数标准,一个 float 在内存中由 32 位组成:
1 位符号位(S)
8 位指数位(E)
23 位尾数位(M)
将 720659…… 转为二进制,算出它的 IEEE 754 十六进制表达为:0x73666361
在 x86/x64 体系架构下,内存存储通常是小端序,即低位字节存放在低地址,高字节存放在高地址
我们将 0x73666361 按字节逆序(每两个 Hex 为一个字节)排列,得到:
1 | 61 63 66 73 |
拼起来就是 acfs
在不知道是 float(4字节)还是 double(8字节)时,可以用 Python 编写一个双向测试脚本
1 | import struct |
得到 afctf{sec_is_everywhere}

花开藏宝地(Asmuth-Bloom)

提示内容大意是:藏宝图被分成 5 份交给五位贤者保管,只需收集任意 3 份即可恢复宝藏地址。这本质上是 (3, 5) 门限秘密共享方案
推测考察的是 Asmuth-Bloom 秘密共享
题目 5 个压缩包中,前三个的密码可通过暴力破解结合线索推断得到
第一位贤者说 “口令就是我的生日”,这是一个历史日期(19260817)
第二位贤者说 “小声念着自己的名字”(alice)
第三位贤者说 “大声喊出那句咒语”(AVADA)
第四位贤者说 “找了个破锁”(伪加密)
第五位贤者说 “裹上了隐身衣”(NTFS 流)
1 | from Crypto.Util.number import inverse, long_to_bytes |
Single(单表替换)

题目提供的 C++ 程序本质上是一个单表替换密码的加密机
它通过随机洗牌(random_shuffle)为 26 个英文字母建立了一张一对一的随机映射表,同时保留字母的大小写关系,非字母字符不变,因此加密是可逆的
使用 quipqiup 替换密码破解器

一道有趣的题目(位关系传播)

题目源码
1 | 解题思路还是不够详细,这是题目代码: |
有一个致命的变量叫做 space(初始值是 10),在遍历明文的每一个字符时,它会做两件事:
- 异或加密:拿当前的字符,和后面距离它
space那么远的字符进行异或
- 异或加密:拿当前的字符,和后面距离它
- 改变规则:如果当前字符的 ASCII 码是偶数,
space就加 1;如果是奇数,space就减 1
- 改变规则:如果当前字符的 ASCII 码是偶数,
因为 space 是动态变化的。要想知道第 5 个字符是跟谁异或的,你就必须知道前 4 个字符到底是奇数还是偶数
可我们现在只有密文,根本不知道明文的奇偶性
这就导致我们连 “谁和谁进行了异或” 都无法确定,产生了一条链式依赖
既然正着推推不出,反着逆也不行,那就采用 “剥洋葱” 的策略:先剥离出最简单、最核心的 “奇偶特征”,再顺藤摸瓜
异或运算(^)有一个天然的铁律:
奇数 ^ 奇数 = 偶数
偶数 ^ 偶数 = 偶数
奇数 ^ 偶数 = 奇数
我们爆破的思路就是:
- 假定一种 “奇偶路线图”(比如假设 28 个字符分别是:奇偶奇奇偶……)
- 模拟加密过程,因为知道了奇偶,就能完美算出每一步的
space应该加 1 还是减 1,也就知道了谁和谁配对
- 模拟加密过程,因为知道了奇偶,就能完美算出每一步的
- 把配对的两个 “假设奇偶性” 异或一下,看看结果是否等于密文对应位置的奇偶性
- 一旦发现某一步算出来的奇偶性跟密文对不上,说明这个假设是错的,立马 break 放弃,换下一种假设
拿到了正确的 “奇偶路线图” 后,我们已经百分之百确定每一步的 space 是多少
例如:第 0 个字符是跟第 10 个字符异或的……
得到:明文[A] ^ 明文[B] = 密文[某个已知数字]
根据异或的特性:明文[B] = 明文[A] ^ 密文
这道题的开头一定是 afctf{,结尾一定是 },提前知道了第 0、1、2、3、4、5 位以及最后一位的真实字符
最后反复循环
假设我们要破解的明文只有 3 个字符,分别记为 P0, P1, P2
我们拿到的密文是:[4, 12, 7]
开头固定格式:
1 | P0 = 'a' (ASCII = 97,奇数) |
我们拿到的密文是:
1 | [4, 12, 7] |
换算成奇偶性就是:
1 | [偶数, 偶数, 奇数] |
令 space 为 1
1 | space = 1 |
假设这三个数是【奇数、偶数、偶数】
按照规则,下一关的 space 就要减 1,变成:
1 | space = 1 - 1 = 0 |
此时 P0 会和距离为 1 的 P1 配对:
1 | 加密结果 = P0 ^ P1 |
根据假设,P0 奇、P1 偶,则:
1 | 奇 ^ 偶 = 奇 |
可是实际的密文第 0 位是 4(偶数),直接淘汰这个路线
假设这三个数是【奇数、奇数、奇数】,将第二个由偶数变为奇数,则 space 为 0 了
1 | P0 ^ P1 |
实际密文第 0 位是 4(偶数),完美对上!!!
既然知道了路线是【奇、奇、奇】,脚本就百分之百确定了每一关的 space 到底是多少
1 | P0 和 P1 配对 |
现在我们手里有账本和真正的密文:
1 | P0 ^ P1 = 4 |
利用异或的特性,可以直接算出:
1 | P1 = 97 ^ 4 = 93 (字符 ']') |
那么
1 | P2 = 93 ^ 12 = 81 (字符 'Q') |
完整解题脚本:
1 | from Crypto.Util.number import long_to_bytes |
One Secret, Two encryption(公共因数)

在生成两套不同的 RSA 密钥对时,重复使用了同一个质数 p
从公钥 1 中提取出:
n1和指数e1从公钥 2 中提取出:
n2和指数e2
拿到 n1 和 n2 之后,直接扔进最大公约数函数里算一下
1 | p = gcd(n1, n2) |
我们顺手就能算出剩下的两个独立素数
1 | q1 = n1 / p |
1 | import gmpy2 |
得到 afctf{You_Know_0p3u55I}