偶然发现 OSU 也有办 CTF,但是没参加上,结束后下载了点直接看附件的 Crypto 和 Reverse 来做做

osu!gaming CTF 2025 (Rhythm Game Edition)

# crypto

# crypto/rot727

af431375b715993a82d6c9b516a4feeb_MD5

ROT13

80eb6327ed01a54ae82ea3e953273c9c_MD5

# crypto/beyond-wood

0f93ae3995d84c9ababbd5471a1f06c9_MD5

压缩包内有一个加密脚本和一个 output.png
21dc60093a0891a2b1a9f83737419625_MD5

# script.py
from PIL import Image
import random
FLAG = Image.open("flag.png")
width, height = FLAG.size
key = [random.randrange(0, 256) for _ in range(width+height+3)]
out = FLAG.copy()
for i in range(width):
    for j in range(height):
        pixel = FLAG.getpixel((i, j))
        pixel = tuple(x ^ k for x, k in zip(pixel, key))
        newi, newj = (2134266 + i * 727) % width, (4501511 + j * 727) % height 
        out.putpixel((newi, newj), pixel)
out.save("output.png")

加密过程先生成一个 key 字节数组,与每个像素的 RGB 值异或,然后按照某个算法将像素移位

解密直接将像素放回原位即可,不用管像素的异或,颜色可以区分出来

from PIL import Image
import random
encrypted_img = Image.open("output.png")
width, height = encrypted_img.size
decrypted = Image.new("RGB", (width, height))
for i in range(width):
    for j in range(height):
        newi, newj = (2134266 + i * 727) % width, (4501511 + j * 727) % height
        pixel = encrypted_img.getpixel((newi, newj))
        decrypted.putpixel((i, j), pixel)
decrypted.save("decrypted_flag.png")

还原图像
20b9482b6410382846f2708633317f0e_MD5

# crypto/xnor-xnor-xnor

a960ef32ccf6b2b2eb4da6dd47e87c05_MD5

查看加密脚本

import os
flag = open("flag.txt", "rb").read()
def xnor_gate(a, b):
    if a == 0 and b == 0:
        return 1
    elif a == 0 and b == 1:
        return 0
    elif a == 1 and b == 0:
        return 0
    else:
        return 1
def str_to_bits(s):
    bits = []
    for x in s:
        bits += [(x >> i) & 1 for i in range(8)][::-1]
    return bits
def bits_to_str(bits):
    return bytes([sum(x * 2 ** j for j, x in enumerate(bits[i:i+8][::-1])) for i in range(0, len(bits), 8)])
def xnor(pt_bits, key_bits):
    return [xnor_gate(pt_bit, key_bit) for pt_bit, key_bit in zip(pt_bits, key_bits)]
key = os.urandom(4) * (1 + len(flag) // 4)
key_bits = str_to_bits(key)
flag_bits = str_to_bits(flag)
enc_flag = xnor(xnor(xnor(flag_bits, key_bits), key_bits), key_bits)
print(bits_to_str(enc_flag).hex())
# 7e5fa0f2731fb9b9671fb1d62254b6e5645fe4ff2273b8f04e4ee6e5215ae6ed6c

加密过程先生成 4 个字节的 key,然后复制到 len(flag) / 4 + 1 的长度,再逐位同或 3 次

同或 3 次相当于同或 1 次,由于已知 flag 前缀为 osu{ ,可以对 4 字节 key 进行爆破

import binascii
enc_flag = binascii.unhexlify(b'7e5fa0f2731fb9b9671fb1d62254b6e5645fe4ff2273b8f04e4ee6e5215ae6ed6c')
print(enc_flag)
key = b''
for k1 in range(256):
    if chr(~(k1 ^ enc_flag[0]) & 0xFF) == 'o':
        key += k1.to_bytes(1, 'big')
for k2 in range(256):
    if chr(~(k2 ^ enc_flag[1]) & 0xFF) == 's':
        key += k2.to_bytes(1, 'big')
    
for k3 in range(256):
    if chr(~(k3 ^ enc_flag[2]) & 0xFF) == 'u':
        key += k3.to_bytes(1, 'big')
    
for k4 in range(256):
    if chr(~(k4 ^ enc_flag[3]) & 0xFF) == '{':
        key += k4.to_bytes(1, 'big')
key = key * (1 + len(enc_flag) // 4)
flag = ''.join([chr(~(x ^ y) & 0xFF) for x, y in zip(key, enc_flag)])
print(flag)

e1fae23e0755d3b0655ecf1703445712_MD5

# crypto/pls-nominate

3e843ed059c0005a14fab2f54ffc39bf_MD5

查看加密脚本

from Crypto.Util.number import *
FLAG = open("flag.txt", "rb").read()
message = bytes_to_long(
    b"hello there can you pls nominate my map https://osu.ppy.sh/beatmapsets/2436259 :steamhappy: i can bribe you with a flag if you do: " + FLAG
)
ns = [getPrime(727) * getPrime(727) for _ in range(5)]
e = 5
print(len(FLAG))
print(ns)
print([pow(message, e, n) for n in ns])

RSA 加密,提供了 flag 的长度、5 个 N 、5 个对应的密文 C

由于使用同一个指数 e=5 加密并使用了多个 N ,存在广播攻击,可用中国剩余定理恢复
m^5 mod N, N = n1*n2*...*n5

如果 m < n_i, m^5 < N = n1*n2*...*n5 ,直接开方就能得到 m
但是这里 m > n_i ,需要爆破一个 t 使得 m^5 = x+t*N (x = m^5 mod N)

N 为 7266 位, m^5 为 7274 位,差距约为 2^(7274-7266) = 256
5fa88d9c406a0859930a826aac7287e1_MD5

k 的搜索范围大约 256 +- 100

from Crypto.Util.number import *
import gmpy2
from functools import reduce
message = b"hello there can you pls nominate my map https://osu.ppy.sh/beatmapsets/2436259 :steamhappy: i can bribe you with a flag if you do: " + b'osu{' + b'a' * (51 - 4)
message = bytes_to_long(message)
ns = [250595396282063209494575040924347535718377683482891600245762578107828862102674761114596255712438613333084596184792828788407383247558654559454089469986479081755983932855108019303831103066796233258382489582930536579371270189763162153515702860564938568045151352777820047894231299381399970139347989102071591624967158663442340607621438098266100701196825065519485094630829410736139969891010792546625692049625954656831699035171996469050264653519, 274193894251894220756361545934981580561716587343502325614560502213231097664469582415896327192024233714369720707686115433619798180404789138894067772483823865845960858748702597162370083356345247341014451005796413454404641015516131648586490172413946109076519615677569833823093216546284937656690546486895353267404868698676199564604463636499575466246860736708986174600028222786416474243294353345680209117811499529853520907091701228114817603341, 250799342406461286072464998395337312450245201655027007410237718631499754452822255347456526289099898965321822671514782488741429427305776533799167428860480805487315744950556177330105418061977431001775317350105402223927656557524000999352457757910475375628290615583082601813818635461940350546377443417651889260364946457646723831198413138381669914264256219982213937975414376723125047495586971424241557485231775101178049132539231505246677535311, 424944038623958440933855665649910939213053530859402286143675513220496021786445337272966523453996205752202713497414819150875478398393046433188515528170856189163826639434032436582059667688688880862276540443913407720932133045261989787061306952765245738545030903458101323357019464537917143233906507656465350475977450213990615572573474700619979502153864626401863294100140176446546365233603532530624614938982882757207592863414287369042546971519, 190902360498923981884467164531021372209737709612751157876047387113070619788864781987406983172298076877277648095765822757629049183301893699559004938030166520411955041284763767367633388446007417241478444104664237424445213634253699407015432110027972438018815169650491766550691229860103594060212173628624522421052861949630723039778975476515078589820738377723637869903050748150998629965678848786824733474968760721078438148119740362699143145661]
cs = [123451724931477698812069511077458860459906769716993869507604368750013627155203649026878565706523272034474865506508962044958969096479439140090681403538412644329412752922637248582028201974150208943081418296518937380419465322558643434318990712657710219963788075009287648875763482189708828745866942138140694074154628539909488581608681566648945293332282252866416448399029058170554552880389525160322280616124297247776106117085199571903380300602, 25521272212086093871680233376409740841684880441320606653986457586214228433726846827063346040622596803682405351090250464809787622301562945715858274147064371839443966925136930647541454865892780911461753966896009773200289476756676124794087249400266226027218836477923252144492106017794796924100502787481309583584738175138358520327875032255246435556182629496953900460417813322557030951101538854106453327597012091626682332420269075467900021711, 185794248348100154380171644978622218400518238250460812348815837123659842567285820889770059992960889881347901994420631735102166283167843240900863871790624347481898956733174665116495731603736544724548220276282583347110470600528846477853732162784786525839648496719341390265839130761362980916014963941737879183454424512265005367317359376039242988696224018798645014519276397488302687294083964113652155465518932071360150626192713920598188877877, 253586754830185402755129821208401339617813079433078480306550244166018490731187464131167959996920120199131859308803805312785667542198282217092146066559207637833505962392131719788571007971893923218099583927291388588829681723690416962688366275898952085934742953047229390090702589945168823704791405472122768397486450092804662113840124827388352338015003693741233274505595280978468538153880244070082267537407664878646371738748713917087874183934, 179921278884389482689970386454786826216817164768869732618011304299464646110222440114625532548731013316437178194666578920837571688939194292542951671294588526331670811293769249832666220726222224721812635640949602967313922271367769076173808739323285134139353621782784562334885703861549547716097420971226456620806331145247840335617343331077891979324239020388123429770254863290698575175653976557893390825861821917810477967554648397683909903482]
e = 5
# CRT 求解 x ≡ m^5 mod N
def crt(residues, moduli):
    total = 0
    N_total = 1
    for n in moduli:
        N_total *= n
  
    for r, n in zip(residues, moduli):
        Ni = N_total // n
        inv = int(gmpy2.invert(Ni, n))
        total += r * Ni * inv
    return total % N_total
N_prod = 1
for n in ns:
    N_prod *= n
x = crt(cs, ns)
bit_len_m5 = gmpy2.bit_length(pow(message, e))
log2_N = gmpy2.bit_length(N_prod)
t_approx = 1 << (bit_len_m5 - log2_N)
print(f"Estimated t: {t_approx} (around {t_approx-100} ~ {t_approx+100})")
print(f"N bit length: {log2_N}")
print(f"m^5 bit length: {bit_len_m5}")
found = False
prefix = b"hello there can you pls nominate my map https://osu.ppy.sh/beatmapsets/2436259 :steamhappy: i can bribe you with a flag if you do: "
for t in range(max(0, t_approx - 100), t_approx + 100):
    candidate = x + t * N_prod
    m, exact = gmpy2.iroot(candidate, e)
    if not exact:
        continue
  
    m_bytes = long_to_bytes(int(m))
    if m_bytes.startswith(prefix):
        print(f"[+] Found! t = {t}")
        flag = m_bytes[len(prefix):]
        print(f"Flag: {flag.decode()}")
        found = True
        break
if not found:
    print("[-] Failed to find valid m. Try expanding t range?")

c75e11f8375c7cf244bcd6c3943f7310_MD5

# crypto/linear-feedback

3690f950cc532696f0e95bd715bb6f22_MD5

查看加密脚本

from secrets import randbits
from math import floor
from hashlib import sha256
class LFSR:
    def __init__(self, key, taps, format):
        self.key = key
        self.taps = taps
        self.state = list(map(int, list(format.format(key))))
  
    def _clock(self):
        ob = self.state[0]
        self.state = self.state[1:] + [sum([self.state[t] for t in self.taps]) % 2]
        return ob
def xnor_gate(a, b):
    if a == 0 and b == 0:
        return 1
    elif a == 0 and b == 1:
        return 0
    elif a == 1 and b == 0:
        return 0
    else:
        return 1
key1 = randbits(21)
key2 = randbits(29)
L1 = LFSR(key1, [2, 4, 5, 1, 7, 9, 8], "{:021b}")
L2 = LFSR(key2, [5, 3, 5, 5, 9, 9, 7], "{:029b}")
bits = [xnor_gate(L1._clock(), L2._clock()) for _ in range(floor(72.7))]
print(bits)
FLAG = open("flag.txt", "rb").read()
keystream = sha256((str(key1) + str(key2)).encode()).digest() * 2
print(bytes([b1 ^ b2 for b1, b2 in zip(FLAG, keystream)]).hex())

定义了一个 LSFR,随机生成了两个 key ,给出了 key1 key2 分别生成 72 位密钥流后同或的结果,并用 SHA256(key1+key2) 与 flag 异或加密

由于 key1key2 分别只有 21 和 29 位,根据 LSFR 的特性,密钥流的前 x 位与密钥本身相同
而题目给出 72 位的同或结果 outstream ,可以爆破其中一个密钥(比如爆破 key1 )后得到密钥流 keystream1keystream1 ^ outstream 可以反推另一个密钥 outstream2 ,检查 outstream2 能否由 LFSR(key2, [5, 3, 5, 5, 9, 9, 7], "{:029b}") 生成,即可还原 key1 key2

outstream = [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0]
for key in range(0, 2**21):
    L1 = LFSR(key, [2, 4, 5, 1, 7, 9, 8], "{:021b}")
    keystream1 = [L1._clock() for _ in range(72)]
    outstream2 = [xnor_gate(keystream1[i], outstream[i]) for i in range(72)]
  
    L2 = LFSR(int(''.join(map(str, outstream2[:29])), 2), [5, 3, 5, 5, 9, 9, 7], "{:029b}")
    keystream2 = [L2._clock() for _ in range(72)]
  
    if outstream2 == keystream2:
        key1 = key
        key2 = int(''.join(map(str, outstream2[:29])), 2)
        break
  
print(key1, key2)

9b5951e01dd2502ff4d36a32ea638562_MD5

但这组 key 无法解出 flag,推测可能存在多组 key,尝试所有组合

from hashlib import sha256
class LFSR:
    def __init__(self, key, taps, format):
        self.key = key
        self.taps = taps
        self.state = list(map(int, list(format.format(key))))
  
    def _clock(self):
        ob = self.state[0]
        self.state = self.state[1:] + [sum([self.state[t] for t in self.taps]) % 2]
        return ob
def xnor_gate(a, b):
    if a == 0 and b == 0:
        return 1
    elif a == 0 and b == 1:
        return 0
    elif a == 1 and b == 0:
        return 0
    else:
        return 1
outstream = [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0]
flag = bytearray(bytes.fromhex("9f7f799ec2fb64e743d8ed06ca6be98e24724c9ca48e21013c8baefe83b5a304af3f7ad6c4cc64fa4380e854e8"))
for key in range(0, 2**21):
    L1 = LFSR(key, [2, 4, 5, 1, 7, 9, 8], "{:021b}")
    keystream1 = [L1._clock() for _ in range(72)]
    outstream2 = [xnor_gate(keystream1[i], outstream[i]) for i in range(72)]
  
    L2 = LFSR(int(''.join(map(str, outstream2[:29])), 2), [5, 3, 5, 5, 9, 9, 7], "{:029b}")
    keystream2 = [L2._clock() for _ in range(72)]
  
    if outstream2 == keystream2:
        key1 = key
        key2 = int(''.join(map(str, outstream2[:29])), 2)
        print(f"[+] Match found! key1={key1}, key2={key2}")
    
        keystream = sha256((str(key1) + str(key2)).encode()).digest() * 2
        print(bytes([b1 ^ b2 for b1, b2 in zip(flag, keystream)]))

d582bd239ba67899174e4bcaf56c9998_MD5

# crypto/ssss

查看加密脚本

#!/usr/local/bin/python3
from Crypto.Util.number import *
import random
p = 2**255 - 19
k = 15
SECRET = random.randrange(0, p)
def lcg(x, a, b, p):
    return (a * x + b) % p
a = random.randrange(0, p)
b = random.randrange(0, p)
poly = [SECRET]
while len(poly) != k: poly.append(lcg(poly[-1], a, b, p))
def evaluate_poly(f, x):
    return sum(c * pow(x, i, p) for i, c in enumerate(f)) % p
print("welcome to ssss", flush=True)
for _ in range(k - 1):
    x = int(input())
    assert 0 < x < p, "no cheating!"
    print(evaluate_poly(poly, x), flush=True)
if int(input("secret? ")) == SECRET:
    FLAG = open("flag.txt").read()
    print(FLAG, flush=True)

加密使用了 LCG 线性同余生成器
初始生成三个随机参数 s0 , a , b ,再通过 LCG 生成 s1~s14 (所有计算都在模 p 域中)
题目允许输入 14 次 x(0<x<p)x(0<x<p),每次给出

f(x)=i=014(Sixi)=S0+S1x+S2x2+...+S14x14f(x)=\sum_{i=0}^{14}(S_i \cdot x^i)=S_0+S_1x+S_2x^2+...+S_{14}x^{14}

可通过输入 1-14,获得f(1)f(1)f(14)f(14),得到 14 个方程,但由于 S 共有 15 个,无法直接解出
考虑当 n>0 时,存在递推关系Sn=aSn1+bS_n=aS_{n-1}+b,因此可以把所有SnS_n 都转化为含有S0S_0 的表达式,然后尝试解出含有 S0 , a , b 14 个方程的方程组

from pwn import *
nc = remote("ssss.challs.sekai.team", 1337)
print(nc.recvline().decode().strip())
print(nc.recvline().decode().strip())
points_ = []
for x in range(1, 15):
    nc.sendline(str(x).encode())
    y = int(nc.recvline().decode().strip())
    print((x, y))
    points_.append((x, y))
from sage.all import *
p = 2**255 - 19
k = 15
F = GF(p)
# 建立多元多项式环(s,a,b)
R = PolynomialRing(F, ['s','a','b'])
s, a, b = R.gens()
# 根据递推公式计算系数 S_i (s, a, b)
def build_coefficients(s, a, b, k):
    coeffs = [s]
    for i in range(1, k):
        next_c = a * coeffs[-1] + b
        coeffs.append(next_c)
    return coeffs
# 构造多项式 f (x) = sum_{i=0}^{14} S_i * x^i
coeffs = build_coefficients(s, a, b, k)
def evaluate_poly(x_val):
    return sum(coeffs[i] * (x_val**i) for i in range(k))
# 收集点后建立方程
equations = []
for x, y in points_:
    eq = evaluate_poly(x) - y
    equations.append(eq)
# 尝试消元或 Groebner basis 求解
I = ideal(equations)
GB = I.groebner_basis()
print(I)
print(GB)
s_val = (-Integer(GB[0].subs({s:0,a:0,b:0}))) % p
a_val = (-Integer(GB[1].subs({s:0,a:0,b:0}))) % p
b_val = (-Integer(GB[2].subs({s:0,a:0,b:0}))) % p
def build_coeffs(s0, a0, b0, k, p):
    cs = [int(s0)]
    for i in range(1, k):
        cs.append((a0 * cs[-1] + b0) % p)
    return cs
cs = build_coeffs(s_val, a_val, b_val, 15, p)
for (x,y) in points_:
    acc = 0
    xp = 1
    for ci in cs:
        acc = (acc + ci * xp) % p
        xp = (xp * int(x)) % p
    assert acc == int(y), "验证失败"
print("所有点验证通过!")
print(nc.read())
nc.sendline(str(s_val).encode())
print(nc.recvline().decode().strip())

ead0bd0d3dc69745fe848122ed67787b_MD5

# crypto/please-nominate

db96b1ed55b2aadf51dbb4c916b37b65_MD5

查看加密脚本

from Crypto.Util.number import *
FLAG = open("flag.txt").read()
BNS = ["Plus4j", "Mattay", "Dailycore"]
print(len(FLAG))
for BN in BNS:
    print("message for", BN)
    message = bytes_to_long(
        (f"hi there {BN}, " + FLAG).encode()
    )
    n = getPrime(727) * getPrime(727)
    e = 3
    print(n)
    print(pow(message, e, n))

RSA 加密,已知明文为 "hi there {BN}, " 开头,给了 3 组 N 和密文 C
已知明文的高位,且使用相同的 e=3 ,属于 stereotyped message 模版消息攻击:
AA 表示明文高位,xx 表示明文低位,明文M=(A+x)M=(A+x),因此有

(A+x)3C0 (mod N)(A+x)^3-C≡0\ (mod\ N)

x<N(1/e)x<N^(1/e) 时,可通过 Coppersmith 方法快速解出,但这里的NN 约为 727+727=1454 位,FLAG 的长度为 147 字节,需要求的xx 约为 8*147=1176 位,不满足该条件,无法直接解得xx

题目给出了 3 组参数,可以构造出 3 个同余方程,然后通过 CRT 合并为一个方程:
对于每组参数,将方程展开后,得到

fi(x)=ci,0+ci,1x+ci,2x2++ci,exe (mod Ni)f_i(x)=c_{i,0}+c_{i,1}x+c_{i,2}x^2+⋯+c_{i,e}x^e\ (mod\ N_i)

合并后的方程中,xdx^d 的系数可通过 sage 函数 CRT_list() 直接计算得到
Cd=C_d= CRT_list([ c0,d,c1,d,,ce,dc_{0,d},c_{1,d},\dots,c_{e,d} ],[ N0,N1,,NeN_0,N_1,\dots,N_e ])
也就是满足
Cd mod N0=c0,d;Cd mod N1=c0,1;...;Cd mod Ne=c0,eC_{d}\ \text{mod}\ N_0 = c_{0,d}; C_{d}\ \text{mod}\ N_1 = c_{0,1}; ...; C_{d}\ \text{mod}\ N_e = c_{0,e}

from Crypto.Util.number import *
from sage.all import *
BNS = ["Plus4j", "Mattay", "Dailycore"]
ns = [398846569478640111212929905737126219425846611917845064245986310899352455531776606361272505433849914145167344554995030812644189047710542954339906669786929747875597103059283954786345252202913509966200329213618547501451752329008531151228646387182403280019664272348231587940227470159846477386295856419407431569159867135365878913551268186765163877676657618137090681937329865631964114691373454627873900294385351135992352798790940857277941472243, 263921537800979838796221921202623647462415714721726394821753160972868778052085367522658133754602607941536627474441882978361116817475949497489399939969612509386335591643019109294105788234910211931439396509289221190345347268312099449152342020093136914687793357372601654532872673983206837150636881928382445566331068237688851345596537893940860402116702078271640048006152159670916299390559068168682951764623558492401318864545619303656361575039, 244031565800210621970295548144726813179733488382314342571474949081381534498271587584918252306707810369627816196681952536809552862200098862267362735277533022204353497254323228274491354364582967316701126783750290886096960998524414107270804949357307485711415647006606381700355291677615735646360495158637105102935658791364633128882874894799509049190571860852436246528522948513282608143921733438326627413507376148669722384969604597622237576689]
cs = [158222951303542921410153264594688628146576794503998427093311713650774531277430572380170172031191979123500854263417781975728851277579707820383487572964731381023292312261571110891911863884482902461410218286288183400964045679561296043109527250114073394295486982996930960424139332989226106162113091535475425207610140495532136130360540296097313129598880764160739736823532823136291009499982471028009894097569348660440830784543668141509385395632, 166535918659821916010028099769670832129351247306732465032191446110439632420210106966178779555368253320514619095691319433325610616798380002564235371548166904170934635615481900225094067835806190805967329346507481446735982286060193425107057243896658750175391024795867108700750688771820192759373880324263838550825384190714299697136118712791588291627904942573970568726142296145821435413605295796704576678263679112272532276173497584694652201371, 132630164676661516893599967289982601955380588903536428955472887691456873565355730161547637935630009622741758822400797894281114572338413548852823840995609427904180355965179631480101131212344121270312182355342132383562008365671754190667582150820337165104642347684925014217687937383396003151315200366708307846959894632317624125785370063052712288658901699972320248281442796056361927746314341729204052997736037101265782906021307934740092047950]
N = prod(ns)
x = PolynomialRing(ZZ, 'x').gen() # CRT 操作需要在整数环上进行
flag_len = 147
polys = []
for BN, n, c in zip(BNS, ns, cs):
    prefix = f"hi there {BN}, "
    message = bytes_to_long(
        (prefix + '\0' * flag_len).encode()
    )
  
    poly = (message + x) ** 3 - c
    polys.append(poly)
  
Fs = CRT_list(polys, ns).change_ring(Zmod(N)) # small_roots 需要在模 N 环上进行
roots = []
# 爆破参数,这一题里面 beta=0.8 才能解出 x
#for beta in [0.01 * i for i in range(1, 100)]:
#    rts = Fs.small_roots(X=2**(8*(flag_len)), beta=beta)
#    if rts:
#        roots.append((beta, rts[0]))
root = Fs.small_roots(X=2**(8*(flag_len)), beta=0.8)[0]
print(long_to_bytes(Integer(root)))

5387871200933ea325527a0d9424aeb4_MD5

# reverse

# rev/bleh

59acf59eb5c9d6799996b4ddb193c2fd_MD5

压缩包内给了 3800 + 个文件,每个 15KB,打开第一个看到 ELF 文件头
4112d9e6a67bbf92297a5e4bce0813b0_MD5

编写脚本把所有片段合并在一个文件中

import os
elf = b''
for block in os.listdir('dist'):
    elf += open(f'dist/{block}', 'rb').read()
with open('elf', 'wb') as f: f.write(elf)

IDA 分析文件,查看 main 函数
3b45ed765799e8c1bc74a442501ed0d7_MD5

输入为 0x20 字节,逐字节进行 sub_1337() 操作,最后与 s2 进行对比
核心逻辑在 sub_1337 函数
2a2480f468d96945e6f8a8ce23fdb54a_MD5

除了 SSE 指令的混淆代码,还调用了两个函数 sub_11C9sub_1280
96e11ebbdfc62558dbc758933e0e03fa_MD5

6dfebcd739149c48c7131fb2262ea6f3_MD5

丢给 AI 分析,可知 sub_11C9(x, k) = x + ksub_1280(x, k) = x - k

观察后续操作
8b7b98d8bce7a04d9bf206a778772ecb_MD5
有效的指令为 xoradd

还原函数逻辑,最后只取一个字节

def sub_1337(a1, a2, n):
	a1 += 6
	a2 += 128
	n -= 128
	return (n + (a1 ^ a2)) & 0xFF

逐个字节爆破即可

key_str = "PL4YING_CTFS_ISNTBETTER_THAN_OSU"
# 小端序
target_bytes = bytes.fromhex(
    "7393F1587D9FCB34"
    "A90B7CE14674DD4F"
    "BD3AB92183F65AC2"
    "24A31A93FC75E447"
)
state = 4919
flag = bytearray()
for i in range(32):
    k = ord(key_str[i])
    t2 = k + 128
    for c in range(256):
        t1 = c + 6
        xor_val = t1 ^ t2
        new_state = (state - 128) + xor_val
        if (new_state & 0xFF) == target_bytes[i]:
            flag.append(c)
            state = new_state
            break
print("Flag:", flag.decode('ascii', errors='replace'))

f020ca964a958907e1d6ac87855e2b76_MD5

# rev/tosu-1

d705f49dd6a7b58929953a8889df30aa_MD5

tosu.exe chal1.map 运行
9b64c4ef4f3b19acbde190422165f630_MD5
一个 note 都不打,最后显示 1080 个 miss

IDA 打开,查看字符串
3caefa00cb71066034cc8a6a11a1a805_MD5

定位到函数 sub_7FF73FBF58D0() ,大概查看代码应该是游戏主要逻辑
ecf3d36446143f712cad312338959109_MD5

最后结算成绩显示部分,根据 v53 的值输出不同内容

追踪值的生成流程,根据 AI 的分析有两个关键函数 sub_7FF73FBF17A0sub_7FF73FC3A870
8c8a14d841df9239619382eca1d5a18a_MD5
4d8066fe39e9c6a607152ae42e840a03_MD5

sub_7FF73FBF17A0 是一个自定义的 Hash 函数,输出的 Hash 值作为密钥传入 sub_7FF73FC3A870 进行 AES 解密处理
因此需要游戏达到某个条件(应该是全打 300),才会输出正确的 Hash,进而正确解密 flag 数据

观察 Hash 函数 sub_7FF73FBF17A0 ,其中的第三个参数 Block 在代码中多次出现,分析可知这个变量存储了每个 note 的判定结果,所以可以考虑修改程序逻辑,将 miss 等非 300 的判定修改为 300

以下是判断 miss 的逻辑, v3 为某次输入的时间,当 v3 > noteTime + 0.2 判定为 miss
其中 v6 就是 Blockv8 是偏移量,因此 miss 对应的值为 4
b5d35e9b7a302e19cbd9b9f5f0537ae5_MD5

往下查看当非 miss 时的分数计算逻辑,可以分析出 300 对应的内存值为 3
a6384131f0ab46ceaa7ea798c716b545_MD5

直接 patch miss 的判定逻辑,将内存赋值统一修改为 3
同时 nop 掉 0.2 秒的时间判定,可以直接结束游戏,不用等待谱面播放完
3a07da836a4b2aa62c9904dda1252ddc_MD5
2490ac97e192f6ac7b1757f665ff31ef_MD5

d4a5d23fa6ef4e1adbb7a12954819edc_MD5

运行后虽然左上角显示为 miss,但内存中的数据是判定为 300,最终成功输出 flag
1e248e763bca45200bf735ab52f56747_MD5