crypto
Challenges

Scr4mbl3d

I like to confuse people. (Or I'm the one confused?)
❯ unzip -l scr4mbl3d.zip
Archive: scr4mbl3d.zip
Length Date Time Name
--------- ---------- ----- ----
130 1980-00-00 00:00 scr4mbl3d/out.txt
1316 1980-00-00 00:00 scr4mbl3d/source.py
--------- -------
1446 2 files
A custom block cipher tries to “look hard” by mixing SHA-256 and odd arithmetic. Under the hood it’s a Feistel network over (mod a 256-bit prime). No secret key. Everything (P, F, constants, schedule) is fixed and known. A Feistel network without a key is just a reversible permutation you can run backward and recover the plaintext.
#!/usr/bin/env python3
import sys
from os import path
from random import randint
from hashlib import sha256
P = 112100829556962061444927618073086278041158621998950683631735636667566868795947
ROUNDS = randint(26, 53)
CONSTANT = [(44 * i ^ 3 + 98 * i ^ 2 + 172 * i + 491) % P for i in range(ROUNDS)]
EXPONENT = 3
def split(x):
chunk1 = x // P
chunk2 = x % P
return chunk1, chunk2
def merge(chunk1, chunk2):
return chunk1 * P + chunk2
def ff(x):
return ((x * EXPONENT) * 0x5DEECE66D) % P
def gg(x):
digest = sha256(int(x).to_bytes(256)).digest()
return int.from_bytes(digest) % P
def transform(x, y, i):
u = x
if i % 11 == 0:
v = (y + ff(u)) % P
else:
v = (y + gg(u)) % P
v = (v + CONSTANT[i]) % P
return v, u
def encrypt(input):
chunk1, chunk2 = split(input)
for i in range(ROUNDS):
if i % 5 == 0:
chunk1, chunk2 = transform(chunk1, chunk2, i)
else:
chunk2, chunk1 = transform(chunk2, chunk1, i)
output = merge(chunk1, chunk2)
return output
if __name__ == "__main__":
out_dir = sys.argv[1]
flag = sys.argv[2].encode()
input = int.from_bytes(flag)
ciphertext = encrypt(input)
with open(path.join(out_dir, "out.txt"), "w") as f:
f.write(hex(ciphertext))
0x1ba2580d8128ef4aee52dbc4ac7c2d13b75361feb86d5085b4116865fb7fa8b3f851320e2c47a0887ce471d77709507323ee78fa6895dcf8b7116360d5c89ea9
Where is the Flag? The flag is encrypted using a custom block cipher and stored as the ciphertext. The original flag was converted to bytes (1), then to an integer (2), encrypted with a randomized number of rounds (26-53) (3), and output as hex (4).


How do we get it? 🚩
Create decrypt functions that undo each encryption step —
Since rounds are random (26-53), try all possibilities (smoll enough,hehe)
Look for results that decode to readable text starting with "
snakectf{
"
from hashlib import sha256
P = 112100829556962061444927618073086278041158621998950683631735636667566868795947
CT = int("1ba2580d8128ef4aee52dbc4ac7c2d13b75361feb86d5085b4116865fb7fa8b3f851320e2c47a0887ce471d77709507323ee78fa6895dcf8b7116360d5c89ea9", 16)
def ff(x): # matches challenge: multiply by EXPONENT (3), not power
return (x * 3 * 0x5DEECE66D) % P
def gg(x):
# challenge hashed an int(x) padded to 256 bytes; keep that to match
return int.from_bytes(sha256(int(x).to_bytes(256, 'big')).digest(), 'big') % P
def consts(n):
# NOTE: ^ is XOR in Python, exactly as in the challenge
return [((44*i) ^ 3 + (98*i) ^ 2 + 172*i + 491) % P for i in range(n)]
def decrypt(ct, rounds):
L, R = ct // P, ct % P
C = consts(rounds)
for i in range(rounds - 1, -1, -1):
if i % 5 == 0:
# inverse of (L,R) -> (R + F(L) + C, L)
L, R = R, (L - (ff(R) if i % 11 == 0 else gg(R)) - C[i]) % P
else:
# inverse of (L,R) -> (R, L + F(R) + C)
b = L
a = (R - (ff(b) if i % 11 == 0 else gg(b)) - C[i]) % P
L, R = a, b
x = L * P + R
return x.to_bytes((x.bit_length() + 7) // 8, 'big')
def encrypt(pt, rounds): # for verification
x = int.from_bytes(pt, 'big')
L, R = x // P, x % P
C = consts(rounds)
for i in range(rounds):
if i % 5 == 0:
L, R = (R + (ff(L) if i % 11 == 0 else gg(L)) + C[i]) % P, L
else:
R, L = (L + (ff(R) if i % 11 == 0 else gg(R)) + C[i]) % P, R
return L * P + R
def solver():
for r in range(26, 54):
pt = decrypt(CT, r)
try:
s = pt.decode()
except UnicodeDecodeError:
continue
if "snakectf{" in s.lower():
print("ROUNDS =", r)
print(s)
assert encrypt(pt, r) == CT
return
print("no flag found in 26..53 (shouldn't happen)")
solver()
❯ python sol.py
ROUNDS = 29
snakeCTF{Ev3ry7hing_1s_34s13r_w1th_F3is7el_bb5dcc5e0a39ff0b}
snakeCTF{Ev3ry7hing_1s_34s13r_w1th_F3is7el_bb5dcc5e0a39ff0b}
Unf33dMe

Easy structure but the choice of parameters make it as strong as a Babylonian defense.
❯ unzip -l unf33dme.zip
Archive: unf33dme.zip
Length Date Time Name
--------- ---------- ----- ----
324 1980-00-00 00:00 unf33dme/out.txt
2430 1980-00-00 00:00 unf33dme/source.sage
--------- -------
2754 2 files
TL;DR: The “Unf33dMe
” hash is a simple per-word permutation where each 2-byte chunk of the flag is independently run through three cube-and-add steps with public constants, then the only cross-mixing is a final pairwise swap-and-add that couples words two at a time.
Because there’s no secret key and each word is only 16 bits, we can rebuild the public round constants (from SHAKE256("SNAKECTF")
), and for each swapped pair directly brute-force one word, compute the other from the equation, and check it—repeat for the 12 pairs, join candidates, PKCS#7-unpad and keep the printable ASCII that looks like a flag.
Doing this yields the flag: snakeCTF{p0lys_4r3_m4g1c_:)_16f95932140b4fbe}
.
#!/usr/bin/env sage
import sys
from os import path
from Crypto.Hash import SHAKE256
from Crypto.Util.Padding import pad
from Crypto.Util.number import bytes_to_long
class Babylon:
def __init__(self):
self.setParams()
self.genConstants()
def setParams(self):
self.exp = 3
self.p = 65537
self.nbytes = self.p.bit_length() // 8
self.F = GF(self.p)
self.state_size = 24
self.rounds = 3
def genConstants(self):
shake = SHAKE256.new()
shake.update(b"SNAKECTF")
self.constants = []
for _ in range(self.rounds):
self.constants.append(self.F(int.from_bytes(shake.read(self.nbytes), 'big')))
def decompose(self, message):
state = []
padded_message = pad(message, self.state_size * self.nbytes)
for i in range(0, len(padded_message), self.nbytes):
chunk = bytes_to_long(padded_message[i:i + self.nbytes])
state.append(chunk)
return state
def random(self):
return [self.F.random_element() for _ in range(self.state_size)]
def shuffle(self, state):
for i in range(0, self.state_size, 2):
t = state[i]
state[i] = state[i + 1]
state[i + 1] = t
return state
def add(self, state, constant):
return [state[i] + constant for i in range(self.state_size)]
def xor(self, a, b):
return [a[i] + b[i] for i in range(self.state_size)]
def sbox(self, state):
return [(state[i]) ^ self.exp for i in range(self.state_size)]
def round(self, state, r):
state = self.sbox(state)
state = self.add(state, self.constants[r])
return state
def permute(self, state, key):
state = self.xor(state, key)
for r in range(self.rounds):
state = self.round(state, r)
return state
def hash(self, message):
input = self.decompose(message)
IV = self.random()
output = self.permute(input, IV)
digest = self.xor(output, self.shuffle(input))
return digest, IV
if __name__ == "__main__":
out_dir = sys.argv[1]
flag = sys.argv[2].encode()
babylon = Babylon()
assert len(flag) < babylon.state_size * babylon.nbytes, len(flag)
digest, IV = babylon.hash(flag)
with open(path.join(out_dir, "out.txt"), "w") as f:
f.write(f"{digest}\n{IV}")
# out.txt
[18840, 31254, 7668, 2508, 9028, 40087, 13926, 49817, 59529, 5447, 24798, 59648, 28190, 37202, 43195, 21784, 16974, 37175, 48106, 46196, 32798, 40361, 1921, 65016]
[48670, 1962, 17694, 598, 34425, 54036, 25778, 3049, 56622, 58561, 62531, 49295, 3325, 5067, 25993, 56181, 46518, 23511, 8319, 62958, 40466, 57256, 2332, 40044]
Where is the flag? The archive’s out.txt
has two Python lists:
digest
— 24 field elements (mod p=65537)IV
— 24 field elements used as the per-word “key”.
Those two lists are all you need to reconstruct (the flag) thanks to how the hash is built.
from hashlib import shake_256
# --- params from source.sage ---
p = 65537
state_size = 24
rounds = 3
nbytes = 2
block_bytes = state_size * nbytes
# paste the two lists from out.txt
digest = [18840,31254,7668,2508,9028,40087,13926,49817,59529,5447,24798,59648,
28190,37202,43195,21784,16974,37175,48106,46196,32798,40361,1921,65016]
IV = [48670,1962,17694,598,34425,54036,25778,3049,56622,58561,62531,49295,
3325,5067,25993,56181,46518,23511,8319,62958,40466,57256,2332,40044]
# round constants (public)
buf = shake_256(b"SNAKECTF").digest(rounds * nbytes)
C = [int.from_bytes(buf[i*nbytes:(i+1)*nbytes], "big") % p for i in range(rounds)]
def f_v(x, v):
y = (x + v) % p
for c in C:
y = (pow(y, 3, p) + c) % p
return y
def pkcs7_unpad(b: bytes) -> bytes:
if not b: return b
pad = b[-1]
if 1 <= pad <= block_bytes and b.endswith(bytes([pad])*pad):
return b[:-pad]
return b
# solve each (even,odd) pair independently
pairs = []
for i in range(0, state_size, 2):
d0, d1 = digest[i], digest[i+1]
v0, v1 = IV[i], IV[i+1]
cand = []
for a in range(65536): # 16-bit word
b = (d0 - f_v(a, v0)) % p
if b < 65536 and (d1 - f_v(b, v1)) % p == a:
cand.append((a, b))
pairs.append(cand)
# combine candidates and filter
def words_to_bytes(ws):
return b"".join(w.to_bytes(2, "big") for w in ws)
flag = None
def dfs(k, acc):
global flag
if flag: return
if k == len(pairs):
msg = pkcs7_unpad(words_to_bytes(acc))
if (b"snakeCTF{" in msg and all(32 <= x <= 126 for x in msg) and msg.endswith(b"}")):
flag = msg.decode("ascii")
return
for a, b in pairs[k]:
dfs(k+1, acc + [a, b])
dfs(0, [])
print(flag)
❯ python hehe.py
snakeCTF{p0lys_4r3_m4g1c_:)_16f95932140b4fbe}
Free Start

It looks like it is impossible!
ncat --ssl freestart.challs.snakectf.org 1337
#!/usr/bin/env sage
from Anemoi_primitive.anemoi import *
from copy import deepcopy
import os
import signal
from random import randint
TIMEOUT = 200
FLAG = os.getenv("FLAG", "FLAG{this_is_a_fake_flag_for_testing_purposes}")
PRIME = 280989701
# SPONGE
class SPONGE:
def __init__(self, prime):
self.prime = prime
self.n_bits_per_block = len(bin(prime)[2:]) - 1
# Anemoi params
self.l = 1
self.alpha = 3
self.n_rounds = 21
def hash(self, message, initial_c):
assert initial_c < self.prime
# ABSORBING PHASE
state = [[0], [initial_c]]
for b in message:
state[0][0] = (state[0][0] + b) % self.prime
temp = deepcopy(state)
permutation = ANEMOI(self.prime, self.alpha, self.n_rounds, self.l)
state = permutation(temp[0], temp[1])
return state[0][0]
# SERVICE
TEST = 100
def main():
S = SPONGE(PRIME)
print("For each of the given messages, you must give me a collision. Let's start!")
# create message
print(
"The messages should be represented as a list of values comma separated such as 2, 3, 4, 5, 6"
)
while True:
print(
"""
MENU:
1) Play
2) Exit
"""
)
choice = input("> ")
if choice == "1":
passed_tests = 0
while passed_tests < TEST:
message = [randint(0, PRIME - 1) for _ in range(5)]
initial_capacity = randint(0, PRIME - 1)
print(f"The chosen message is: {message}")
print(f"The initial capacity is: {initial_capacity}")
hash_value = S.hash(message, initial_capacity)
print(f"The corresponding hash value is {hash_value}")
solvable = int(input("Is there solution? (0 means NO, 1 means YES): "))
if solvable == 0:
continue
elif solvable > 1:
break
input_message = list(
map(int, input("Give me your message: ").strip().split(","))
)
if len(input_message) < 2:
print("Your message must have at least two blocks")
break
if not all([v < PRIME for v in input_message]):
print("Your message must be composed of values in the field!")
break
if "|".join(map(str, input_message)) in "|".join(map(str, message)):
print("Your message cannot contains subsequences of my message!")
break
input_initial_capacity = int(input("Give me your initial capacity: "))
your_hash_value = S.hash(input_message, input_initial_capacity)
if your_hash_value != hash_value:
print("The hashes do not match!")
break
else:
print("Congratulations!")
passed_tests += 1
if passed_tests == TEST:
print(f"Congratulations! Here is the flag: {FLAG}")
elif choice == "2":
break
if __name__ == "__main__":
signal.alarm(TIMEOUT)
main()
tldr;
tldr; The service “hashes” messages with a 2-word sponge whose permutation is an invertible Anemoi round function (no secret key). Because the server lets us choose the initial capacity (free-start) and the permutation is fully public and invertible, we can pick any final (x,y) state with x = target hash, invert the permutation to the input of the last block, and use one extra message block to “steer” x to that input while keeping y fixed.
That yields a 2-block free-start collision for any target hash, avoiding any subsequence of the server’s message.
Where is da flag? 🚩— Will printed after passing 100 collision rounds:
if passed_tests == TEST:
print(f"Congratulations! Here is the flag: {FLAG}")
Our job is to
Repeatedly craft valid collisions that match the server’s hash output.
How? The server reveals, for each round:
the random message
message = [m0, m1, m2, m3, m4]
the random initial capacity
initial_capacity = c0
the resulting hash
h = S.hash(message, c0)
We must reply with (a different message, at least 2 blocks) + (our initial capacity) that hashes to the same h
, and that doesn’t contain any 2-element subsequence from the server’s message. Do that 100 times
The vulnerability?
# challenge.py (SPONGE.hash)
state = [[0], [initial_c]] # x = 0, y = initial capacity (l = 1)
for b in message:
state[0][0] = (state[0][0] + b) % PRIME # absorb *only* into x
permutation = ANEMOI(PRIME, 3, 21, 1) # public, fixed, invertible
state = permutation(temp[0], temp[1]) # state <- P(state)
return state[0][0] # output x only
Result is: Given any target hash h, we can choose the final y, invert the permutation once to obtain the input that must go into the last permutation call, and then use a 2-block message to hit that input exactly while obeying the server’s constraints.
import ast
from pwn import process, remote
from sage.all import GF, Integer
# import random
PRIME = 280_989_701
ALPHA = 3
N_ROUNDS = 21
class AnemoiL1:
def __init__(self, q, alpha, nrounds):
self.q = q
self.F = GF(q)
self.alpha = alpha
self.nrounds = nrounds
self.g = self.F.multiplicative_generator()
self.beta = self.g
self.delta = self.g ** (-1)
self.alpha_inv = pow(alpha, -1, q - 1) # prime field -> phi(q)=q-1
PI_0 = Integer("1415926535897932384626433832795028841971693993751058209749445923078164062862089986280348253421170679")
PI_1 = Integer("8214808651328230664709384460955058223172535940812848111745028410270193852110555964462294895493038196")
pi_F_0 = self.F(PI_0 % q)
pi_F_1 = self.F(PI_1 % q) # unused for l=1 but keep consistent
self.C, self.D = [], []
for r in range(nrounds + 1):
pow_alpha = (pi_F_0**r + self.F(1)) ** self.alpha
C_r = self.g * (pi_F_0**(2*r)) + pow_alpha
D_r = self.g * (self.F(1)) + pow_alpha + self.delta
self.C.append([C_r])
self.D.append([D_r])
def addC(self, x, y, r): return (x + self.C[r][0], y + self.D[r][0])
def addC_inv(self, x, y, r): return (x - self.C[r][0], y - self.D[r][0])
def lin(self, x, y): return (2*x + y, x + y)
def lin_inv(self, x, y): return (x - y, 2*y - x)
def sbox(self, x, y):
x = x - self.beta * (y**2)
y = y - x**self.alpha_inv
x = x + (self.beta * (y**2) + self.delta)
return (x, y)
def sbox_inv(self, x2, y1):
x1 = x2 - (self.beta * (y1**2) + self.delta)
y0 = y1 + (x1 ** self.alpha_inv)
x0 = x1 + self.beta * (y0**2)
return (x0, y0)
def P(self, x, y):
for r in range(self.nrounds):
x, y = self.addC(x, y, r)
x, y = self.lin(x, y)
x, y = self.sbox(x, y)
x, y = self.addC(x, y, self.nrounds)
x, y = self.lin(x, y)
return (x, y)
def P_inv(self, x, y):
x, y = self.lin_inv(x, y)
x, y = self.addC_inv(x, y, self.nrounds)
for r in range(self.nrounds - 1, -1, -1):
x, y = self.sbox_inv(x, y)
x, y = self.lin_inv(x, y)
x, y = self.addC_inv(x, y, r)
return (x, y)
def make_collision(target_hash, A: AnemoiL1):
F = A.F
p = int(F.characteristic())
x2 = F(target_hash)
y2 = F.random_element() # FIX: sample from field, not randrange(modulus)
x_in2, y_in2 = A.P_inv(x2, y2)
x1_prime = F.random_element() # FIX: same here
b2 = int((x_in2 - x1_prime)) % p # convert to int in [0,p)
b1, cap = A.P_inv(x1_prime, y_in2)
return [int(b1), b2], int(cap)
def solve():
A = AnemoiL1(PRIME, ALPHA, N_ROUNDS)
io = process(["sage", "-python", "challenge.py"]) # or: remote("freestart.challs.snakectf.org", 1337, ssl=True, sni="freestart.challs.snakectf.org")
io.recvuntil(b">")
io.sendline(b"1")
passed = 0
while passed < 100:
io.recvuntil(b"The chosen message is:")
M = ast.literal_eval(io.recvline().decode().strip())
io.recvuntil(b"The initial capacity is:")
_ = int(io.recvline().decode())
io.recvuntil(b"The corresponding hash value is")
h = int(io.recvline().decode())
io.recvuntil(b"Is there solution?")
io.sendline(b"1")
while True:
msg, cap = make_collision(h, A)
if not any(msg == M[i:i+2] for i in range(len(M)-1)):
break
io.recvuntil(b"Give me your message:")
io.sendline(f"{msg[0]},{msg[1]}".encode())
io.recvuntil(b"Give me your initial capacity:")
io.sendline(str(cap).encode())
line = io.recvline().decode()
if "Congratulations!" in line:
passed += 1
print(f"[+] round ok ({passed}/100)")
else:
raise SystemExit(f"server rejected our collision: {line.strip()}")
# get flag
for _ in range(8):
s = io.recvline(timeout=2) or b""
if b"Here is the flag" in s:
print(s.decode().strip())
break
solve()
snakeCTF{resultant_computation_can_help_a_lot_if_some_roots_are_easy_to_find_ac91ebf18ccc01d5}
Triple-Flavor (upsolve)

Why settle for just one mode of operation when you can have 3?
ncat --ssl triple-flavor.challs.snakectf.org 1337
❯ unzip -l triple-flavor.zip
Archive: triple-flavor.zip
Length Date Time Name
--------- ---------- ----- ----
1701 1969-12-31 16:00 server.py
--------- -------
1701 1 file
tbd-will write later (this is cool sh)
#!/usr/bin/env python3
from os import urandom, environ
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from hashlib import sha256
from string import ascii_lowercase, digits
from secrets import choice
import signal
FLAG = environ.get("FLAG", "snakeCTF{fakeflag}")
TIMEOUT = environ.get("TIMEOUT", 300)
SECRET_LEN = 15
key_len = SECRET_LEN // 3
secret_token = ''.join([choice(ascii_lowercase + digits) for _ in range(SECRET_LEN)])
seeds = [secret_token[i:i+key_len] for i in range(0, len(secret_token), key_len)]
keys = [sha256(seed.encode()).digest()[:16] for seed in seeds]
ivs = [urandom(16), urandom(16)]
def xor(a:bytes, b:bytes):
return bytes([x ^ y for x,y in zip(a, b)])
def encrypt(pt:bytes, keys:list, ivs:list) -> bytes:
cipher1 = AES.new(keys[0], AES.MODE_ECB)
cipher2 = AES.new(keys[1], AES.MODE_OFB, ivs[0])
cipher3 = AES.new(keys[2], AES.MODE_CBC, ivs[1])
ct1 = cipher1.encrypt(pad(pt, 16))
ct2 = cipher2.encrypt(ct1)
twks = [sha256(ivs[1] + i.to_bytes(1, 'big')).digest()[:16] for i in range(0, len(ct2)//16)]
ct3 = cipher3.decrypt(xor(ct2, b''.join(twks)))
return ct3
def main():
print('Can you guess my secret token?')
try:
pt = bytes.fromhex(input('Give me a plaintext to encrypt (in hex): '))
ct = encrypt(pt, keys, ivs)
print(f'Ciphertext (in hex): {''.join([iv.hex() for iv in ivs]) + ct.hex()}')
st = input('Give me your guess: ')
if st == secret_token:
print(f'Well done, here is the flag: {FLAG}')
else:
print('Nope.')
except:
exit('Something went wrong')
if __name__ == '__main__':
signal.alarm(TIMEOUT)
main()
Last updated