Sitemap
Gracialy

Gracialy

Cryptography— SMT Program 2025 Write-up

6 min readAug 10, 2025

--

Press enter or click to view image in full size

No, it’s not about Bitcoin, blockchain, or any meme coins randomly popping up on your timeline. In CTFs, cryptography means spotting the flaw that turns scary-looking hex-strings into something laughably solvable. Let’s look at a few challenges SMT provided this year.

1. welcome (100 pts)

his message has been... encoded? encrypted? Whatever that is... Anyways, can you read it?

UkxTMjAyNXt2M2tibmwzX3MwX3NnM19icXhvczBfdjBxa2MhfQ==

The ciphertext consists of letters (about 50% uppercase and 50% lowercase), as well as numbers, and double equal-characters (==) at the end. Those are the characteristics of base 64 encoding. Using a well-known site, CyberChef, we retrieve “RLS2025{v3kbnl3_s0_sg3_bqxos0_v0qkc!}”.

Press enter or click to view image in full size

It’s not our expected string yet (SMT2025{}), but we can already see the pattern. From the pattern, our best guess is that it was encoded with caesar cipher. We can use dcode.fr to test all possible shifts.

Press enter or click to view image in full size

Most of the time, we can trust dcode.fr result ranking (the goat frfr). The flag was obtained by shifting 25 place: SMT2025{w3lcom3_t0_th3_crypt0_w0rld!}.

2. eAESy (200 pts)

This is a basic implementation of AES. Can you decrypt the message?

The following is chall.py attachment.

import os
from Crypto.Cipher import AES # pip install pycryptodome
from Crypto.Util.Padding import pad, unpad

FLAG = b'SMT2025{REDACTED}'
key = b'smt_progr4m_2k25'

# Function to encrypt the flag
def encrypt(msg, key):
iv = os.urandom(AES.block_size)
cipher = AES.new(key, AES.MODE_CBC, iv)
ciphertext = cipher.encrypt(pad(msg, AES.block_size)) # AES block size is 16 bytes
return (iv + ciphertext).hex()

# Write the encryption result to flag.enc
with open("flag.enc", "w") as f:
f.write(encrypt(FLAG, key))

# Encrypted FLAG (from flag.enc): 4abd733e0662e1b1aa3d1996f789209b5c9684941f1717d05000e861cab8491bef153fffd9feb7502ba96144d061517a134456204eb06d250f5266e0e9945c76

Looking at the code, we can see AES encryption in CBC mode and the hardcoded key. Again, flag is in the form of SMT2025{} and the output is provided in hex.

We have the key, ciphertext, and the means of encryption. We also know the IV were prepended to the ciphertext before being hex-encoded. That means, the IV can be obtained from the first 16 bytes of the ciphertext.

Get Gracialy’s stories in your inbox

Join Medium for free to get updates from this writer.

So, we can decode with the following code.

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

key = b'smt_progr4m_2k25'
enc_hex = "4abd733e0662e1b1aa3d1996f789209b5c9684941f1717d05000e861cab8491bef153fffd9feb7502ba96144d061517a134456204eb06d250f5266e0e9945c76"

# Convert the hex string back to bytes
data = bytes.fromhex(enc_hex)

# Split the first 16 bytes as the IV and the rest as ciphertext
iv = data[:16]
ciphertext = data[16:]

# Initialize AES in CBC mode and decrypt
cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)

print(plaintext.decode())

Run the code and obtain the flag: SMT2025{3ncrypt1on_4nd_d3crypt1on}.

3. rsaaaaaaaaaa (200 pts)

In RSA, the security lies on the fact that N is hard to factorize. What if it isn't?

The following is chall.py attachment.

from Crypto.Util.number import getPrime, bytes_to_long, long_to_bytes # pip install pycryptodome

FLAG = b"SMT2025{REDACTED}"

# RSA Parameters
p = getPrime(128)
q = getPrime(128)
N = p*q
e = 65537
phi = (p-1)*(q-1)
d = pow(e,-1,phi)

# Encrypting the flag
c = pow(bytes_to_long(FLAG),e,N)

# Print only the public parameters
print(f"N = {N}")
print(f"e = {e}")
print(f"c = {c}")

Another attachment is output.txt.

# Output from running chall.py

N = 41874700861388258853489749809278828485531955632713300130620929705146462431987
e = 65537
c = 6459193742574944020759123901642766353850952579897308947159707555151928327695

One of the key to RSA challenges is knowing the p and q primes that make up the N key. Note that the N here is ~256 bit, which means the primes are only 128-bit each. It’s a fairly weak and quickly factorable key. We can use various methods, such as Pollard’s Rho, ECM, msieve, sympy, or yafu. There’s a good chance it’s already on factordb, which feels more practical and fast.

Press enter or click to view image in full size

Once we obtained p and q, we can compute φ(N)=(p−1)(q−1) and get the private exponent d≡e^−1(modφ(N)). Then, obtain the message with m≡c^d mod N.

from Crypto.Util.number import inverse, long_to_bytes

# From output.txt
N = 41874700861388258853489749809278828485531955632713300130620929705146462431987
e = 65537
c = 6459193742574944020759123901642766353850952579897308947159707555151928327695

# From FactorDB
p = 177799494650259702135069413332527706687
q = 235516422269691162678354673764568461901

# Sanity check
assert p * q == N, "p*q != N"

# Compute private key
phi = (p - 1) * (q - 1)
d = inverse(e, phi)

# Decrypt
m = pow(c, d, N)
flag = long_to_bytes(m)
print(flag)

Run the code and obtain the flag: SMT2025{sm4ll_pr1m3_b4d}.

4. birthday (300 pts)

There are 366 possible birthdays in a year, yet only 23 people are needed for the chance of a shared birthday to exceed 50%.

There are 2^32 possibly generated hash from this hash algorithm, yet only [???] generated hash are needed for the chance of a collision to exceed 50%.

Can you find the collision?

Connect using `nc 52.77.77.117 10041`

They also provided us with server.py.

#!/usr/bin/env python3

import hashlib
from secret import FLAG


# Hash algorithm
# 4-byte / 32-bit hash --> 2^32 possible hash values
def custom_hash(string):
mini_md5 = hashlib.md5(string.encode()).digest()[:4].hex()
return mini_md5


# Main code

print("\033[1mHappy birthday!\033[0m 🥳🎉🎉")
print("If you give me two different strings that have the same hash value, I will give you the \033[1mFLAG\033[0m.")
print("There's one little catch, however: " + 'both strings have to start with \033[1m"SMT"\033[0m :)')

str1 = input("String 1: ")
str2 = input("String 2: ")

try:
if str1 == str2:
print("❌ You have to input two different strings!")

else:
if str1[:3] == "SMT" and str2[:3] == "SMT":
hash1 = custom_hash(str1)
hash2 = custom_hash(str2)
print(f"(Hash) String 1: {hash1}")
print(f"(Hash) String 2: {hash2}")
if hash1 == hash2:
print(f"✅ Same hash... Congratulations! You deserve a flag: \033[93m{FLAG.decode()}\033[0m")
else:
print(f"❌ Different hash... Try again!")
else:
print('❌ Remember, both strings have to start with "SMT"!')

except Exception as e:
print(f"⚠️ Error: {e}")

Looking at the challenge, custom_hash is output the first 4 bytes (32 bits) of MD5. Our goal is to find two different strings that start with “SMT” and that produce the same 32-bit MD5 prefix.

We can generate many candidates with “SMT” prefix and random suffix, compute the custom_hash digest, map them, and grab the collision pair.

import hashlib, random, string

def custom_hash(s: str) -> str:
return hashlib.md5(s.encode()).digest()[:4].hex()

def rand_suffix(n=12):
alphabet = string.ascii_letters + string.digits
return ''.join(random.choice(alphabet) for _ in range(n))

seen = {}
while True:
s = "SMT" + rand_suffix(12)
h = custom_hash(s)
if h in seen and seen[h] != s:
print("String 1:", seen[h])
print("String 2:", s)
print("Hash:", h)
break
seen[h] = s

This is the collision pair I found.

The flag is obtained: SMT2025{saengil_chukha_hamnida}.

Press enter or click to view image in full size

--

--