前言
这比赛……怎么说呢,挺搞的,延期又延期,延到一个咱一整天课的日子,于是一上午都没看;中午吃瓜吃到大家九点钟准时登录平台发现一道题没有,直到大概40分钟后才有题,再加上一些其他的冲突,总之不少选手怨声载道的。
不过研究生就是好啊,先前打的一堆比赛给我打自闭了,一道题做不出来参与感0,这下至少能做几道密码了。但是签到题怎么交都交不上去,我不明白,这确实得骂一骂主办方吧。
正文
srsa 100pt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 import osimport ptyfrom Crypto.Util.number import *from secret import flagmenu = """ [1] Get Public Key [2] Get Ciphertext [3] Get Gift [4] Exit """ if __name__ == "__main__" : m = bytes_to_long(flag) p = getPrime(512 ) q = getPrime(512 ) e = getPrime(64 ) cnt = 0 while True : try : print (menu) choice = int (input ("Your choice: " )) if choice == 1 : n = p * q print (f"n = {n} " ) print (f"e = {e} " ) elif choice == 2 : n = p * q c = pow (m, e, n) print (f"c = {c} " ) elif choice == 3 : if cnt == 2 : print ("You know too much!!!" ) continue k = getPrime(384 ) r = getPrime(128 ) gift = k * p + r print (f"{gift = } " ) cnt += 1 elif choice == 4 : print ("Bye!" ) break else : print ("Invalid choice" ) except : print ("You can't do this!" ) break
一道RSA,给出了n, e, c和两个关于p的gift。直接丢给Gemini,它帮助我分析了每个变量,然后造了格()单凭我应该是想不到的。
已知:
n = p q
,
g i f t 1 = k 1 p + r 1
,
g i f t 2 = k 2 p + r 2
,因此将
p
换成
n / q
,则有
g i f t 1 × q = k 1 n + r 1 q
,
g i f t 2
同理,因此构造矩阵
[ n 0 0 0 n 0 g i f t 1 g i f t 2 1 ]
,这就是一个格,满足关系式
[ − k 1 , − k 2 , q ] [ n 0 0 0 n 0 g i f t 1 g i f t 2 1 ] = [ q r 1 , q r 2 , q ]
简单提一嘴格基规约(LLL),它的作用是从“大”的基中找到“更小”的基,我们现在有一个三阶矩阵(三个行向量)张成的空间,但是每个基向量都太长了,n是1024位数,gift都是896位数,因此第一行和第二行向量的长度均为
2 1024
,第三行长度为
( g i f t 1 ) 2 + ( g i f t 2 ) 2 + 1 ≈ 2 896
,而我们通过上面的关系式可以知道,这个空间可以用一组更小的基向量表示,这组基向量有一个——也就是
[ q r 1 , q r 2 , q ]
,它异常短,长度只有
( q r 1 ) 2 + ( q r 2 ) 2 + q 2 ≈ 2 640
,在一个基向量都很大的空间里,这是很罕见的。
以及由于格基规约的性质,它倾向于找到三个分量差不多的基,因此可以将矩阵右下角那个1改成
2 128
,这样三个分量长度就差不多了,更容易找到这组基。还好本题的小基差距很明显,不优化也能成功。
解出q之后RSA部分就不提了,最终的代码如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 from Crypto.Util.number import *from pwn import *conn = remote('192.168.18.25' , 9999 ) conn.sendafter(b'Your choice: ' , b'1\n' ) conn.recvuntil(b'n = ' ) n = int (conn.recvline()) conn.recvuntil(b'e = ' ) e = int (conn.recvline()) conn.sendafter(b'Your choice: ' , b'2\n' ) conn.recvuntil(b'c = ' ) c = int (conn.recvline()) conn.sendafter(b'Your choice: ' , b'3\n' ) conn.recvuntil(b'gift = ' ) gift_1 = int (conn.recvline()) conn.sendafter(b'Your choice: ' , b'3\n' ) conn.recvuntil(b'gift = ' ) gift_2 = int (conn.recvline()) print ("开始构建格..." )B = Matrix(ZZ, [ [n, 0 , 0 ], [0 , n, 0 ], [gift_1, gift_2, 1 ] ]) print ("格矩阵 B:" )print (B)print ("\n正在运行 LLL 算法..." )B_reduced = B.LLL() print ("LLL 约简后的基 (B_reduced):" )print (B_reduced)q = abs (B_reduced[0 ][2 ]) print ("\n--- 结果 ---" )if n % q == 0 and q > 1 and q < n: p = n // q print (f"[+] 成功找到 p 和 q!" ) print (f"p = {p} " ) print (f"q = {q} " ) if p * q == n: print ("[+] 验证成功: p * q == n" ) else : print ("[-] 验证失败: p * q != n" ) else : print (f"[-] LLL 攻击失败。" ) print (f"提取到的 q = {q} " ) print ("[-] q 不是 n 的有效因子。请检查 n 和 gifts 是否正确。" ) exit(0 ) conn.close() assert n%q == 0 p = n//q phi_n = (q - 1 )*(p - 1 ) d = inverse(e, phi_n) print ("d = " ,d)m = pow (c, d, n) print ("解密后的消息 m 为:" , long_to_bytes(m))
Sign4Flag 200pt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 import osimport ptyfrom Crypto.Util.number import *from gmssl import sm2, funcfrom hashlib import *from secret import flag, private_key, public_keysm2_crypt = sm2.CryptSM2(private_key=private_key, public_key=public_key) def sign (msg ): signature = sm2_crypt.sign(md5(msg).digest(), os.urandom(32 ).hex ()) return signature def verify (msg, sig ): return sm2_crypt.verify(sig, md5(msg).digest()) def login (usrname, passwd, sig ): if usrname != usr: return False if not verify(long_to_bytes(passwd), sig): return False return True menu = """ Flag management system [1] Sign in [2] Register [3] Forgot password [4] Exit""" usr = "admin" a = getPrime(64 ) sig = sign(long_to_bytes(13337 )) if __name__ == "__main__" : while True : try : print (menu) op = input ("> " ) if op == "1" : username = input ("Username: " ) password = int (input ("Password: " )) if login(username, password, sig): print (f"Welcome admin! Here is your flag: {flag[:GCD(password, a)]} " ) else : print ("Wrong username or password!" ) break elif op == "2" : print ("Sorry, registration is closed." ) elif op == "3" : tmp = int (input ("New password > " )) tmp2 = int (input ("Security number > " )) if not isPrime(tmp): print ("Password must be prime!" ) continue if tmp2 >= tmp: print ("Security number too large!" ) continue if tmp2 <= 0 : print ("Security number invalid!" ) continue sig = sign(long_to_bytes(tmp)) a = tmp2 print ("Password changed!" ) elif op == "4" : print ("Bye~" ) break else : print ("Invalid option!" ) except : print ("Something wrong" ) break
题目可以重置密码和a,但是密码一定是质数,a小于密码,这样重置后验证的GCD(password,a)只能是1,得不到全部的flag。因此考虑伪造签名。签名之前对消息进行了一次md5,可以找两串md5相同的字节b1和b2,bytes_to_long(b1)是质数,bytes_to_long(b2)是合数,这样可以将密码修改为bytes_to_long(b1),a修改为bytes_to_long(b2)的一个稍大的因子,输入密码时输入bytes_to_long(b2),即可泄露更多位。
由于b1和b2的md5相同,因此对于sm2来说它们就是同一个值,自然可以通过验签。但是实际上md5碰撞产生至少一个质数的概率并不大(如果真的有幸抽到了两个质数,md5还相同,那让密码是大的那个质数,a是小的那个质数就行了)。如果只是不断用fastcoll 去生成碰撞对,实测不太有希望得到质数。但是,md5可以使用长度扩展攻击,也就是说,同一个md5的两串字节,后面再加上同样的字节,md5仍相同。因此可以在一组碰撞实例的末尾拼接上一个奇数,期望得到一个质数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 from Crypto.Util.number import isPrime, bytes_to_long, long_to_bytesimport hashlibx = "4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa200a8284bf36e8e4b55b35f427593d849676da0d1555d8360fb5f07fea2" y = "4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa202a8284bf36e8e4b55b35f427593d849676da0d1d55d8360fb5f07fea2" print ("x : " , bytes_to_long(bytes .fromhex(x)))print ("md5(x) : " , hashlib.md5(bytes .fromhex(x)).hexdigest())print ("md5(y) : " , hashlib.md5(bytes .fromhex(y)).hexdigest())z = 1 xx = 0 yy = 0 while True : xx = bytes_to_long(bytes .fromhex(x) + long_to_bytes(z)) yy = bytes_to_long(bytes .fromhex(y) + long_to_bytes(z)) if isPrime(xx) or isPrime(yy): break z += 2 print ("x+z :" , xx)print ("y+z :" , yy)print ("md5(x+z) : " , hashlib.md5(long_to_bytes(xx)).hexdigest())print ("md5(y+z) : " , hashlib.md5(long_to_bytes(yy)).hexdigest())
突然感觉md5很有意思,构造碰撞的时候可以指定开头,长度扩展攻击可以指定结尾,不知道最短的碰撞对有多长,在这个长度以后的数据完全可以自定义了,想续啥就续啥。如果指定了中间部分的话,转化成开头或者结尾咯。这也恰恰说明了md5有多么不安全(笑)
stream 300pt
比赛的时候没做出来,虽然Gemini和ChatGPT已经提供了很好的思路,但是我还是误入了歧途()赛后研究一晚上才拿下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 from decimal import *from Crypto.Util.number import bytes_to_longfrom os import urandomfrom hashlib import sha512from gmssl import sm3, funcfrom random import choicesfrom string import printablefrom tqdm import trangefrom secret import flag, keygetcontext().prec = 100 def sm3_hash (msg ): msg = func.bytes_to_list(msg) hash_value = sm3.sm3_hash(msg) return bytes .fromhex(hash_value) class RC2147483648 : def __init__ (self, seed ): self .state = seed def getByte (self ): t = str (self .state) bs = sm3_hash(t.encode()) return bs[-1 ] def getSstream (self, n ): ss = b'' for _ in trange(n): ss += self .getByte().to_bytes(1 , 'big' ) self .next () return ss def next (self ): u = Decimal(3.5699456 ) self .state = self .state * u * (Decimal(1 ) - self .state) def encrypted (self, pt ): out = b'' Sstream = self .getSstream(len (pt)) for i in range (len (msg)): out += (msg[i] ^ Sstream[i]).to_bytes(1 , 'big' ) return out def decrypted (self, ct ): """ emmm... """ encrypter = RC2147483648(key) msg = "" .join(choices(printable, k=1048576 )).encode() msg = msg[:len (msg)//2 ] + b'\x00' + flag + b'\x00' + msg[len (msg)//2 :] + b'\x00\x00\x00\x00' ct = encrypter.encrypted(msg) f = open ('ciphertext' , 'wb' ) f.write(ct) f.close()
一个稀奇古怪的加密,使用逻辑斯谛映射(logistic map)从初始seed不断导出下一个状态,每个状态经过SM3散列得到一个字节,大概长这样
State: S 0 ( seed ) → S n + 1 = u S n ( 1 − S n ) S 1 → S n + 1 = u S n ( 1 − S n ) S 2 → S n + 1 = u S n ( 1 − S n ) … ↓ sm3, lastbyte ↓ sm3, lastbyte ↓ sm3, lastbyte Key: K 0 K 1 K 2 …
加密器不断得到连续的字节流,异或明文得到密文。因此,虽然它没有给解密算法,只要我们有key,完全可以自己将密钥流生成出来,异或得到明文。但问题是我们没有key,而key可能是一个100位的小数,爆破是不现实的。
尊贵的ChatGPT大人搜寻到了资料,它是这么说的
(1)在有限精度下 logistic-map 类 PRNG 会退化并产生可估计的周期/模式、(2)已知明文/已知 keystream 位置会被用来恢复初始条件或参数,因此该方案并非形式上安全。
这个加密方案的两个可能的弱点GPT都提到了,按照GPT给出的论文来看,这篇 文章似乎很有用。它提及在参数
3 < λ < 3.7
时,state会陷入周期,系统没能表现出混沌性。因此,这个加密是周期性的异或,而非一次一密。而周期性的异或加上明文可打印的条件,是可以逐个位置减少密钥空间,乃至恢复密钥的。
我们小心求证一下,对于给定的参数u=3.5699456,随机选取1000个种子,每个种子生成1048576个字节的密钥流,验证是否存在较短的周期。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 import randomfrom decimal import Decimal, getcontextfrom gmssl.sm3 import sm3, funcfrom tqdm import trange, tqdmgetcontext().prec = 100 def sm3_hash (msg ): msg = func.bytes_to_list(msg) hash_value = sm3.sm3_hash(msg) return bytes .fromhex(hash_value) class RC2147483648 : def __init__ (self, seed ): if not (Decimal(0 ) < seed < Decimal(1 )): raise ValueError("Seed必须在 (0, 1) 范围内" ) self .state = seed self .u = Decimal('3.5699456' ) def getByte (self ): t = str (self .state) bs = sm3_hash(t.encode()) return bs[-1 ] def next (self ): self .state = self .state * self .u * (Decimal(1 ) - self .state) if not (Decimal(0 ) < self .state < Decimal(1 )): self .state = Decimal(random.randint(1 , 999999 )) / Decimal(1000000 ) return self .state def getSstream (self, n ): ss = b'' for _ in trange(n, desc="Generating S-stream" ): ss += self .getByte().to_bytes(1 , 'big' ) self .next () return ss def encrypted (self, pt ): out = b'' Sstream = self .getSstream(len (pt)) for i in range (len (pt)): out += (pt[i] ^ Sstream[i]).to_bytes(1 , 'big' ) return out def check_short_cycle (seed, max_iter, period_limit ): """ 检查给定种子是否在 max_iter 步内 产生一个长度小于 period_limit 的循环。 使用字典来跟踪 "状态 -> 迭代次数" """ rc = RC2147483648(seed) states_seen = {} for i in range (max_iter): current_state = rc.next () if current_state in states_seen: first_seen_at = states_seen[current_state] cycle_len = i - first_seen_at if cycle_len < period_limit: return True , cycle_len else : return False , cycle_len states_seen[current_state] = i return False , None def run_test_suite (num_seeds, max_iter, period_limit ): """ 运行完整的测试套件 """ print (f"--- 开始周期性测试 ---" ) print (f"测试种子数: {num_seeds} " ) print (f"最大迭代次数/种子: {max_iter} (2^{int (max_iter.bit_length()-1 )} )" ) print (f"短周期阈值: {period_limit} (2^{int (period_limit.bit_length()-1 )} )" ) print (f"Decimal 精度: {getcontext().prec} 位" ) print ("-" * 20 ) short_cycle_count = 0 found_cycles = [] for i in tqdm(range (num_seeds), desc="Testing Seeds" ): rand_str = '0.' + '' .join(random.choices('0123456789' , k=getcontext().prec - 1 )) seed = Decimal(rand_str) if seed == Decimal(0 ) or seed == Decimal(1 ): continue try : is_short, cycle_len = check_short_cycle(seed, max_iter, period_limit) if is_short: short_cycle_count += 1 found_cycles.append((str (seed), cycle_len)) except ValueError as e: tqdm.write(f"种子 {str (seed)[:10 ]} ... 在迭代中失效: {e} " ) except Exception as e: tqdm.write(f"种子 {str (seed)[:10 ]} ... 发生未知错误: {e} " ) print ("\n--- 测试结果 ---" ) print (f"在 {num_seeds} 个随机种子中," ) print (f"**发现 {short_cycle_count} 个种子的周期小于 {period_limit} 。**" ) if short_cycle_count > 0 : print ("\n发现短周期的种子(部分示例):" ) for seed_str, length in found_cycles[:10 ]: print (f" - 种子 (前10位): {seed_str[:12 ]} ... \t周期: {length} " ) if __name__ == "__main__" : NUM_SEEDS = 1000 MAX_ITER = 2 **20 PERIOD_LIMIT = 2 **15 run_test_suite(NUM_SEEDS, MAX_ITER, PERIOD_LIMIT)
经过测试,在1000次实验中,1000次的周期都小于32768,进一步测试表明周期均为4096。这说明了虽然我们不知道题目使用的seed,但是大概率周期同样是4096,我们根据这个周期进行破解。
我们已知:flag前后均有一个0x00,明文最后四个字节是0x00,其余字符均为可打印字符。因此,对于每个位置,遍历0到255,对于这个位置对应的所有密文,异或这个密钥应该是可打印明文(或者刚才那几个特定的位置上是0x00)。如果出现不可打印的明文,则这个密钥一定不正确。对于有0x00存在的位,还有更高效的策略:由于0x00异或任何数都不改变另一个数,所以这个位置的密文直接就是密钥。经过大量密文的筛选,每个位置上的候选密钥都不会太多。
由于密文长为1048619,可打印的随机字符串长为1048576,0x00共有6个,因此flag一共37位。因此我们只需要这37个位置的密钥就足够了,其他位置即使密钥不能完全确定也不影响。
需要注意的是,周期为4096并不代表从一开始整个加密器就进入了周期。事实上,加密器会先运行一段时间才陷入循环。因此,密文开头的数据需要舍弃。在将尽可能多的数据纳入分析后,只有最前的172068个字节不能保留。如果留下它们,则会引起冲突,找不到能使所有密文都变成可打印明文的密钥了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 import stringimport sysfrom tqdm import tqdmfrom math import prodCT_FILE = 'ciphertext' PERIOD = 4096 def main (): print (f"[*] 正在加载密文文件: {CT_FILE} " ) try : with open (CT_FILE, 'rb' ) as f: ct = f.read() L_ct = len (ct) print (f"[*] 密文加载成功,总长度: {L_ct} 字节" ) except FileNotFoundError: print (f"[!] 错误: 密文文件 '{CT_FILE} ' 未找到。" ) sys.exit(1 ) L_RANDOM_MSG_TOTAL = 1048576 L_RANDOM_PART1 = L_RANDOM_MSG_TOTAL // 2 L_KNOWN_PLAINTEXT_BYTES = 6 L_flag = L_ct - L_RANDOM_MSG_TOTAL - L_KNOWN_PLAINTEXT_BYTES if L_flag < 0 : print (f"[!] 错误: 密文长度 {L_ct} 太短。" ) sys.exit(1 ) print (f"[*] 周期 $p={PERIOD} $。推断: len(flag) = {L_flag} 字节" ) L_CONTAMINANT_MID = L_flag + 2 flag_body_start_idx = L_RANDOM_PART1 + 1 + 5 flag_body_end_idx = flag_body_start_idx + (L_flag - 5 ) flag_body_ct = ct[flag_body_start_idx: flag_body_end_idx] slice2_start = L_RANDOM_PART1 + L_CONTAMINANT_MID slice2_end = L_ct - 4 slice2_range = (slice2_start, slice2_end) slice1_reliable_start = int (L_RANDOM_PART1 * 0.3281937 ) slice1_reliable_end = L_RANDOM_PART1 slice1_reliable_range = (slice1_reliable_start, slice1_reliable_end) print (f"[*] '约束 2' 将使用两个数据范围:" ) print (f" - 暂态后 (slice1) 范围: {slice1_reliable_range} " ) print (f" - 稳定 (slice2) 范围: {slice2_range} " ) PRINTABLE_BYTES = set (string.printable.encode('ascii' )) PRINTABLE_ONLY = PRINTABLE_BYTES - {0 } if 0 in PRINTABLE_BYTES else PRINTABLE_BYTES flag_body_key_positions = [] for i in range (len (flag_body_ct)): flag_body_key_positions.append((flag_body_start_idx + i) % PERIOD) unique_key_positions = set (flag_body_key_positions) key_candidates = {pos: set (range (256 )) for pos in unique_key_positions} print (f"[*] Flag 主体 ({len (flag_body_ct)} 字节) 依赖于 {len (unique_key_positions)} 个唯一的密钥字节。" ) print (f"[*] 正在应用“约束 1”(已知明文)..." ) known_pt = {} known_pt[L_RANDOM_PART1] = 0x00 prefix = b'flag{' for i in range (len (prefix)): known_pt[L_RANDOM_PART1 + 1 + i] = prefix[i] known_pt[L_RANDOM_PART1 + 1 + L_flag] = 0x00 for i in range (4 ): known_pt[L_ct - 4 + i] = 0x00 for idx, pt_byte in known_pt.items(): pos = idx % PERIOD if pos in key_candidates: key_byte = ct[idx] ^ pt_byte key_candidates[pos].intersection_update({key_byte}) print (f"[*] 正在应用“约束 2”(Printable msg parts)..." ) def filter_set_with_byte (guesses_set, ct_byte, constraints_set ): """辅助函数:用一个密文字节过滤一个猜测集""" if len (guesses_set) <= 1 : return current_guesses = guesses_set.copy() for k_guess in current_guesses: if (ct_byte ^ k_guess) not in PRINTABLE_ONLY: guesses_set.remove(k_guess) for pos in tqdm(key_candidates.keys(), desc="Filtering keys" ): if len (key_candidates[pos]) == 1 : continue start_idx = slice1_reliable_range[0 ] end_idx = slice1_reliable_range[1 ] first_i = start_idx + (pos - start_idx) % PERIOD for i in range (first_i, end_idx, PERIOD): filter_set_with_byte(key_candidates[pos], ct[i], PRINTABLE_ONLY) start_idx = slice2_range[0 ] end_idx = slice2_range[1 ] first_i = start_idx + (pos - start_idx) % PERIOD for i in range (first_i, end_idx, PERIOD): filter_set_with_byte(key_candidates[pos], ct[i], PRINTABLE_ONLY) print ("\n[*] 约束应用完成。正在检查剩余可能性..." ) total_solutions = 1 has_contradiction = False unresolved_keys = {} for pos in unique_key_positions: count = len (key_candidates[pos]) if count == 0 : print (f"[!] 致命错误: 密钥位置 {pos} 没有可能的解!(出现矛盾)" ) has_contradiction = True elif count > 1 : print (f"[!] 警告: 密钥位置 {pos} 仍有 {count} 个解" ) unresolved_keys[pos] = count total_solutions = prod([len (key_candidates[pos]) for pos in unique_key_positions]) if has_contradiction: print ("[!] 无法解密。请检查周期或已知明文/暂态假设。" ) sys.exit(1 ) print (f"[*] 总可能性空间: {total_solutions} 个解。" ) if total_solutions > 500000 : print ("[!] 解空间太大,无法全部打印。" ) print (f" 未唯一确定的密钥: {unresolved_keys} " ) print (" 请考虑增加更多约束" ) sys.exit(1 ) print ("\n==================== 可能的 FLAG ====================" ) solution_count = 0 def solve (flag_body_idx, current_flag_bytes ): nonlocal solution_count if flag_body_idx == len (flag_body_ct): try : print ("flag{" + current_flag_bytes.decode('ascii' )) solution_count += 1 except UnicodeDecodeError: pass return pos = flag_body_key_positions[flag_body_idx] c_byte = flag_body_ct[flag_body_idx] for k_guess in key_candidates[pos]: pt_byte = c_byte ^ k_guess if pt_byte in PRINTABLE_BYTES: solve(flag_body_idx + 1 , current_flag_bytes + bytes ([pt_byte])) solve(0 , b'' ) print ("=====================================================" ) print (f"[*] 搜索完成。共找到 {solution_count} 种符合 'printable-flag' 约束的解。" ) if solution_count == 0 and total_solutions > 0 : print ("[!] 未找到符合 'printable' 约束的解。也许 flag 包含非打印字符?" ) if __name__ == "__main__" : main()
用这些限制筛选掉尽可能多的可能性后,最后仍然有12种可能性不能排除,肉眼看一看找到最像flag的就好了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 [*] 正在加载密文文件: ciphertext [*] 密文加载成功,总长度: 1048619 字节 [*] 周期 $p=4096$。推断: len(flag) = 37 字节 [*] '约束 2' 将使用两个数据范围: - 暂态后 (slice1) 范围: (172068, 524288) - 稳定 (slice2) 范围: (524327, 1048615) [*] Flag 主体 (32 字节) 依赖于 32 个唯一的密钥字节。 [*] 正在应用“约束 1”(已知明文)... [*] 正在应用“约束 2”(Printable msg parts)... Filtering keys: 100%|██████████| 32/32 [00:00<00:00, 13249.53it/s] [*] 约束应用完成。正在检查剩余可能性... [!] 警告: 密钥位置 6 仍有 2 个解 [!] 警告: 密钥位置 7 仍有 2 个解 [!] 警告: 密钥位置 8 仍有 3 个解 [*] 总可能性空间: 12 个解。 ==================== 可能的 FLAG ==================== flag{Cngot1c_Sequ3nces_4lso_conv3rg3} flag{Cnaot1c_Sequ3nces_4lso_conv3rg3} flag{Cn`ot1c_Sequ3nces_4lso_conv3rg3} flag{Chgot1c_Sequ3nces_4lso_conv3rg3} flag{Chaot1c_Sequ3nces_4lso_conv3rg3} flag{Ch`ot1c_Sequ3nces_4lso_conv3rg3} flag{Engot1c_Sequ3nces_4lso_conv3rg3} flag{Enaot1c_Sequ3nces_4lso_conv3rg3} flag{En`ot1c_Sequ3nces_4lso_conv3rg3} flag{Ehgot1c_Sequ3nces_4lso_conv3rg3} flag{Ehaot1c_Sequ3nces_4lso_conv3rg3} flag{Eh`ot1c_Sequ3nces_4lso_conv3rg3} ===================================================== [*] 搜索完成。共找到 12 种符合 'printable-flag' 约束的解。
最正确的只能是flag{Chaot1c_Sequ3nces_4lso_conv3rg3}这个。
补充
即使完全抛弃flag前面的部分,只从flag开始也能够排除大部分了,剩下大约200多种,再借助一下精湛的英语语感(例如谷歌翻译在拼错单词时会猜测正确的词)就能找到。和另外两道题的flag都是随机的十六进制数相比,这一题倒是很有意思。
以及在做这题的时候虽然想到了周期性,但是一开始没跑那1000个种子的试验,因为那份代码跑一次硬控我六分钟。刚开始我用Gemini提供的IC(Index of Coincidence)测试。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 import collectionsimport mathimport sysimport numpy as npCT_FILE = 'ciphertext' MAX_PERIOD_GUESS_IC = 50 PRINTABLE_IC_THEORY = 1.0 / len (string.printable) def calculate_ic (data ): """ 计算一段字节数据的重合指数 (IC) """ if not data or len (data) < 2 : return 0.0 N = len (data) counts = np.bincount(np.frombuffer(data, dtype=np.uint8), minlength=256 ) numerator = np.sum (counts * (counts - 1 )) denominator = N * (N - 1 ) if denominator == 0 : return 0.0 return numerator / denominator def find_period_ic (ct, known_printable_ic ): """ 使用重合指数 (IC) 猜测周期 """ print (f"\n--- 重合指数 (IC) 分析 (测试周期 1 到 {MAX_PERIOD_GUESS_IC} ) ---" ) random_ic = 1.0 / 256.0 print (f"[*] 理论 IC (随机数据): {random_ic:.5 f} " ) print (f"[*] 理论 IC (Printable 明文): {known_printable_ic:.5 f} (我们的目标)" ) overall_ic = calculate_ic(ct) print (f"[*] “干净”密文的整体 IC: {overall_ic:.5 f} (应接近 0.0039)" ) if abs (overall_ic - random_ic) > 0.001 : print ("[!] 警告: 整体 IC 不接近随机值。" ) print ("\n[+] 开始逐个测试周期:" ) print ("---------------------------------------" ) print (" 周期 (长度) | 平均 IC 值" ) print ("---------------------------------------" ) results = [] for period in range (1 , MAX_PERIOD_GUESS_IC + 1 ): columns = [bytearray () for _ in range (period)] for i, byte in enumerate (ct): columns[i % period].append(byte) total_ic = 0.0 for col_data in columns: total_ic += calculate_ic(col_data) avg_ic = total_ic / period results.append((period, avg_ic)) marker = "" if avg_ic > (known_printable_ic * 0.9 ): marker = f" <<-- 高可能性 (接近 {known_printable_ic:.4 f} )" print (f" {period:<11 } | {avg_ic:.5 f} {marker} " ) print ("---------------------------------------" ) if results: best_period, best_ic = max (results, key=lambda item: item[1 ]) print (f"\n[*] IC 分析推测的最可能周期: {best_period} (IC = {best_ic:.5 f} )" ) else : print ("\n[*] IC 分析未找到明显的周期。" ) def main (): print (f"[*] 正在加载密文文件: {CT_FILE} " ) try : with open (CT_FILE, 'rb' ) as f: ct = f.read() L_ct = len (ct) print (f"[*] 密文加载成功,总长度: {L_ct} 字节" ) except FileNotFoundError: print (f"[!] 错误: 密文文件 '{CT_FILE} ' 未找到。" ) sys.exit(1 ) L_RANDOM_MSG_TOTAL = 1048576 L_RANDOM_PART1 = L_RANDOM_MSG_TOTAL // 2 L_KNOWN_PLAINTEXT_BYTES = 6 L_flag = L_ct - L_RANDOM_MSG_TOTAL - L_KNOWN_PLAINTEXT_BYTES if L_flag < 0 : print (f"[!] 错误: 密文长度 {L_ct} 太短,与预期的明文结构不符。" ) print (f"[!] 预期至少需要 {L_RANDOM_MSG_TOTAL + L_KNOWN_PLAINTEXT_BYTES} 字节。" ) sys.exit(1 ) print (f"[*] 根据密文长度推断: len(flag) = {L_flag} 字节" ) L_CONTAMINANT_MID = L_flag + 2 slice1_start = 0 slice1_end = L_RANDOM_PART1 slice2_start = slice1_end + L_CONTAMINANT_MID slice2_end = L_ct - 4 ct_clean_1 = ct[slice1_start : slice1_end] ct_clean_2 = ct[slice2_start : slice2_end] ct_to_test = ct_clean_1 + ct_clean_2 L_clean = len (ct_to_test) print (f"[*] 提取的“干净”密文块 1: 字节 0 到 {slice1_end} " ) print (f"[*] 提取的“干净”密文块 2: 字节 {slice2_start} 到 {slice2_end} " ) print (f"[*] 用于 IC 分析的总数据量: {L_clean} 字节 (应为 {L_RANDOM_MSG_TOTAL} )" ) if L_clean != L_RANDOM_MSG_TOTAL: print (f"[!] 警告: 提取的“干净”数据长度 ({L_clean} ) 与预期的 {L_RANDOM_MSG_TOTAL} 不符!" ) import string printable_bytes = set (string.printable.encode('ascii' )) printable_ic_estimate = 1.0 / len (printable_bytes) find_period_ic(ct_to_test, printable_ic_estimate) print ("\n--- 分析完成 ---" ) print ("请查看 IC 分析中 IC 值'异常高'(接近 ~0.01)的周期。" ) print ("这个数字就是密钥的周期长度。" ) if __name__ == "__main__" : main()
如果周期不对,那么每个密文对应的密钥不同,密文分布应该在0x00到0xFF均等,也就是重合指数应该在
1 256 ≈ 0.00391
,周期对的话密文来自可打印字符异或同一个数,由于可打印字符的范围较小,只有100种,因此密文范围也较小,重合指数约为
1 100 = 0.01
,将远远大于周期不对的情况。我偶然发现2的幂次对应的重合指数都格外高,于是严重怀疑周期是2的若干次方。
然后用2的幂测IC,发现2048和4096都几乎达到1,2048还高一些。我问Gemini为什么达不到1,它跟我说在进入周期之前会有一段暂态,这部分不应加入计算。于是它去掉了前75%,终于看到接近0.01了——但是更大的周期IC更高,131072甚至超过了0.01,我没敢信。于是我去想别的验证周期的方案,就花了好几个六分钟,跑不同的种子。不过还得感谢这段经历,要不然我到死都想不到为什么周期明明是4096,但是按照4096切片却找不到key使得每一位都是可打印字符。结论是最开始那一段就该扔掉。
以及这个混沌理论都是2008年的东西了,华为杯有点老啊。
后记
最近比赛有点多,强网杯做出0道,强网拟态抄网上的模板做了一道,着实有些道心破碎了,感觉自己有点没入门密码学hhh,但是即使自己就这水平,也已经算是队伍里半个主力了,怎么会这样呢()多学吧,幻夜子雨菜菜的。