ssmctf is organised by ssmct which i found out is the largest ctf group in sg! They were super friendly and helpful throughout the ctf. More details
1. Peekaboo!! (Forensics)
We are given a database.db file which contains three tables. Using string database.db we can several suspicious strings in the flag format. Concatenating these strings gives us the flag.
2. Magic spell (Forensics)
We are given a magic-spell.epub file, but it is formatted incorrectly.
- Convert the epub to zip by renaming it to
magic-spell.zip. - Extract the zip file.
- We find a file in
OEBPS/namedcontent.opf - The flag was hidden in Base64 in the UUID field of
content.opf
[Not relevant] but i wasted time trying to fix the epub file since there was extraneous syntax error and using calibre πΏ
3. PyMath (Misc)
The source code defines the problem as such:
We need to input strg1 and strg2 such that
- They pass their respective checks.
- The final
gcdcondition is satisfied.
Check for strg1:
- Length β€ 3.
- float(strg1) != 1.
- All characters are ASCII.
Check for strg2:
- No β0β or β1β.
- No letters or non-ASCII.
The gcd condition is:
math.gcd(int((num3 ** num2) % num1), num3) == num3
which means (num3^num2) % num1 must be a multiple of num3.
Since num3 is prime,
for num3^(num2) % num1 == 0, this means num1 must be a multiple of num3.
From the source code, num1 is int(strg1), while num3 is a random 53-bit prime. An obvious choice for strg1 is 1 but this violates the check for strg1.
Trying strg1 = 0.5, strg2 = 2 works since:
num3^2 % 0.5
but note that x % 0.5 is always 0 for any integer x, so this is a special case. And gcd(0, num3) == num3 is always true.
So inputing these, we get the flag.
4. Almost the same (Misc)
A simple solve script to compare the difference between the two files and print the flag.
import difflib
def get_file_diff(file1_path, file2_path):
try:
with open(file1_path, 'r') as file1, open(file2_path, 'r') as file2:
file1_lines = file1.readlines()
file2_lines = file2.readlines()
except FileNotFoundError:
return "Error: One or both files not found."
diff = difflib.unified_diff(
file1_lines, file2_lines, fromfile=file1_path, tofile=file2_path)
return ''.join(diff)
file1_path = 'alter'
file2_path = 'original'
diff_output = get_file_diff(file1_path, file2_path)
print(diff_output)
5. Spaces have meaning (Forensics)
The document contains five identical Lorem Ipsum paragraphs separated by specific patterns of spaces and tabs, followed by a long sequence of spaces and tabs at the end. A common approach in CTF challenges is to interpret spaces and tabs as binary digits (e.g., space as 0, tab as 1) and convert the resulting binary string to ASCII characters.
import re
def extract_flag(filename):
with open(filename, 'r', encoding='utf-8') as file:
content = file.read()
# Extract the trailing spaces and tabs after the last paragraph
# Use regex to find the sequence of spaces and tabs at the end
match = re.search(r'[ \t]+$', content)
if not match:
return "No trailing spaces/tabs found"
sequence = match.group(0)
# Convert spaces to 0 and tabs to 1
binary = ''.join('0' if char == ' ' else '1' for char in sequence)
# Ensure binary string length is divisible by 8
if len(binary) % 8 != 0:
print(f"Warning: Binary length {len(binary)} is not divisible by 8")
# Convert binary to ASCII
flag = ''
for i in range(0, len(binary), 8):
byte = binary[i:i+8]
if len(byte) == 8:
char = chr(int(byte, 2))
flag += char
return flag
6. tarriff evaluations (Crypto)
In chall.py, the problem involves RSA with e = 0x10001, n = p * q, ct, and p - leak.
since VAR_1 = 10000!, which is extremely large, the leak term is likely to be very close to zero.
p - leak is a 309-digit number, while n is a 2048-bit number, which is about 617 digits, so p should be around 1024 bits, which is about 308 digits, so yes, p - leak is on the order of p.
a. Define the given values
from Crypto.Util.number import long_to_bytes
ct = 4781314062204780803707083785029526695515373328754437058360148481983776761238818824513873915894272458596935350980408035233897241944785362251336400778024643919001797904298257118956520404524323559715746000068412954302542636386259106528078043580652880881897576074268512303671155030218729500291454403743708608548480977506269831895151273739405520980230118053504663654907891856282911532618045404860638219785802714538998317878998629883714243141387091669847359587414088871667047728752324989888015074594967676359710063823610770145417994477795587582507026793311537537265129227699483332016257848146975506362526106699486237406763
n = 14327967778933513684866741755591664860009753335289842801500138776246927388908565045549036953515821363782360195603223134969430251873746384902650245859216942478227679940968216392374020987088032189979649651207466678612481243028925015679241748401963491207527485705180215317627141927757414725735045204253853660905080780363956413549452746600539875673613497721615180116534516152310881448965832037145076142117113964080856096271518220684895761507535874424951586089881169141701209499788164074440619615229101530812012074536366473358651533916950773961659249817974974369435476243005102815457927657745687933477253799476265131761443
p_minus_leak = 116329608700921268219766270234310817804604851928199730711558466567902777993123659585400002712294671354175439631889219638125564836115230152964446576187727220004877191154015250469077590936104201390817315774241347283869619414516623651771211649835826869325694760344337757904706438096211760033894962243458863212410
b. Compute q
p = p_minus_leak
q = n // p
c. Compute phi (p - 1)(q - 1) and d
from sage.all import inverse_mod
e = 0x10001
phi = (p - 1) * (q - 1)
d = inverse_mod(e, phi)
d. Decrypt the ciphertext
pt = pow(ct, d, n)
flag = long_to_bytes(pt)
print(flag)
Note when we do the assertion that p * q == n, it will fail because p is not exactly equal to the original p, but it is very close, so we can ignore this. so we set p = round(p_minus_leak)
we can also bruteforce the value of p since it is very close to p_minus_leak, so we can try values around it.
found = False
for delta in range(-1000, 1001):
candidate_p = p + delta
if n % candidate_p == 0:
p = candidate_p
found = True
break
After running it in sage, we get some gibberish
b"BM\xaa@\x00d1<\x96\x88t\xcb\x17U!\x03@\xff\x0b{\xa9}\x83O\x9c`\xc7\xcd\xd5\x8d\xd7\xbc)\x9a\xe9\xd7.o\xcf\xa2\n\x1a\x80\xdf5\xab\xc1\x11?\x05QY\xb0'\xf17\xa7(\xe6P\xbf]\x0b\x86)wI\xb1\x02(X3g\xa3d1y\xe0y\xe43\xe2y|\xacp\x1b\xe0U*\xf0\xd2\x87B\xd8\xae\xb0~\x19X\x1c\xba\xe7\x00\xf5\x1d\xa8\x1f\x03~X\xc89\xdb\xcdX\xd6\x98\xff\x00\xe8\x11u\xe8U\xdaCc\x91jJ#\xa6\xc9\x19\x07%>\x9f\xdd\xf2\xc2\xdf\xdd\xfc\x98!\xac#b\xd6\xe3\xa6\xe1\rW\xda\x87\x8f+\xb4\x1b_0\xe7F\t\xa2\xb3f{Q\x98\xa6R\xc8\xaf\xa4W_Kw\x01\xa8w\x91\xfe\xcb\x18\x9a\xda\xd4qJ\r\xba\xf8\xd64\xc70\xca3\xa5\xce\xa3>\r\x83\xe5\xd1\r\xa6\x84,\xaf\xa2\xf4Y#f\xe2\xb2\xa9]\x87\xff\xa3X\x9b\xdd.\x1c\x95\x8d\x0b&\x06!\xd7\x9ap\xd5h\x11_[[\xfe\xf89\xc8\xf7\xc8\xfe|"
Interestingly, the byte string starts with b"BM\xaa@\x00d1..." so it may be saying itβs a .bmp file.
Convering it to a hex string
53534d4354467b5468697320697320612067726561742074696d6520746f2067657420726963682c20726963686572207468616e2065766572206265666f726521212120546865206d61726b6574732061726520676f696e6720746f20626f6f6d2c207468652073746f636b20697320676f696e6720746f20626f6f6d217d
we then save it as a .bmp file and open it. The flag is written there in text π
7. Forensics Master (Forensics)
Using file * in the directory, we see 299 files with various extensions (e.g., .doc, .php, .bmp, .zip, .txt, etc.). All files except 179.zip are identified as βASCII text, with no line terminators,β which is unusual for typical file formats like .bmp, .mp3, or .pdf.
This suggests the files may contain raw data (e.g., numbers or encoded strings) rather than their expected content. Expectedly, it contained an image of a flag.
8. Messy Workplace 1 (Misc)
We are given a script where they scramble parts of the flag.
First part
- Input:
"\_dwe35rse0_psu" - Current function places even-indexed chars at front half, odd-indexed chars at back half
Second part
- Input:
"3_hoeeP7e_em_e1}" - Performs a series of swaps in reverse order
Third part
- Input:
"SSStMnCeT3Fm{0c" - Reorders characters based on the order array
So we needa solve each part step by step.
- For the first part, we can write a function to unscramble the string by placing even-indexed characters in the front half and odd-indexed characters in the back half.
- For the second part, we need to reverse the swaps given in the input.
- For the third part, we need to reorder the characters based on the provided order.
def solve_part1(scrambled):
result = [''] * len(scrambled)
front = 0
back = len(scrambled) - 1
for i, c in enumerate(scrambled):
if i % 2 == 0:
result[i//2] = c
else:
result[len(scrambled) - (i//2) - 1] = c
return ''.join(result)
def solve_part2(scrambled, swaps):
s = list(scrambled)
for i, j in reversed(swaps):
s[i], s[j] = s[j], s[i]
return ''.join(s)
def solve_part3(scrambled, order):
original = [''] * len(scrambled)
for i, idx in enumerate(order):
original[idx] = scrambled[i]
return ''.join(original)
9. Rotary (Reverse Engineering)
We are given a Python script that processes a flag (a secret string) through a series of operations involving five βbeltsβ (circular conveyor belt-like structures).
Each belt has:
- A length (number of positions)
- A shift value (how many positions it rotates each time)
- Specific positions where it interacts with other belts
Parts of the flag are loaded onto certain belts:
- belt2 gets flag[0:12] (first 12 characters)
- belt4 gets flag[12:24] (next 12 characters)
- belt5 gets flag[24:] (remaining characters)
Specific flag characters are swapped into certain belt positions:
- flag[3] β belt1[1]
- flag[1] β belt3[2]
- flag[13] β belt3[4]
- flag[24] β belt1[6]
- flag[28] β belt3[9]
30 Iterations
In each iteration, the belts:
- Rotate (shift left by their shift value).
- Swap characters with other belts at specific positions.
After 30 iterations
Output:
- belt1: bcJ5*4t
- belt2: rrjcCfFct0}y
- belt3: SStMBmTe*{
- belt4: 7B_J1h_LYsmY
- belt5: tFrx{mI_D3wgQB0
Our goal is to reverse these operations to recover the original flag.
Solve solution: Reverses rotations correctly (rotates right).
- Undoes swaps in reverse order.
- Properly extracts the flag by accounting for all initial loads and swaps.
class ReverseBelt:
def __init__(self, length, shift, positions, initial_state):
self.belt = list(initial_state)
self.length = length
self.shift = shift
self.positions = positions
def swap(self, in_, position):
self.belt[position] = in_
def rotate_reverse(self):
for _ in range(self.shift):
self.belt = [self.belt[-1]] + self.belt[:-1]
return self.belt
belt1 = ReverseBelt(7, 1, [1, 6], "bcJ5_4t")
belt2 = ReverseBelt(12, 2, [3, 8], "rrjcCfFct0}y")
belt3 = ReverseBelt(10, 3, [2, 4, 9], "SStMBmTe_{")
belt4 = ReverseBelt(12, 2, [1], "7B_J1h_LYsmY")
belt5 = ReverseBelt(15, 2, [0, 4], "tFrx{mI_D3wgQB0")
# 30 iterations
for _ in range(30):
rotated = belt5.rotate_reverse()
belt3.swap(rotated[4], 9)
belt1.swap(rotated[0], 6)
rotated = belt4.rotate_reverse()
belt3.swap(rotated[1], 4)
rotated = belt3.rotate_reverse()
belt2.swap(rotated[2], 8)
belt4.swap(rotated[4], 1)
belt5.swap(rotated[9], 4)
rotated = belt2.rotate_reverse()
belt1.swap(rotated[3], 1)
belt3.swap(rotated[8], 2)
rotated = belt1.rotate_reverse()
belt2.swap(rotated[1], 3)
belt5.swap(rotated[6], 0)
# Belt2 has the start
flag[:12] = belt2.belt[:12]
# Belt4 has the middle
flag[12:24] = belt4.belt[:12]
# Belt5 has the end
flag[24:36] = belt5.belt[:12]
flag[3] = belt1.belt[1] # belt1.swap(flag[3], 1)
flag[1] = belt3.belt[2] # belt3.swap(flag[1], 2)
flag[13] = belt3.belt[4] # belt3.swap(flag[13], 4)
flag[24] = belt1.belt[6] # belt1.swap(flag[24], 6)
flag[28] = belt3.belt[9] # belt3.swap(flag[28], 9)