KalmarCTF 2024

#CTF-Writeup

Table of Contents

Last weekend, I played KalmarCTF 2024 with my team @1337%Yogurt. The cryptography category is very hard this year, so I could only manage to solve some of them. Here is my writeup for challenges that are solved by me.

Cracking The Casino

casino.py

#!/usr/bin/python3
from Pedersen_commitments import gen, commit, verify


# I want to host a trustworthy online casino!
# To implement blackjack and craps in a trustworthy way i need verifiable dice and cards!
# I've used information theoretic commitments to prevent players from cheating.
# Can you audit these functionalities for me ?

from random import randint
# Verifiable Dice roll
def roll_dice(pk):
    roll = randint(1,6)
    comm, r = commit(pk,roll)
    return comm, roll, r

# verifies a dice roll
def check_dice(pk,comm,guess,r):
    res = verify(pk,comm, r, int(guess))
    return res

# verifiable random card:
def draw_card(pk):
    idx = randint(0,51)
    # clubs spades diamonds hearts
    suits = "CSDH"
    values = "234567890JQKA"
    value = values[idx%13]
    suit = suits[idx//13]
    card = value + suit
    comm, r = commit(pk, int(card.encode().hex(),16))
    return comm, card, r

# take a card (as two chars, fx 4S = 4 of spades) and verifies it was the committed card
def check_card(pk, comm, guess, r):
    res = verify(pk, comm, r, int(guess.encode().hex(),16))
    return res


# Debug testing values for larger values
def debug_test(pk):
    dbg = randint(0,2**32-2)
    comm, r = commit(pk,dbg)
    return comm, dbg, r

# verify debug values
def check_dbg(pk,comm,guess,r):
    res = verify(pk,comm, r, int(guess))
    return res


def audit():
    print("Welcome to my (beta test) Casino!")
    q,g,h = gen()
    pk = q,g,h
    print(f'public key for Pedersen Commitment Scheme is:\nq = {q}\ng = {g}\nh = {h}')
    chosen = input("what would you like to play?\n[D]ice\n[C]ards")

    if chosen.lower() == "d":
        game = roll_dice
        verif = check_dice
    elif chosen.lower() == "c":
        game = draw_card
        verif = check_card
    else:
        game = debug_test
        verif = check_dbg

    correct = 0
    # If you can guess the committed values more than i'd expect, then
    for _ in range(1337):
        if correct == 100:
            print("Oh wow, you broke my casino??!? Thanks so much for finding this before launch so i don't lose all my money to cheaters!")
            with open("flag.txt","r") as f:
                flag = f.read()
            print(f"here's that flag you wanted, you earned it! {flag}")
            exit()

        comm, v, r = game(pk)
        print(f'Commitment: {comm}')
        g = input(f'Are you able to guess the value? [Y]es/[N]o')
        if g.lower() == "n":
            print(f'commited value was {v}')
            print(f'randomness used was {r}')
            print(f'verifies = {verif(pk,comm,v,r)}')
        elif g.lower() == "y":
            guess = input(f'whats your guess?')
            if verif(pk, comm, guess, r):
                correct += 1
                print("Oh wow! well done!")
            else:
                print("That's not right... Why are you wasting my time if you haven't broken anything?")
                exit()

    print(f'Guess my system is secure then! Lets go ahead with the launch!')
    exit()

if __name__ == "__main__":
    audit()

Pedersen_commitments.py

from Crypto.Util.number import getStrongPrime
from Crypto.Random.random import randint

## Implementation of Pedersen Commitment Scheme
## Computationally binding, information theoreticly hiding

# Generate public key for Pedersen Commitments
def gen():
    q = getStrongPrime(1024)

    g = randint(1,q-1)
    s = randint(1,q-1)
    h = pow(g,s,q)

    return q,g,h

# Create Pedersen Commitment to message x
def commit(pk, m):
    q, g, h = pk
    r = randint(1,q-1)

    comm = pow(g,m,q) * pow(h,r,q)
    comm %= q

    return comm,r

# Verify Pedersen Commitment to message x, with randomness r
def verify(param, c, r, x):
    q, g, h = param
    if not (x > 1 and x < q):
        return False
    return c == (pow(g,x,q) * pow(h,r,q)) % q

Basically, you need to guess the message which is used for commitment. Because we have lots of rounds to try and Python random can be predictable if collect enough samples, so we can use debug mode to collect 624 32-bits messages, then we will be able to guess next message

solve.py

from pwn import *
from mt19937predictor import MT19937Predictor #https://github.com/kmyk/mersenne-twister-predictor

#target = process(["python3", "casino.py"])
target = remote("chal-kalmarc.tf", 9)
target.recvline()
target.recvline()
q = int(target.recvline().decode()[3:])
g = int(target.recvline().decode()[3:])
h = int(target.recvline().decode()[3:])

predictor = MT19937Predictor()

target.recvuntil(b"[C]ards")
target.send(b"N\n")

guess = 0
game = 0
for _ in range(1337):
    comm = int(target.recvline().decode()[len("Commitment:"):])
    if guess < 625:
        guess += 1
        target.sendlineafter(b"[Y]es/[N]o", b"N")
        v = int(target.recvline().decode()[len("commited value was "):])
        r = int(target.recvline().decode()[len("randomness used was "):])
        target.recvline()
        predictor.setrandbits(v, 32)
    else:
        target.sendlineafter(b"[Y]es/[N]o", b"Y")
        target.sendlineafter(b"whats your guess?", (str(predictor.randint(0, 2**32-2))).encode())
        target.recvline()
        game += 1
    if game == 100:
        target.interactive()

Flag: Kalmar{First_Crypto_Down!}

Re-Cracking The Casino *

casino.py

#!/usr/bin/python3
from Pedersen_commitments import gen, commit, verify


# I want to host a trustworthy online casino!
# To implement blackjack and craps in a trustworthy way i need verifiable dice and cards!
# I've used information theoretic commitments to prevent players from cheating.
# Can you audit these functionalities for me ?

# Thanks for the feedback, I'll use secure randomness then!
from Crypto.Random.random import randint
# Verifiable Dice roll
def roll_dice(pk):
    roll = randint(1,6)
    comm, r = commit(pk,roll)
    return comm, roll, r

# verifies a dice roll
def check_dice(pk,comm,guess,r):
    res = verify(pk,comm, r, int(guess))
    return res

# verifiable random card:
def draw_card(pk):
    idx = randint(0,51)
    # clubs spades diamonds hearts
    suits = "CSDH"
    values = "234567890JQKA"
    value = values[idx%13]
    suit = suits[idx//13]
    card = value + suit
    comm, r = commit(pk, int(card.encode().hex(),16))
    return comm, card, r

# take a card (as two chars, fx 4S = 4 of spades) and verifies it was the committed card
def check_card(pk, comm, guess, r):
    res = verify(pk, comm, r, int(guess.encode().hex(),16))
    return res


# Debug testing values for larger values
def debug_test(pk):
    dbg = randint(0,2**32-2)
    comm, r = commit(pk,dbg)
    return comm, dbg, r

# verify debug values
def check_dbg(pk,comm,guess,r):
    res = verify(pk, comm, r, int(guess))
    return res


def audit():
    print("Welcome to my (Launch day!) Casino!")
    q,g,h = gen()
    pk = q,g,h
    print(f'public key for Pedersen Commitment Scheme is:\nq = {q}\ng = {g}\nh = {h}')
    chosen = input("what would you like to play?\n[D]ice\n[C]ards")

    if chosen.lower() == "d":
        game = roll_dice
        verif = check_dice
    elif chosen.lower() == "c":
        game = draw_card
        verif = check_card
    else:
        game = debug_test
        verif = check_dbg

    correct = 0

    # Should be secure now :)
    for _ in range(256):
        if correct == 250:
            print("Oh wow, you broke my casino again??!? That's impossible!")
            with open("flag.txt","r") as f:
                flag = f.read()
            print(f"here's that flag you wanted, you earned it! {flag}")
            exit()

        comm, v, r = game(pk)
        print(f'Commitment: {comm}')
        g = input(f'Are you able to guess the value? [Y]es/[N]o')
        if g.lower() == "n":
            print(f'commited value was {v}')
            print(f'randomness used was {r}')
            print(f'verifies = {verif(pk,comm,v,r)}')
        elif g.lower() == "y":
            guess = input(f'whats your guess?')
            if verif(pk, comm, guess, r):
                correct += 1
                print("Oh wow! well done!")
            else:
                print("That's not right... Why are you wasting my time if you haven't broken anything?")
                exit()

    print(f'Guess my system is secure then! Lets go ahead with the launch!')
    exit()

if __name__ == "__main__":
    audit()

(Pedersen_commitments.py is the same as first version’s one)

This time, server use more secure random function, and we need to guess correct 250/256 times, so we need to find different method to solve instead of crack random like the first version

In this challenge, I chose card mode to reduce space to search. We can convert card to number that server uses, and save its prime factor into a list factor

So why we need to do that ? We will solve discrete logarithm problem:

$$comm = g^x * h^r \mod q = g^{(x + rs)} \mod q$$(1)

When connect to the server, we will have a chance to have $(s, q - 1) = p > 1$ where $p$ is a small prime. If $p$ doesn’t exist in factor, then we can change the equation (1) to $comm = g^x \mod q$ and solve dlog over $\mathbb{F}_q$, and now we can bruteforce $x$ to satisfy the commitment function.

solve.py

from pwn import *
from Crypto.Random.random import randint
from Crypto.Util.number import sieve_base

card_values = [12867, 13123, 13379, 13635, 13891, 14147, 14403, 14659, 12355, 19011, 20803, 19267, 16707, 12883, 13139, 13395, 13651, 13907, 14163, 14419, 14675, 12371, 19027, 20819, 19283, 16723, 12868, 13124, 13380, 13636, 13892, 14148, 14404, 14660, 12356, 19012, 20804, 19268, 16708, 12872, 13128, 13384, 13640, 13896, 14152, 14408, 14664, 12360, 19016, 20808, 19272, 16712]

factor = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 43, 47, 53, 61, 71, 73, 89, 97, 101, 103, 107, 109, 131, 137, 139, 151, 191, 193, 223, 239, 277, 293, 353, 359, 479, 487, 547, 587, 733, 743, 787, 991, 1193, 1609, 1753, 1801, 1877, 2089, 2377, 2389, 3089, 3217, 4177, 4289, 4721, 4801, 4817, 5569, 6337, 13907, 14419, 19267]

while True:
    try:
        target = process(["python3", "casino.py"])
        #target = remote("chal-kalmarc.tf", 13337)
        target.recvline()
        target.recvline()
        q = int(target.recvline().decode()[3:])
        g = int(target.recvline().decode()[3:])
        h = int(target.recvline().decode()[3:])
        target.recvuntil(b"[C]ards")
        target.send(b"C\n")
        divs = 1
        for p in sieve_base:
            if (q - 1) % p == 0 and p not in factor:
                divs *= p
        d = (q - 1) // divs
        hd = pow(h, d, q)
        assert hd == 1
        gd = pow(g, d, q)
        assert gd != 1
        print("OK")
        gds = [pow(gd, value, q) for value in card_values]
        assert len(set(gds)) == len(card_values)
        for _ in range(256):
            comm = int(target.recvline().decode()[len("Commitment:"):])
            commd = pow(comm, d, q)
            for idx, value in enumerate(card_values):
                if pow(gd, value, q) == commd:
                    suits = "CSDH"
                    values = "234567890JQKA"
                    value = values[idx%13]
                    suit = suits[idx//13]
                    card = value + suit
            target.sendlineafter(b"[Y]es/[N]o", b"Y")
            target.sendlineafter(b"whats your guess?", card.encode())
            target.recvline()
        target.interactive()
    except:
        target.close()

flag: Kalmar{Why_call_it_strong_if_its_so_weak…}

MathGolf-Warmup

chal-warmup.py

#!/usr/bin/env python3

# MathGolf-Warmup challenge by shalaamum for KalmarCTF 2024

import signal
import sys
import time
import sage.all

def out_of_time(signum, frame):
    print("\nTime is up!")
    sys.exit(1)

signal.signal(signal.SIGALRM, out_of_time)
signal.alarm(60)


def sequence_slow(n, b, c, a0, a1, p):
    if n == 0:
        return a0
    elif n == 1:
        return a1
    else:
        return (b*sequence(n - 1, b, c, a0, a1, p) + c*sequence(n - 2, b, c, a0, a1, p)) % p

# sequence = sequence_slow
from lib import sequence_fast
sequence = sequence_fast
# sequence_fast has the same return value as sequence_slow, it is just ...
# faster.


#from Crypto.Util.number import getPrime
#from Crypto.Random.random import randrange
#class ProblemGenerator:
#    def get(self):
#        p = getPrime(64)
#        n = randrange(1, 1<<64)
#        b, c, a0, a1 = [randrange(0, p) for _ in range(4)]
#        return n, b, c, a0, a1, p
from lib import ProblemGenerator
generator = ProblemGenerator()
# You can assume that ProblemGenerator acts as the above commented snippet.
# It just makes some tweaks that are intended to reduce the variance of the
# run times. Trying to guess those tweaks is unlikely to be helpful to solve
# the challenge.
# Note: The reason in the comment above are for the non-warmup version of the
# challenge.


def get_number():
    return int(input().strip()[2:], 16)


def sequence_from_parameters(n, b, c, a0, a1, p, parameters):
    poly = parameters[0:2]
    phi = parameters[2:4]
    psi = parameters[4:6]
    const_phi = parameters[6:8]
    const_psi = parameters[8:10]

    Fp = sage.all.GF(p)
    RFp = sage.all.PolynomialRing(Fp, ['t'])
    F = sage.all.GF(p**2, name='t', modulus=RFp(poly + [1]))
    phi = F(phi)
    psi = F(psi)
    const_phi = F(const_phi)
    const_psi = F(const_psi)

    answer = list(phi**n * const_phi - psi**n * const_psi)
    if answer[1] != 0:
        print("That can't be right...")
        sys.exit(1)
    return int(answer[0])

for i in range(100):
    print(f'Solved {i} of 100')
    n, b, c, a0, a1, p  = generator.get()
    print(f'b  = 0x{b:016x}')
    print(f'c  = 0x{c:016x}')
    print(f'a0 = 0x{a0:016x}')
    print(f'a1 = 0x{a1:016x}')
    print(f'p  = 0x{p:016x}')

    parameters = []
    print('Polynomial: ')
    parameters.append(get_number())
    parameters.append(get_number())
    print('phi: ')
    parameters.append(get_number())
    parameters.append(get_number())
    print('psi: ')
    parameters.append(get_number())
    parameters.append(get_number())
    print('const_phi: ')
    parameters.append(get_number())
    parameters.append(get_number())
    print('const_psi: ')
    parameters.append(get_number())
    parameters.append(get_number())

    print('Checking...')
    answer = sequence_from_parameters(n, b, c, a0, a1, p, parameters)
    correct = sequence(n, b, c, a0, a1, p)
    if answer != correct:
        print(f'Incorrect! Correct answer was 0x{correct:016x}')
        sys.exit(1)

print(open('flag.txt', 'r').read())

In this challenge, we are asked to calculate nth element of the sequence:

$$\begin{aligned} a _ n = b * a _ {n - 1} + c * a _ {n - 2} \mod p \end{aligned}$$

I will explain how to find general term of $(a_n)$. First, we need to solve the quadratic equation (it is called characteristic equation):

$$x^2 - bx - c = 0 \mod p$$(1)

Our delta will be $\Delta = \sqrt{b^2 + 4c}$, and because we are working in $\mathbb{F}_p$, we have two scenarios

Case 1: $\Delta$ is a quadratic residue modulo $p$

If $\Delta$ is a quadratic residue modulo $p$, so we can calculate two roots of (1) like normal. Suppose that our roots are $x_1$ and $x_2$, so the general term of our sequence will be $$a_n = u * x _ 1 ^ n + v * x _ 2 ^ n$$

Because server gives us $a_0$ and $a_1$, so we can calculate $u$ and $v$ easily, which are const_phi and const_psi

Case 2: $\Delta$ is a nonquadratic residue modulo $p$

Now, we can’t work over $\mathbb{F} _ p$ because $\sqrt{\Delta}$ doesn’t exist, so we need to work over $\mathbb{F} _ {p^2}$, and it is equivalent to $\mathbb{F} _ p[x]/(x^2 - \Delta)$. After extend the field, we can do the same as Case 1

Because server requires polynomial modulo in all cases, so for case 1, I bruteforce to find a number which is a quadratic nonresidue modulo $p$.

solve.py

from sage.all import *
from pwn import *

def sequence_fast(b, c, a0, a1, p):
    Fp = GF(p)
    RFp = PolynomialRing(Fp, 't')
    t = RFp.gen()
    delta = Fp(b**2 + 4 * c)
    if kronecker(delta, p) == 1: 
        phi = (b + delta.sqrt()) * pow(2, -1, p) % p
        psi = (b - delta.sqrt()) * pow(2, -1, p) % p
        cpsi = (a1 - a0 * phi) * pow(phi - psi, -1, p) % p
        cphi = (a0 + cpsi) % p
        while True:
            coeff = randint(1, p)
            if (t**2 + coeff).is_irreducible():
                break
        return [coeff, 0, phi, 0, psi, 0, cphi, 0, cpsi, 0]
    else:
        F = GF(p**2, name="t", modulus = RFp([-delta, 0, 1]))
        W = F.gen()
        phi = (b + W) / F(2)
        psi = (b - W) / F(2)
        cpsi = F(a1 - a0 * phi) / F(phi - psi)
        cphi = a0 + cpsi
        ans = [-delta, 0] + list(phi) + list(psi) + list(cphi) + list(cpsi)
        return ans


target = remote("mathgolf.chal-kalmarc.tf", "3470")
for _ in range(100):
    target.recvline()
    b = int(target.recvline().decode()[len("b  = "):], 16)
    c = int(target.recvline().decode()[len("c  = "):], 16)
    a0 = int(target.recvline().decode()[len("a0 = "):], 16)
    a1 = int(target.recvline().decode()[len("a1 = "):], 16)
    p = int(target.recvline().decode()[len("p  = "):], 16)
    ans = sequence_fast(b, c, a0, a1, p)
    for i in range(5):
        target.recvline()
        request = str(hex(ans[2 * i])) + "\n" + str(hex(ans[2 * i + 1])) + "\n"
        target.send(request.encode())
    target.recvline()

target.interactive()

flag: kalmar{generalized_fibonacci_sequence_in_a_finite_field__did_you_implement_everything_yourself_for_this__for_non-warmup_you_might_have_to}