ABOH 2023

Writeup for all the crypto challenges I solved in Apu Battle of Hackers 2023 (ASEAN Category).

I will try to make my writeup easy to follow ;D

About SageMath

Most of the crypto challenges have .sage file, which is a SageMath code file.

Sagemath is a library built on top of Python, there are some slight syntax difference, which I have summarized here.

Small Sage

I only know how to do smith challenges

#!/usr/bin/env sage
from Crypto.Util.number import bytes_to_long

p, q = random_prime(2 ^ 1024), random_prime(2 ^ 1024)
n = p*q
e = 3

assert len(flag) > e

FLAG = open("flag.txt", "rb").read().strip()
m = bytes_to_long(FLAG + b' is your challenge flag.')
c = pow(m, e, n)

print("N: ", n)
print("C: ", c)
print("e: ", e)

Observation

Reading the given code, we see that this is an RSA implementation with small e = 3.

When the exponent of RSA is small, it is vulnerable to Coppersmith Attack

I know you won’t read all the math in the wikipedia page so I will summarize the idea of Coppersmith Attack here.

The general idea of the attack is it enable us to find root of an equation modulo \(N\), namely

\[f(x) \equiv 0 \text{ (mod } N)\]

where \(f(x)\) is a polynomial function in terms of \(x\)

In the case of RSA, we are trying to solve

\[\begin{aligned} m^e &\equiv c \text{ (mod } N)\\ m^e - c &\equiv 0 \text{ (mod } N)\\ f(m) &\equiv 0 \text{ (mod } N)\\ \end{aligned}\]

where \(m\) is the plaintext and \(c\) is the ciphertext.

But then if coppersmith attack works, wouldn’t we can just break any RSA cryptosystem? Just find the root and we are done right!

Hohoho smart kid, but that is not the case because of Coppersmith Theorem. Coppersmith attack only works under certain condition

Coppersmith Theorem

Note: this is a summarized version

Let \(N\) be the modulus

Let \(d\) be the degree of the polynomial \(f(x)\)

Let \(\alpha\) be a root of the polynomial \(f(x)\)

Given the condition

\[\alpha < N^{\frac{1}{d} - \epsilon}\]

then we can efficiently find \(\alpha\) for some small \(\epsilon\).

The \(\epsilon\) you choose will affect the runtime of your algorithm. The smaller the \(\epsilon\), the longer it takes to run.

Solution

In this problem, our \(f(m)\) is

\[f(m) = m^e - c = m^3 - c\]

Therefore the degree of the polynomial is \(e = 3\), and the coppersmith bound is

\[\alpha < N^{\frac{1}{3} - \epsilon} \approx 2^{2048/3} = 2^{682}\]

This means that if our plaintext \(m\) is less than \(2^{682}\), we should be able to recover it.

\(2^{682}\) is about 85 characters, which should be sufficiently long enough for a flag.

Next we need to form the equation to run the attack. Note that in the original question, the flag is prepended with bunch of text before encryption.

m = bytes_to_long(FLAG + b' is your challenge flag.')

With that in mind, the polynomial should be

\[m * 256^{l} + k \equiv 0 \text{ (mod } N)\]

where

padding = b' is your challenge flag.'
l = len(padding)
k = bytes_to_long(padding))

Now throw this into SageMath built in coppersmith method small_roots(), then we get the flag

Solution script

from Crypto.Util.number import bytes_to_long, long_to_bytes

n = ...
c = ...
e = 3

padding = b' is your challenge flag.'
F.<x> = Zmod(n)[]
f = c - (x * 256^len(padding) + bytes_to_long(padding))^3
f = f.monic() 
flag = int(f.small_roots()[0])

print(long_to_bytes(flag))

Note the line

...
f = f.monic()
...

Convert to a monic polynomial basically means divide the polynomial with the coefficient of the highest degree,

For example

\[5x^3 + 3x + 1 = 0\]

will change to

\[x^3 + \dfrac{3}{5}x + \dfrac{1}{5} = 0\]

This is necessary because sagemath small_roots() only works on monic polynomial. Note that monic polynomial should have the same roots as the original polynomial.

May The Force Be With You

One in a galaxy far far away a there was a story about the famous sith lord who managed to conqure the entire galaxy by just using the force. He encoded a secret message with is god like powers and now it’s up to u to try and decode it. Can u take over the galaxy? Decrypt the message to gain all the powers from the darkside.

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.Random import get_random_bytes
from Crypto.Protocol.KDF import PBKDF2

import textwrap

def encrypt_file(file_path, password):
    with open(file_path, 'rb') as file:
        plaintext = file.read()


    iv = get_random_bytes(AES.block_size)

    passwd = textwrap.dedent(password)[:-1]


    salt = b'salt123'  
    key = PBKDF2(passwd.encode(), salt, dkLen=16)


    cipher = AES.new(key, AES.MODE_CBC, iv)


    ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))


    encrypted_file_path = file_path + '.enc'
    with open(encrypted_file_path, 'wb') as file:
        file.write(ciphertext + iv)

    print("Encryption successful. Encrypted file saved as:", encrypted_file_path)


password = "ni5h2h?Yrq8Do?n+|6a;pKbZkv%}O~tV" 
file_path = "./flag.txt"   
encrypt_file(file_path, password)

Solution

AES encryption is used to encrypt the flag file. The key is generated from the password and we are given the password. So just run AES decryption to get the flag.

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.Random import get_random_bytes
from Crypto.Protocol.KDF import PBKDF2

import textwrap

password = "ni5h2h?Yrq8Do?n+|6a;pKbZkv%}O~tV" 
passwd = textwrap.dedent(password)[:-1]
salt = b'salt123'  
key = PBKDF2(passwd.encode(), salt, dkLen=16)

f = open("flag.txt.enc", "rb").read()
iv = f[-16:]

cipher = AES.new(key, AES.MODE_CBC, iv)
ciphertext = cipher.decrypt(f[:-16])
print(ciphertext)

Substitute

No idea what to do so I do substitution, but RNA encode

Observation

The code looks complicated.

First we see that rnaencode() basically just encode bits to AGCU, use github copilot to generate the decode function.

def rnadecode(msg):
    binstr = []
    for i in msg:
        if i == "A":
            binstr.append("00")
        elif i == "C":
            binstr.append("01")
        elif i == "G":
            binstr.append("10")
        elif i == "U":
            binstr.append("11")
    binstr = ''.join(binstr)
    msg = []
    for i in range(_sage_const_0 , len(binstr), _sage_const_8 ):
        msg.append(chr(int(binstr[i:i+_sage_const_8 ], _sage_const_2 )))
    return ''.join(msg)

Next we have to break the subsitution cipher.

The key is generated from this function

R = list(GF(64))

def keygen(l):
    key = [R[randint(1, 63)] for _ in range(l)] 
    key = math.prod(key) # Optimization the key length :D
    return key

The keygen is simple, it is a multiplication of random elements in R = list(GF(64))

But what is GF(64) !???? 💀

Galois Field

Dont worry bro, I die in my university math classes so that I can explain this to you.

GF stands for Galois Field, which basically just means a field.

A field in abstract algebra is a structure which we can do the operations +-*/ and is closed within.

For example, The Rational Number \(\mathbb{Q}\) is a field.

We can do addition, negation, multiplication and division in \(\mathbb{Q}\)

A counterexample, The Integers \(\mathbb{Z}\) is not a field.

Why so? Let’s take two numbers 5, 2

Can we divide 5 and 2?

The answer is we can’t, 5 divide 2 is not an integer, it has now become a rational number.

The condition of a field is after doing all the arithmetic operations, we are still contained in the original set of elements. We call this the closure property

Now let’s considor another field \(\mathbb{Z}_7\) (Integer modulo 7)

You will find out that we can do division of 5 and 2 here because

\[2 \cdot 4 \equiv 1 \text{ (mod } N)\]

So we can say

\[\dfrac{1}{2} \equiv 2^{-1} \equiv 4 \text{ (mod } N)\]

Therefore to do 5 divide 2,

\[\dfrac{5}{2} \equiv 5 \cdot 2^{-1} \equiv 5 \cdot 4 \equiv 6 \text{ (mod } N)\]

You can check that it is indeed a field.

So now we back to our original question, \(GF(64)\) basically means a field that has 64 elements

A basic property of a finite field is that the number of elements must be a prime power \(p^k\).

In this case \(GF(64) = GF(2^6)\)

When \(k > 1\), we have to do field extension (????💀) on the prime integer field to construct it. (Not necessary have to know to solve this challenge)

So when we run list(GF(64)), this is what you will see in sage

sage: list(GF(64))
[0,  z6,  z6^2,  z6^3,  z6^4,  z6^5,  z6^4 + z6^3 + z6 + 1,  z6^5 + z6^4 + z6^2 + z6,  z6^5 + z6^4 + z6^2 + z6 + 1,  z6^5 + z6^4 + z6^2 + 1,  
 z6^5 + z6^4 + 1,  z6^5 + z6^4 + z6^3 + 1,  z6^5 + z6^3 + 1,  z6^3 + 1,  z6^4 + z6,  z6^5 + z6^2,  z6^4 + z6 + 1,  z6^5 + z6^2 + z6,  
 z6^4 + z6^2 + z6 + 1,  z6^5 + z6^3 + z6^2 + z6,  z6^2 + z6 + 1,  z6^3 + z6^2 + z6,  z6^4 + z6^3 + z6^2,  z6^5 + z6^4 + z6^3,  z6^5 + z6^3 + z6 + 1,
 z6^3 + z6^2 + 1,  z6^4 + z6^3 + z6,  z6^5 + z6^4 + z6^2,  z6^5 + z6^4 + z6 + 1,  z6^5 + z6^4 + z6^3 + z6^2 + 1,  z6^5 + 1,  z6^4 + z6^3 + 1,  
 z6^5 + z6^4 + z6,  z6^5 + z6^4 + z6^3 + z6^2 + z6 + 1,  z6^5 + z6^2 + 1,  z6^4 + 1,  z6^5 + z6,  z6^4 + z6^3 + z6^2 + z6 + 1,  
 z6^5 + z6^4 + z6^3 + z6^2 + z6,  z6^5 + z6^2 + z6 + 1,  z6^4 + z6^2 + 1,  z6^5 + z6^3 + z6,  z6^3 + z6^2 + z6 + 1,  z6^4 + z6^3 + z6^2 + z6,  
 z6^5 + z6^4 + z6^3 + z6^2,  z6^5 + z6 + 1,  z6^4 + z6^3 + z6^2 + 1,  z6^5 + z6^4 + z6^3 + z6,  z6^5 + z6^3 + z6^2 + z6 + 1,  z6^2 + 1,  
 z6^3 + z6,  z6^4 + z6^2,  z6^5 + z6^3,  z6^3 + z6 + 1,  z6^4 + z6^2 + z6,  z6^5 + z6^3 + z6^2,  z6 + 1,  z6^2 + z6,  z6^3 + z6^2,  z6^4 + z6^3,  
 z6^5 + z6^4,  z6^5 + z6^4 + z6^3 + z6 + 1,  z6^5 + z6^3 + z6^2 + 1,  1]

It basically list all the elements of the field.

Solution

So back to the key generation

def keygen(l):
    key = [R[randint(1, 63)] for _ in range(l)] 
    key = math.prod(key) # Optimization the key length :D
    return key

It is a multiplication of random elements in R = list(GF(64)).

As we know that field are closed under multiplication, this means that key will just be one of the element from the list.

So to solve this challenge, we just iterate through all the elements in the field and it will be the correct key

Now that we have the key, we need to break the encryption scheme.

def substitute(c):
    assert c in SET
    return R[SET.index(c)]

def encrypt(msg, key):
    m64 = base64.b64encode(msg)
    enc, pkey = '', key**1337
    for m in m64:
        enc += SET[R.index(pkey * substitute(chr(m)))]
    return enc

The encryption works by multiplying the key with the substituted character, and map back the field element back to integer and then map it to a base64 character.

We can simply code the decryption of the protocol.

def decrypt(enc, key):
    dec = ''
    for e in enc:
        dec += SET[R.index(R[SET.index(e)] / key)]
    return base64.b64decode(dec)

Note that I omit key**1337 because it is not necessary. It basically just map the key to another element of the field.

Final solution script

import string, base64

SET = string.printable[:62] + '\\='
R = list(GF(64))

def rnadecode(msg):
    ...

def decrypt(enc, key):
    ...

c = open("out.txt", "r").read()
c = rnadecode(c)

# Loop through the elements
for key in R[1:]:
    try:
        print(decrypt(c, key))
    except:
        continue

Substitute-v2

No idea what to do v2 maybe some other substitution I guess?

#!/usr/bin/env sage
import string
SET = "ABOH23{}_!?" + string.ascii_lowercase + string.digits
n = len(SET)

def encrypt(msg, f):
    ct = ''
    for c in msg:
        ct += SET[f.substitute(SET.index(c))]
    return ct

P.<x> = PolynomialRing(GF(n))
f = P.random_element(4)

FLAG = open('../flag.txt', 'r').read().strip()

enc = encrypt(FLAG, f)

with open('out.txt', 'w') as f:
    print(enc)
    f.write(enc)

Observation

First we see that a polynomial over GF(n) is randomly chosen

P.<x> = PolynomialRing(GF(n)) # Declare polynomial ring over GF(n)
f = P.random_element(4) # Pick random element of degree 4

So much math, what does ‘polynomial ring over GF(n)’ means?

In very simple terms, you just take every coefficient of the polynomial and modulo \(n\)

For example if the polynomial is over GF(5), then

\[3x^3 + 7x^2 + 5x + 11 \equiv 3x^3 + 2x^2 + 1\]

In this question, we have \(n = 47\)

After choosing a random f, it is passed into the encrypt function

def encrypt(msg, f):
    ct = ''
    for c in msg:
        ct += SET[f.substitute(SET.index(c))]
    return ct

This is simple to explain. Each character is mapped to a number and f.substitute() is performed to it. Then the result is mapped back to another character.

f.substitute(a) basically evaluate the function \(f\) on value \(a\).

In other words, we are calculating \(f(a)\)

For example, if \(f(x) = x^2 + 3\),

then f.substitute(2) -> 2**2 + 3 -> 7

Solution

Since the random function is of degree 4 and each of the coefficient can have up to \(n = 47\) value, the search space is \(47^5 \approx 10^8\).

Which is kinda too large for us to just brute force the \(f\).

But since we know the flag format ABOH23{, we can solve equations to get back the original coefficient.

The idea is to let

\[f(x) = ax^4 + bx^3 + cx^2 + dx + e\]

with \(a,b,c,d,e\) as unknowns, then we form 7 equations

f('A') - a * ('A')^4 + b * ('A')^3 + c * ('A')^2 + d * ('A') + e = 0
f('B') - a * ('B')^4 + b * ('B')^3 + c * ('B')^2 + d * ('B') + e = 0
f('O') - a * ('O')^4 + b * ('O')^3 + c * ('O')^2 + d * ('O') + e = 0
f('H') - a * ('H')^4 + b * ('H')^3 + c * ('H')^2 + d * ('H') + e = 0
f('2') - a * ('2')^4 + b * ('2')^3 + c * ('2')^2 + d * ('2') + e = 0
f('3') - a * ('3')^4 + b * ('3')^3 + c * ('3')^2 + d * ('3') + e = 0
f('{') - a * ('{')^4 + b * ('{')^3 + c * ('{')^2 + d * ('{') + e = 0

We can obtain f('A'), f('B'), ... by looking at the first 7 characters of the ciphertext.

With 7 equations and 5 unknowns, we can solve system of equations with linear algebra (Just throw into sagemath to solve).

Then we can recover the original function f

import string
SET = "ABOH23{}_!?" + string.ascii_lowercase + string.digits
n = len(SET)

format = "ABOH23{"

P.<a,b,c,d,e> = PolynomialRing(GF(n))

ct = open('out.txt', 'r').read().strip()

eqs = []

for i in range(len(format)):
    t = SET.index(format[i])
    eq = a * t^4 + b * t^3 + c * t^2 + d * t + e - SET.index(ct[i])
    eqs.append(eq)

basis = Ideal(eqs).groebner_basis()

a = -basis[0].coefficients()[1]
b = -basis[1].coefficients()[1]
c = -basis[2].coefficients()[1]
d = -basis[3].coefficients()[1]
e = -basis[4].coefficients()[1]

With the original function, recovering the plaintext is trivial.

For each of the character c, we find f(c). Then construct the inverse map.

Note that as the function is not bijective, there are multiple possible character for some of the character. With a bit of guessing we can recover the flag.

Final solution script

import string
SET = "ABOH23{}_!?" + string.ascii_lowercase + string.digits
n = len(SET)

format = "ABOH23{"

P.<a,b,c,d,e> = PolynomialRing(GF(n))

ct = open('out.txt', 'r').read().strip()

eqs = []

for i in range(len(format)):
    t = SET.index(format[i])
    eq = a * t^4 + b * t^3 + c * t^2 + d * t + e - SET.index(ct[i])
    eqs.append(eq)

basis = Ideal(eqs).groebner_basis()

a = -basis[0].coefficients()[1]
b = -basis[1].coefficients()[1]
c = -basis[2].coefficients()[1]
d = -basis[3].coefficients()[1]
e = -basis[4].coefficients()[1]

inv_map = [[] for i in range(n)]

for i in range(n):
    index = a * i^4 + b * i^3 + c * i^2 + d * i + e
    inv_map[index].append(i)
        
for i in ct:
    for j in inv_map[SET.index(i)]:
        print(SET[j], end=',')
    print()

Hash Clash

Observation

The server implemented a custom hash function, and we are tasked to construct an efficient hash inverse

def simpleHashFunction(pt):
    mod = 2**128
    mult = 31107660549029995755035237740128219709
    cur = 281483737825725237143369797025428684609
    for c in pt:
        cur = ((cur + ord(c)) * mult) % mod
    ct = ""
    for i in range(16):
        ct = hex(cur % 256)[2:].zfill(2) + ct
        cur >>= 8
    return ct

Notice that the hash function implements an LCG

Let \(pt = b_{1}b_{2}...b_{n}\), where \(b_{i}\) represents the \(i\) character

The hash function above can be expand to be

\[\begin{aligned} H(b_{1}b_{2}...b_{n}) &= [[[(c + b_{1}) * m] + b_{2} * m] \dots]\\ &= cm^n + b_{1}m^n + b_{2}m^{n-1} + \dots + b_{n}m^{1} \end{aligned}\]

Since \(c\) and \(m\) are given, essentially we are task to find the root of the polynomial

\[f(b_{1}, \dots, b_{n}) = b_{1}m^n + b_{2}m^{n-1} + \dots + b_{n}m^{1} + cm^n - h\]

where h is the given hash, with the condition that \(b_{i}\) must be in the range of \([33, 127]\)

There are lots of \(b_{i}\) that satisifes the polynomial but the range restriction is a big problem.

Solution

First notice that we can artifically subtract 70 for each of the \(b_i\) to make the bound easier to handle

\[f(b_{1}, \dots, b_{n}) = (b_{1} - 70)m^n + (b_{2} - 70)m^{n-1} + \dots + (b_{n} -70)m^{1} + cm^n - h\]

Now the condition is \(b_{i}\) must be in the range of \([-37, 57]\), so

\[|b_{i}| \leq 37\]

I could pick a better bound but this is sufficient.

Each of the \(b_{i}\) are required to be small, so we can solve this by using Latice reduction LLL

Construct the matrix

\[\begin{bmatrix} 1 & 0 & 0 & \dots & Xm^n & 0\\ 0 & 1 & 0 & \dots & Xm^{n-2} & 0\\ 0 & 0 & 1 & \dots & Xm^{n-3} & 0\\ \vdots & \vdots & \vdots & \ddots & \vdots & \vdots\\ 0 & 0 & 0 & \dots & Xt & 1 \end{bmatrix}\]

\(t\) is the constant term of the polynomial, \(X\) is the padding to make sure that the column will sum to 0.

I set it to be \(X = 2^{100}\)

With around 32 characters, I can get small solution for \(b_{i}\)

Output for one of the LLL iterations

[  0   1  -1   2   5   2   1   4   8  -3  -3   7   3   0  -3   3  -7   3  -1  -2   0  -2   2  -4  -6  -2  -1  11  -1  -7   1   4  -3  -3   0   0   0   0]
[  5  -3  -4   1  -4   6  -1   3   0   3   8  -4  -2  -6  -1  -5   4  -2  -4   0  -4   0   1   8  -1   7   5  -5  -4   3  -3  -4  -3   3  -1   0   0   0]
[  3   2   0   4  -6   7   1  -3   4   3   9  -1   2   2  -2  -1  -5   3  -5  -1  -9  -2   2   3  -1  -2   4  -6  -4   5   3   2   3   2   1  -1   0   1]
[ -1  -1   2   2   1  -3   4  -8   0  -4   5  -9   5  -1   5   1  -5   2   2   1   3   0  -5   4  10  -3  -6  -7  -1   4   4   2   6   3   0   0   0   0]
[  4  -2   1   0  -5  -3  -5   2  -3   4   3  -5  -2   4   2  -7   4   6  -4  -4  -5   2   1  -9   1   2   2  -3   2   1  -5  -5   2   1   3  -4   0   0]
[  4   4  -1   2   6  -1   3  -5  -1  -1   6  -6   2 -11   1  -6  10   2   0   3  -5  -5  -3   3   3   5  -4  -2  -6   5  -6  -2  -2   6  -2   0   0   0]
[ -2   4   4   0  -1  -6  -4  -6   1   4   2   0  -3  -1  -8   4   9  -5  -2   8 -10  -1   1   0   1   4   6  -6  -3   5   4   0  -3   0   4   0   0   1]
[ -4   0  -2   4  -1  -1  -4   3   8  -4   2  -1   2   6  -2   6  -9  -1   8   5   1  -2  -2  10   2   3  -3  -1   0  -9   5   2   1   2   0   0   0   0]
[ -4   4   1   6   2   6  -1  -7  -3   1  -2   1   1   4   1  -1   2   2   6   6   7  -5   1   2  -1  -1  -2   1   1   3  -5  -7   1   0  -7   3   0  -1]
[  1   2  -1   5   2  -6  -3  -2   6  -1   4  -3   4  -2   0  -7   0  -1   7   1   6   4  -4   0   6   5   1   1   3  -1  -4  -6  -8  -4  -1   0   0  -1]
...

As long as the second last element of the row is 0, the last element of the row is 1, then we have a collision.

Final solution script

from pwn import *

r = remote("localhost", int(9999))

n = 36; mult = 31107660549029995755035237740128219709
mod = 2**128; cur = 281483737825725237143369797025428684609

for i in range(30):
    r.recvuntil(b"hash =")
    h = int(r.recvline(), 16)

    # Calculate Constant term
    f = cur * mult**(n)
    for j in range(n):
        f = f + 70 * mult**(n - j)
        f = f % mod
    f = f - h

    # Construct the Matrix
    M = []
    for j in range(n):
        arr = [1 if j == k else 0 for k in range(n)]
        arr.append(int((mult**(n - j)) % mod) << 100)
        arr.append(0)
        M.append(arr)

    M.append([0 for _ in range(n)] + [int(f) << 100, 1])

    M = Matrix(ZZ, M).LLL()

    # Find good row
    for i in range(n):
        if (M[i][-1] == 1):
            row = M[i]
            break

    # Send answer to server
    ans = ''
    for i in row[:-2]:
        ans += chr(i + 70)

    r.sendline(ans)

r.interactive()

Updated: