Preliminary
As far as I’m concerned crypto involves a bunch of maths. After painstakingly but successfully solving 3 out of the 8 challenges in this category in time (which is low as hell), I can imagine the amount of maths one has to know in order to solve many of these.
I’ll attempt to go through all 8 challenges, in detail.
The Challenges
crypto/rot727
- Author
- wwm
- Category
-
crypto - Flag
-
osu{oh_wait_bigger_number_doesnt_mean_more_secure} - Solved in time?
-
yes
i rotated my flag 727 times! that’s super secure right
aeg{at_imuf_nussqd_zgynqd_paqezf_yqmz_yadq_eqogdq}
That name reminds us of ROT13, a rotation cipher. We know every flag starts with osu{, so to make it quick, just put the string in CyberChef. Set amount to 14 and the challenge is solved.
crypto/beyond-wood
- Author
- wwm
- Category
-
crypto - Files
- crypto_beyond-wood.tar.gz
- Flag
-
osu{h1_05u_d351gn_t34m} - Solved in time?
-
no
spinning white floating glowing man in forest
First of all, I completely forgot about this challenge while solving one of the later ones. But the most embarrasing part of all is I forgot how to solve this one during the contest.
Anyway, back to the solve. The archive contains an altered image named output.png and an encryptor written in Python. Our challange is to recover flag.png.
from PIL import Imageimport 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")Let’s make some observations.
This encryptor has two layers to it:
- The first being an
XORcolor layer, per-pixel, with a tiny . Only the first bytes of matter where for RGB/RGBA (due to ). - The other being a per-axis affine map, given is width, is height, with:
Talk about the basics of XOR, let . Then , because XOR is its own inverse.
So, for the dominant plaintext color and dominant ciphertext color :
If the original background was black, , so . Extend with the alpha byte if the image was RGBA.
Then about the inversion of an affine permutation. We invert
where is the modular inverse of modulo . This exists if . Here , so as long as and , both axes are invertible.
From there we have a solve strategy to reverse what was done. For this whole segment, since we don’t see any opaque property of the encrypted image, let’s assume alpha is not present.
We can use frequency to recover the XOR key: Compute the mode color of the ciphertext RGB histogram, . Finding is easily done with Python.
Then we try inverting the shuffle. Compute
Write at . The challenge is solved.
Here’s my solve script:
from PIL import Imagefrom collections import Counter
enc_i = Image.open("output.png")width, height = enc_i.size
px = list(enc_i.getdata())
colors = Counter(px)xor_key = colors.most_common(1)[0][0]
dec_img = Image.new('RGB', (width, height))
inv_w = pow(727, -1, width)inv_h = pow(727, -1, height)
for i in range(width): for j in range(height): enc_px = enc_i.getpixel((i, j))
dec_px = tuple(p ^ k for p, k in zip(enc_px, xor_key))
org_i = ((i - 2134266) * inv_w) % width org_j = ((j - 4501511) * inv_h) % height
dec_img.putpixel((org_i, org_j), dec_px)
dec_img.save("flag_decrypted.png")We get a pretty funny image with the flag handwritten in it.
crypto/xnor-xnor-xnor
- Author
- wwm
- Category
-
crypto - Files
- crypto_xnor-xnor-xnor.tar.gz
- Flag
-
osu{b3l0v3d_3xclus1v3_my_b3l0v3d} - Solved in time?
-
yes
Looks like something about XNOR. There is a single Python script in the challenge archive.
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())# 7e5fa0f2731fb9b9671fb1d62254b6e5645fe4ff2273b8f04e4ee6e5215ae6ed6cXNOR is bitwise NOT of XOR: xnor(x,k) = ~(x ^ k). The two very useful identities we’ll be using:
- When we check the truth table, we can see
xnor(x,k)is also~(x) ^ k. - Applying the
NOTand theXORrule gives us some interesting result:
~((~x) ^ k) = x ^ kNow apply twice, we get:
xnor(xnor(x,k), k)= ~(~(x ^ k) ^ k)= x (by identity)So two XNORs just cancel each other. The challenge Python code used three, so that means it’s just a single XNOR:
xnor(xnor(xnor(x,k), k), k) = xnor(x,k)Therefore the encryption is just enc = ~(flag ^ key), and from that, we get key = (~enc) ^ flag. Furthermore, the challenge script repeats some 4-byte block across the message. We know the first four plaintext bytes, that’s just osu{ established by the rules of this contest.
Recover the flag by building a key stream flag = (~enc) ^ key_stream. The challenge is solved.
Here’s my solve script:
enc_hex = "7e5fa0f2731fb9b9671fb1d62254b6e5645fe4ff2273b8f04e4ee6e5215ae6ed6c"enc = bytes.fromhex(enc_hex)
def xnor_byte(a,b): return (~(a ^ b)) & 0xFF
prefix = b"osu{"k4 = bytes(xnor_byte(p,e) for p,e in zip(prefix, enc[:4]))
ks = k4 * ((len(enc)//4) + 1)flag = bytes(xnor_byte(e,k) for e,k in zip(enc, ks))print(flag.decode())crypto/pls-nominate
- Author
- wwm
- Category
-
crypto - Files
- crypto_pls-nominate.tar.gz
- Flag
-
osu{pr3tty_pl3453_w1th_4_ch3rry_0n_t0p!?:pleading:} - Solved in time?
-
no
pls help me get the attention of bns by spamming this message to them pls pls pls
It’s getting a little difficult, I gave up on this at the very beginning. After trying to solve this, I completely understand why I would never solve this in the given timeframe.
The challenge archive includes two files, a plaintext file containing the results of a Python script file.
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 = 5print(len(FLAG))print(ns)print([pow(message, e, n) for n in ns])The text file is the output of this script, as expected. Now let’s make a couple of observations, we’re given a script that does these things:
- It creates a plaintext
msg = prefix || FLAG,FLAGis obviously unknown andprefixis, - It then generates 5
RSAmoduli , with each constructed bygetPrime(727) * getPrime(727), - It uses a small public exponent ,
- Finally it prints the 5 moduli and the 5 ciphertexts
Our challenge is to reverse what this does to our flag. After doing some bits of research, I found out about the RSA low-exponent setup, which is supposed to be a classic: If the same plaintext is encrypted under different moduli with the same small exponent and no padding, and if
then the Chinese Remainder Theorem lets us compute the integer
and if actually as integers, taking the integer -th root of recovers exactly.
That was a mouthful, but that is Håstad’s broadcast attack in its simplest form. In our case and we have exactly 5 ciphertexts, this is intended, but there is this one case where we’re bust: if , the Chinese Remainder Theorem will only give us , and the plain integer root may not exist.
Not the end of the world though. If that’s actually the case, note that there must exist a positive integer such that:
We can probably try small values and check if is a perfect -th power. Basically saying we’re relying on luck and the kindness of the challenge author here, lol.
Practically this might work. In theory, could be a really big number because
and if is several times bigger then , the ratio is stupid huge. To be brutally honest if was something of the worst case I wouldn’t know how do I approach this challenge.
Here’s my solve script. That actually succeeded.
# paste ns and cts in, there's line wrap here and they're massive# qwqns = []cts = []e = 5
def integer_nth_root(x: int, n: int): if x < 0: raise ValueError if x == 0: return 0, True lo, hi = 0, 1 << ((x.bit_length() + n - 1) // n) while lo <= hi: mid = (lo + hi) // 2 p = mid ** n if p == x: return mid, True if p < x: lo = mid + 1 else: hi = mid - 1 return hi, (hi ** n == x)
def crt_combine(ns, cts): N = 1 for ni in ns: N *= ni total = 0 for ni, ci in zip(ns, cts): Ni = N // ni inv = pow(Ni, -1, ni) total += ci * Ni * inv total %= N return total, N
def main(): M, N = crt_combine(ns, cts) root, exact = integer_nth_root(M, e) if exact: m = root else: # brute-force small k; tuned to reasonable limit for this CTF instance max_k = 1 << 20 m = None for k in range(max_k): y = M + k * N root, exact = integer_nth_root(y, e) if exact: m = root break if m is None: raise SystemExit(1) msg = m.to_bytes((m.bit_length() + 7) // 8, 'big').lstrip(b'\x00') 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: " if not msg.startswith(prefix): # fallback: try stripping leading zeros once more, then check msg = msg.lstrip(b'\x00') if not msg.startswith(prefix): raise SystemExit(1) flag = msg[len(prefix):] try: print(flag.decode()) except Exception: print(flag.hex())
if __name__ == "__main__": main()That map was qualified a couple of hours after this challenge airs. :steamhappy: indeed.
crypto/linear-feedback
- Author
- wwm
- Category
-
crypto - Files
- crypto_linear-feedback.tar.gz
- Flag
-
osu{th1s_hr1_i5_th3_m0st_fun_m4p_3v3r_1n_0wc} - Solved in time?
-
no
this owc map is so fire btw :steamhappy: https://osu.ppy.sh/beatmapsets/2451798#osu/5355997
There are several things I won’t mention again in this writeup since they’ve been mentioned (and being the good reader you are, you’ve made it halfway), but when I think the concepts should be mentioned again I will.
I also gave up at this challenge from the very beginning, mainly because I was always scared of Linear Feedback Shift Registers (LFSR). That was a nightmare for me during the times I tried to solve other CTFs alone and this concept appeared more than I initially anticipated (be reminded I am a linguistics major, LOL).
But anyway. I’ll simplify the script used in the challenge archive:
# Two LFSRskey1 = 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}")
# Output 72 bits: XNOR of the two LFSR output bits at each clockbits = [xnor_gate(L1._clock(), L2._clock()) for _ in range(floor(72.7))] # 72 bitsprint(bits)
# Flag is XOR'd with SHA-256(str(key1)+str(key2)) repeatedkeystream = sha256((str(key1) + str(key2)).encode()).digest() * 2print(xor(FLAG, keystream).hex())This is the instance of that script we’re given to work with:
bits =[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]
cipherhex =9f7f799ec2fb64e743d8ed06ca6be98e24724c9ca48e21013c8baefe83b5a304af3f7ad6c4cc64fa4380e854e8Let’s make some observations.
The LFSR class is MSB-first and it outputs the initial state directly:
ob = self.state[0] # first bit of current stateself.state = self.state[1:] + [ ... ] # shift left, append feedbackThat state is created through list("{:0wb}".format(key)). Because the output is state[0] before shifting, the first n outputs of an n-bit LFSR are… the n bits of the initial state. In order as well. That means if we know the first 29 outputs of L2, we know key2.
The taps. They are sum’d mod 2, with dupes: Feedback is sum(state[t] for t in taps) % 2, and duplicates matter only modulo 2. The effective L2 tap from its original [5, 3, 5, 5, 9, 9, 7] is just {3, 5, 7}.
Then we know this, because of XNOR:
So, guess key1 to generate 72 output bits of L1. If we can do that, we can deterministically derive 72 such bits of L2 as well, then use that to derive key2.
From there we have our solve strategy. First, we try to brute-force key1 since it’s small enough (just around 2m tries, that’s fine), then for each key1:
- Simulate L1 for 72 clocks
- Deduce the wanted as above
- Take the first 29 bits of that L2 as its initial state, then find
key2 - Verify by simulating L2 to see if it produces what we deduced
- Compute the keystream like the challenge did, then decrypt the flag.
The challenge is solved. Here’s my solve script:
from hashlib import sha256
# pls supply them like above qwqobs = []cipher = bytes.fromhex()
L1_n, L2_n = 21, 29L1_taps = [2,4,5,1,7,9,8]L2_taps = [5,3,5,5,9,9,7]fmt1 = "{:021b}"fmt2 = "{:029b}"need = len(obs)
def step_bits(state, taps): # output is state[0] # feedback is sum(state[t]) % 2 ob = state[0] fb = 0 for t in taps: fb ^= state[t] # xor is fine (sum mod 2) return ob, state[1:] + [fb]
def run_lfsr_bits(key, fmt, taps, outlen): s = list(map(int, fmt.format(key))) out = [] for _ in range(outlen): ob, s = step_bits(s, taps) out.append(ob) return out
def bits_to_int_msb(bits): v = 0 for b in bits: v = (v<<1) | b return v
def solve(): for k1 in range(1<<L1_n): l1 = run_lfsr_bits(k1, fmt1, L1_taps, need) # deduce L2 output stream l2_wanted = [(b if x==1 else 1-b) for b,x in zip(l1, obs)] # recover key2 k2 = bits_to_int_msb(l2_wanted[:L2_n]) # verify that l2 = run_lfsr_bits(k2, fmt2, L2_taps, need) if l2 != l2_wanted: continue # build keystream like chall if found ks = sha256((str(k1) + str(k2)).encode()).digest() * 2 flag = bytes(c ^ k for c, k in zip(cipher, ks)) try: txt = flag.decode() except: txt = None if txt and ("{" in txt and "}" in txt): print(txt) return
if __name__ == "__main__": solve()The brute takes quite some time. For simplicity my solution is using a list, which makes it take a while, I recommend you to look into int-based LFSR! That’d make it seconds.
Also, I’ve read one other writeup of this same problem that uses Gaussian elimination, which seems to be able to get the exact initial states in a single shot, not brute-forcing like this. I think that solution is wonderful, but it has no explanation to it, which is quite sad.
Anyway, :steamhappy:, I like that song as well, being a taiko player.
crypto/ssss
- Author
- wwm
- Category
-
crypto - Files
- crypto_ssss.tar.gz
- Flag
-
osu{0n3_hundr3d_p3rc3nt_4ccur4cy!} - Solved in time?
-
yes
can you ss this secret sharing scheme?
nc ssss.challs.sekai.team 1337
So, this one. Being the third least solved challenge at the time I was about to submit the flag (it was around 50 solves, which is abysmally low compared to the actual challenges later on), I was wondering what was wrong.
The challenge archive, outside of that netcat server we’re supposed to get things, has a single script file. I’ll once again truncate it:
p = 2**255 - 19k = 15SECRET = random.randrange(0, p)
def lcg(x, a, b, p): return (a*x + b) % p
a = randrange(0,p)b = randrange(0,p)poly = [SECRET]while len(poly) != k: poly.append(lcg(poly[-1], a, b, p))
for _ in range(k - 1): x = int(input()) print(sum(c * x^i (mod p))) # that's not xor
if int(input("secret? ")) == SECRET: print(FLAG)Checking the server’s behavior, it allows us to query values of
where the unknown coeff. vector isn’t arbitrary, but satisfies the LCG relation
with unknown . SECRET is .
We can draw a rough solve plan like this:
- Obviously try to probe at to get 14 equations where is a matrix. Interestingly this is a Vandemonde(-ish) matrix as well.
- Then we solve the linear system modulo , as in a particular solution plus a 1-dimensional nullspace spanned by (rank = 14, generically):
- Impose the LCG structure to eliminate and , then derive a quadratic in . We’ll solve that in .
- For each , try to reconstruct , find and check if both conditions hold: that LCG holds for all indices and it matches the oracle evals.
Oh but where does the quadratic come from, though?
Check this derivation out: from the linear solve, for each , we have
Define first differences:
Let and , is affine in . Call that (1).
From the LCG,
When we plug back in, we get:
Therefore, whenever ,
Consistency over two distinct indices forces the ratios to match:
Now we plug (1) back in, cross-multiply, we get:
That’s a quadratic with term .
with , and .
We solve this in and get two candidates. Now that’s why this challenge is named like that, QuadS.
For each candidate:
- Build
- Pick any with and set
- Try to find
- Finally, verify if for all , and that evaluates to what we observed.
That’s our = SECRET. The challenge is solved.
Here’s my solve script. Note that this script does not do the nc part itself, just so I can present the math concepts without the noise:
p = 2**255 - 19k = 15
# paste your 14 oracle outputs (f(1)..f(14)) here qwqys = []
def inv(x): # mod inverse return pow(x % p, p-2, p)
def ts_sqrt(a): # Tonelli–Shanks sqrt mod p a %= p if a == 0: return 0 if pow(a, (p-1)//2, p) != 1: return None q, s = p-1, 0 while q % 2 == 0: q //= 2; s += 1 z = 2 while pow(z, (p-1)//2, p) != p-1: z += 1 m, c = s, pow(z, q, p) t, r = pow(a, q, p), pow(a, (q+1)//2, p) while t != 1: i, t2i = 1, pow(t, 2, p) while t2i != 1: i += 1 t2i = pow(t2i, 2, p) b = pow(c, 1 << (m-i-1), p) m, c = i, (b*b) % p t, r = (t*c) % p, (r*b) % p return r
def solve_quadratic(a, b, c): # ax^2+bx+c=0 over F_p a %= p; b %= p; c %= p if a == 0: return [] if b == 0 and c != 0 else ([(-c*inv(b))%p] if b else list()) inv2 = inv(2) disc = (b*b - 4*a*c) % p s = ts_sqrt(disc) if s is None: return [] x1 = ((-b + s) * inv(2*a)) % p x2 = ((-b - s) * inv(2*a)) % p return [x1] if x1 == x2 else [x1, x2]
def gauss_mod(A, b): """Solve A*c=b (mod p) with A: m x n (here 14x15). Returns particular solution 'part' and a single null vector 'null'.""" m, n = len(A), len(A[0]) M = [A[i][:] + [b[i]%p] for i in range(m)] row, piv = 0, [] for col in range(n): if row == m: break sel = next((r for r in range(row, m) if M[r][col] % p), None) if sel is None: continue M[row], M[sel] = M[sel], M[row] iv = inv(M[row][col]) for c in range(col, n+1): M[row][c] = (M[row][c]*iv) % p for r in range(m): if r != row and M[r][col] % p: f = M[r][col] for c in range(col, n+1): M[r][c] = (M[r][c] - f*M[row][c]) % p piv.append(col); row += 1 part = [0]*n for r, col in enumerate(piv): part[col] = M[r][n] free = [c for c in range(n) if c not in piv] assert len(free) == 1, "expected nullspace dim 1" f = free[0] null = [0]*n; null[f] = 1 for r, col in enumerate(piv): null[col] = (-M[r][f]) % p return part, null
def recover_secret(xs, ys): # build 14x15 Vandermonde-like matrix for x^0..x^14 A = [] for x in xs: row, powx = [], 1 for _ in range(k): row.append(powx) powx = (powx * x) % p A.append(row) part, null = gauss_mod(A, ys)
# first-difference arrays C_i, D_i for i=0..12 C = [(part[i+1]-part[i]) % p for i in range(k-1)] D = [(null[i+1]-null[i]) % p for i in range(k-1)]
# build one quadratic in t using indices i and j=i+1 # a t^2 + b t + g = 0 for i in range(k-3): j = i+1 alpha = (D[i+1]*D[j] - D[j+1]*D[i]) % p beta = (C[i+1]*D[j] + D[i+1]*C[j] - C[j+1]*D[i] - D[j+1]*C[i]) % p gamma = (C[i+1]*C[j] - C[j+1]*C[i]) % p sols = solve_quadratic(alpha, beta, gamma) for t in sols: c = [(part[u] + t*null[u]) % p for u in range(k)] # derive a,b from first nonzero diff a = None for u in range(k-2): den = (c[u+1]-c[u]) % p if den: a = ((c[u+2]-c[u+1]) * inv(den)) % p; break if a is None: continue b = (c[1] - a*c[0]) % p # verify LCG if all((c[u+1] - (a*c[u]+b)) % p == 0 for u in range(k-1)): # verify evals ok = True for x,y in zip(xs, ys): val, powx = 0, 1 for u in range(k): val = (val + c[u]*powx) % p powx = (powx * x) % p if val != y % p: ok = False; break if ok: return c[0] # SECRET raise SystemExit("no solution found; check inputs")
def main(): if len(ys) != k-1: raise SystemExit("need 14 values in ys") xs = list(range(1, k)) # 1..14 secret = recover_secret(xs, [y % p for y in ys]) print(secret)
if __name__ == "__main__": main()Yeah this is hard. Not very :steamhappy: this time, but we got 100% accuracy, fast.
crypto/please-nominate
- Author
- wwm
- Category
-
crypto - Files
- crypto_please-nominate.tar.gz
- Flag
-
osu{0mg_my_m4p_f1n4lly_g0t_r4nk3d_1m_s0_h4ppy!!} - Solved in time?
-
no
ok this time i’m going to be a bit more nice and personal when sending my message
This challenge is one of the two 3/5s difficulty-wise, denoted by the challenge author.
The moment I see 3/5 after bugging myself at the previous challenge, which was denoted as a 2/5, prompts me immediately this has got something to do with Sage. I gave up at that point, lol.
After doing some research again since I was not equipped with the sufficient knowledge to solve the easier variant of this challenge, crypto/pls-nominate, this seems like it was another Håstad-style small-e broadcast attack with different known prefixes.
We can just see the challenge like this:
- Every ciphertext is a cube,
- Every plaintext is
known_prefix || FLAG, - Every
FLAGhas the same length for all three messages, - There are three coprime moduli ,
- Recover the small unknown suffix
So the entire challenge is basically a single cubic congruence modulo with a small root, then we run a univariate Coppersmith to pull that root out.
Let be the number of FLAG bytes. For each BN in ["Plus4j", "Mattay", "Dailycore"], define its prefix string “hi there {BN}, ”, then turn these prefixes into integers:
with a bytes_to_long function.
Why the though? Now, that’s because concatenation in base-256 is
Let the FLAG, as an integer, be , so each message is
Since the challenge uses , each ciphertext is
with pairwise coprime , each a product of two 727-bit primes.
Now we turn the three congruences into one cubic modulo . Expand and move to the left, we get:
For each coeff. position we know its residue mod . Then, we build the global coeffs. modulo using the Chinese Remainder Theorem coeff. wise:
- ,
- ,
- .
We get integers with
Any that solves the three original congruences will satisfy that.
Our last work is to just briefly check over the small-root condition. Bitlengths:
- Each is 1454 bits
- bits.
- .
The FLAG has bytes, so , we’re good.
Finally, recover with univariate Coppersmith. The challenge is solved.
Here’s my solve script:
from sage.all import *from Crypto.Util.number import bytes_to_long, long_to_bytes
L = 147
# paste them in qwqdata = [ ("Plus4j", Integer(), Integer()), ("Mattay", Integer(), Integer()), ("Dailycore", Integer(), Integer()),]
N = Integer(1)for _, n, _ in data: N *= n
P.<x> = PolynomialRing(Zmod(N))f = 0const = 0shift = 2**(8*L) # 256^L
# cubic via CRT weighting (same thing as coeff-wise CRT)for name, n, c in data: A = Integer(bytes_to_long(f"hi there {name}, ".encode())) * shift w = N // n f += w * (x**3 + 3*A*x**2 + 3*(A*A % n)*x) const += w * (c - pow(A, 3, n))
f = (f - const).monic()
# find small root x < 256^L (the flag as integer)for i in range(1, 101): roots = f.small_roots(X=shift, beta=i/100) if roots: print(long_to_bytes(int(roots[0]))) breakcrypto/ssssp
- Author
- wwm
- Category
-
crypto - Files
- crypto_ssss+.tar.gz
- Flag
-
osu{0r_d1d_y0u_us3_fl45hl1ght_1nst34d?} - Solved in time?
-
no
can you do it again, but with hidden?
nc ssssp.challs.sekai.team 1337
The following challenge is rated 3/5 by the challenge author.
Now, I’ve seen some, supposedly, 4/5, crypto challenges. I obviously can solve none of them, and I know for sure two days aren’t going to cut it for those challenges for many, many teams. The general idea around these very difficult challenges is we get away with several tricks in our sleeves, this is no exception to that.
By the time I’m writing this (October 29), I haven’t seen any writeup for this challenge posted. I can’t fact-check my maths and tricks, given this is the hardest crypto challenge of this contest, some of these might convey the wrong idea.
Now back to the challenge. The only script file in the challenge archive looks awfully similar to QuadS, but this time we have to deal with a hidden modulus .
Summarizing the challenge, we once again need to find , our SECRET. The server:
- Has this evaluation field
- Has this hidden modulus that’s a random 256-bit prime
- Has the coeffs. , , generated by an LCG modulo :
- Has an oracle that returns for our chosen .
Here comes the tricks.
Isolating the odd coeffs. mod p with symmetry
Query in pairs . Modulo , we have , then:
Define
Choose 7 distinct , then with we get a deg-6 polynomial in with coeffs. . Build the Vandermonde again on , then solve:
Solving gives us the residues modulo :
Lift each odd coeff.
Each integer representative is either the residue or , since the real coeffs. live modulo but we only know them modulo , and is close to . Also, it can’t be since , which exceeds the range . So, for each odd index we introduce something :
That gives us a 7-term subsequence of the true integer odd coeffs.
The odd subsequence is an LCG!
Alright, this is going to be wild. From the original LCG modulo ,
What if we try skipping one step?
So, also satisfies an LCG!
Now we can run standard LCG modulus-recovery on .
Recover the
Let’s look at an LCG . We can see the quantity
is always a multiple of M, therefore just reveals . Well, up to a unit that is.
So in our code, we just do this to recover :
- Enumerate the masks ,
- Build ,
- Compute ,
- Stop when is a large prime.
Reveal the secret
Now we know . The last thing we have to do is work in , our goal is to recover and , then , finally, the SECRET.
From any three consecutive terms ,
Since , try take a square root in : .
Solving for shouldn’t be hard.
Finally, use , and here . So,
The challenge should be solved. Here’s my solve script.
from math import gcd
p = 2**255 - 19k = 15
# paste your pairs here qwqy_pairs = []
def inv_mod(a, m): a %= m if a == 0: raise ZeroDivisionError("inv_mod(0)") return pow(a, m - 2, m)
# Tonelli–Shanks for odd prime modulidef mod_sqrt(a, prime): a %= prime if a == 0: return 0 if pow(a, (prime - 1) // 2, prime) != 1: return None # factor p-1 = q * 2^s with q odd q, s = prime - 1, 0 while q % 2 == 0: q //= 2 s += 1 # find z a quadratic non-residue z = 2 while pow(z, (prime - 1) // 2, prime) != prime - 1: z += 1 c = pow(z, q, prime) r = pow(a, (q + 1) // 2, prime) t = pow(a, q, prime) m = s while t != 1: # find least i in [1..m-1] with t^(2^i) = 1 i, t2i = 1, pow(t, 2, prime) while t2i != 1: i += 1 t2i = pow(t2i, 2, prime) b = pow(c, 1 << (m - i - 1), prime) r = (r * b) % prime c = (b * b) % prime t = (t * c) % prime m = i return r
def is_probable_prime(n): if n < 2: return False small_primes = [2,3,5,7,11,13,17,19,23,29,31] for sp in small_primes: if n % sp == 0: return n == sp # write n-1 = d * 2^s d, s = n - 1, 0 while d % 2 == 0: d //= 2; s += 1 # bases good for ~512 bits for a in [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37]: if a % n == 0: continue x = pow(a, d, n) if x == 1 or x == n - 1: continue skip = False for _ in range(s - 1): x = (x * x) % n if x == n - 1: skip = True break if not skip: return False return True
# solve 7x7 linear system over F_p to recover c_odd residuesdef solve_odd_coeffs_mod_p(y_pairs): if len(y_pairs) != 7: raise SystemExit("need exactly 7 pairs qwq") O = [] svals = [] inv2 = inv_mod(2, p) for i, (fy, fneg) in enumerate(y_pairs, start=1): num = (fy - fneg) % p den = (2 * i) % p oi = (num * inv_mod(den, p)) % p # O(i) = (f(i)-f(-i)) / (2i) O.append(oi) svals.append((i * i) % p) # s = i^2 # build Vandermonde M[j][t] = s_j^t for t=0..6 M = [[1] * 7 for _ in range(7)] for r in range(7): for c in range(1, 7): M[r][c] = (M[r][c-1] * svals[r]) % p # gaussian elimination mod p: solve M * x = O for r in range(7): M[r].append(O[r] % p) # eliminate row = 0 for col in range(7): # find pivot piv = None for r in range(row, 7): if M[r][col] % p != 0: piv = r; break if piv is None: continue M[row], M[piv] = M[piv], M[row] invp = inv_mod(M[row][col], p) for c in range(col, 8): M[row][c] = (M[row][c] * invp) % p for r in range(7): if r != row and M[r][col] % p != 0: f = M[r][col] for c in range(col, 8): M[r][c] = (M[r][c] - f * M[row][c]) % p row += 1 x = [0]*7 # rows are now [I | x] for r in range(7): lead = None for c in range(7): if M[r][c] == 1: lead = c; break if lead is not None: x[lead] = M[r][7] # x = [c1, c3, c5, c7, c9, c11, c13] mod p return x # residues modulo p
def recover_q_from_odd_lifts(rmods): # rmods: [r1, r3, r5, r7, r9, r11, r13] residues mod p # each actual coefficient Y_j ∈ {rmods[j], rmods[j]+p} best = None for mask in range(1 << 7): Y = [(rmods[j] + (((mask >> j) & 1) * p)) for j in range(7)] # integers # GCD trick: Q = gcd(d_{i+1}^2 - d_i d_{i+2}) d = [Y[i+1] - Y[i] for i in range(6)] g = 0 for i in range(4): # enough samples: i=0..3 uses d0..d5 gi = d[i+1]*d[i+1] - d[i]*d[i+2] gi = abs(gi) g = gi if g == 0 else gcd(g, gi) if g.bit_length() < 200: # quick reject small gcds continue # try to factor as a prime (likely pp) if is_probable_prime(g): best = (g, Y) break # sometimes g is a multiple; try to strip small factors tmp = g for small in [3,5,7,11,13,17,19,23,29,31]: while tmp % small == 0: tmp //= small if tmp.bit_length() >= 200 and is_probable_prime(tmp): best = (tmp, Y) break if not best: raise SystemExit("failed to recover q") return best # (q, Y)
def try_params_from_odd(Y, q): den = (Y[1] - Y[0]) % q if den == 0: # shift one step if denominator zero den = (Y[2] - Y[1]) % q if den == 0: return None A = ((Y[3] - Y[2]) * pow(den, -1, q)) % q C = (Y[2] - A * Y[1]) % q y0 = Y[1] else: A = ((Y[2] - Y[1]) * pow(den, -1, q)) % q C = (Y[1] - A * Y[0]) % q y0 = Y[0] # a = sqrt(A) in F_q -> two candidates ±a a_root = mod_sqrt(A, q) if a_root is None: return None for a in {a_root, (-a_root) % q}: if (a + 1) % q == 0: continue b = (C * pow(a + 1, -1, q)) % q if a % q == 0: continue # S = c0 = (c1 - b) / a ; here c1 = Y[0] secret = ((Y[0] - b) * pow(a, -1, q)) % q return int(secret) return None
def main(): if len(y_pairs) != 7: raise SystemExit("fill y_pairs with 7 tuples pls") # solve for odd coeffs mod p rmods = solve_odd_coeffs_mod_p(y_pairs) # length 7: c1,c3,...,c13 mod p # lift and recover q q, Y = recover_q_from_odd_lifts(rmods) # recover a,b (via A=a^2, C=b(a+1)) and SECRET secret = try_params_from_odd(Y, q) if secret is None: raise SystemExit(1) if secret >= p: secret %= p print(secret)
if __name__ == "__main__": main()Now I think there’s probably multiple way, way shorter solves and many way simpler solves of this one. However I couldn’t think of a way that could get around the usage of the tricks I used, math dalaos please help…
At least, :steamhappy:.
That’s every crypto challenge solved and explained in detail. Well, in my ability that is. In my mother tongue potato has an inside meaning that roughly translates to "that's tough as hell", so cryp-otato.