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 ZP\mathbb{Z}_P​ (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? 🚩

  1. Create decrypt functions that undo each encryption step —

  2. Since rounds are random (26-53), try all possibilities (smoll enough,hehe)

  3. Look for results that decode to readable text starting with "snakectf{"

Script

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:

  1. digest — 24 field elements (mod p=65537)

  2. 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.

Script

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;

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