# Slashroot Final 2024
This is a short writeup for Slashroot 8.0's finals. This CTF was held on a Friday so it's in a bit of a weird mood, as most people have work or classes. Nevertheless, I think we still placed first, as competition was a bit light. This writeup is mostly just a one to one copy from my team's official writeup in Indonesian.
## Cry/Habbo
Chall cukup sederhana, dimana goal utama adalah forge token admin. Token dalam chall ini berupa JSON yang encrypted dengan AES ECB, dan kita diberikan input nama pada JSON, sehingga solusi cukup straightforward, cukup manipulasi block dari enkripsi.
```python=
class Token:
def __init__(
self,
name: str = "",
timestamp: int = 0,
rand: str = RANDOM,
admin: bool = False
) -> None:
if timestamp == 0:
timestamp = int(datetime.now().timestamp())
self.rand = rand
self.name = name
self.timestamp = timestamp
self.admin = admin
def authenticate(self):
if self.admin and self.rand != RANDOM and self.timestamp == -1:
return True
else:
return False
def is_expired(self):
now = int(datetime.now().timestamp())
if self.timestamp == -1:
return False
elif self.timestamp + 60 < now:
return True
else:
return False
class HabboHotel:
def __init__(self) -> None:
self.aes = AES.new(KEY, AES.MODE_ECB)
def strip_keys(self, token: dict):
new = {}
for key in token:
value = token[key]
if type(key) == str:
new[key.strip()] = value
else:
new[key] = value
return new
def generate_token(self, name: str):
try:
self.token = Token(name)
token_str = json.dumps(self.token.__dict__)
print("token block:", [token_str[i:i+16] for i in range(0, len(token_str), 16)])
self.enc_token = self.aes.encrypt(pad(token_str.encode(), 16)).hex()
except Exception as e:
return f"Faulty token :( {repr(e)}"
def read_token(self, enc_token: str):
try:
dec_token = self.aes.decrypt(unhexlify(enc_token))
print(unpad(dec_token, 16).decode())
data = self.strip_keys(json.loads(unpad(dec_token, 16).decode()))
self.token = Token(**data)
self.enc_token = enc_token
except Exception as e:
return f"Faulty token :( {repr(e)}"
```
Terdapat tiga bypass utama yang harus dilakukan:
1. Merubah value random
2. Merubah value timestamp
3. Merubah value admin
Pertama, untuk merubah value random, ide dilakukan adalah mengambil block `","timestamp": ` yang akan menutup string random (truncate) dan memulai key timestamp.
Kemudian, value timestamp dapat diubah dengan memasukan nama ` -1 ` dengan banyak spasi, sehingga terdapat block yang hanya berisi -1 dan spasi.
Terakhir, untuk mengubah value admin, diambil block ` "name β¦` dan `65005, "admin": ` untuk memperoleh key admin, dan true dapat diperoleh dengan metode yang serupa dengan timestamp.
Payload akhir yang diharapkan terbentuk (setelah decrypt) adalah sebagai berikut:
`{"rand": "3731e3", "timestamp": -1, "name ": 65005, "admin": true }`
### Solver
```python=
from azunyan.conn import process, remote
#r = process(['python3', 'chall.py'], level='debug')
r = remote('139.59.99.85', 10012, level='debug')
def login(tok: str):
r.sendlineafter(b'Input:', b'2')
r.sendlineafter(b'Token:', tok.encode())
r.interactive()
def generate_token(payload: str):
r.sendlineafter(b'Input:', b'1')
r.sendlineafter(b'Your name: ', payload.encode())
r.recvuntil(b'Here is your token:')
tok = r.recvtype(str)
r.sendlineafter(b'Input: ', b'3')
return tok
BLOCK_SIZE = 32
tok = generate_token('true')
close = tok[-BLOCK_SIZE:]
first_block = tok[:BLOCK_SIZE]
name_block = tok[BLOCK_SIZE:2*BLOCK_SIZE]
tok = generate_token('trueaaaaaaaaaa')
close_str_timestamp_block = tok[BLOCK_SIZE * 3 : BLOCK_SIZE * 4]
tok = generate_token(" -1, ")
min_1_block = tok[BLOCK_SIZE * 3 : BLOCK_SIZE * 4]
tok = generate_token("name -1, ")
open_str_block = tok[BLOCK_SIZE * 2 : BLOCK_SIZE * 3]
tok = generate_token(": -1, ")
close_property = tok[BLOCK_SIZE * 2 : BLOCK_SIZE * 3]
tok = generate_token('aaaaaaaaa')
admin_block = tok[BLOCK_SIZE * 4 : BLOCK_SIZE * 5]
tok = generate_token(" true ")
true_block = tok[BLOCK_SIZE * 3 : BLOCK_SIZE * 4]
login(first_block + close_str_timestamp_block + min_1_block + open_str_block + close_property + admin_block + true_block + close)
```
## Cry/Stacking
Chall pada dasarnya hanya classical cipher yang vigenere-like. Solusi hanya berupa optimasi brute force.
```python=
class Stacker:
def __init__(self, key: str) -> None:
self.charset = string.ascii_uppercase
self.rounds = 9
self.key, self.x = self.generate_keypair(key)
self.mixed_charset = self.mix_charset()
def generate_keypair(self, key: str):
key = key.upper()
x = 0
for k in key:
x += self.charset.index(k)
return key, x % len(self.charset)
def mix_charset(self):
charset_length = len(self.charset)
key_length = len(self.key)
blocks = [self.charset[i:i+key_length] for i in range(0, charset_length, key_length)][::-1]
final_charset = ""
for i in range(key_length):
for block in blocks:
try:
final_charset += block[i]
except IndexError:
pass
return final_charset
def sub(self, text: str):
result = ""
for i, p in enumerate(text):
key_loc = self.mixed_charset.index(self.key[i%len(self.key)])
plain_loc = self.mixed_charset.index(p)
result += self.mixed_charset[(plain_loc + key_loc) % 26]
return result
def flip(self, text: str):
result = ""
for c in text:
c_loc = self.charset.index(c)
result += self.mixed_charset[c_loc]
return result
def shift(self, text: str):
result = ""
for c in text:
c_loc = self.charset.index(c)
result += self.charset[(c_loc + self.x) % len(self.charset)]
return result
def encrypt(self, plaintext: str):
data = plaintext.upper()
for _ in range(self.rounds):
data = self.sub(data)
data = self.flip(data)
data = self.shift(data)
return data
```
Pertama, kita akan mengimplementasikan fungsi decrypt. Ini sangat mudah karena semua operasi encrypt reversible. Kemudian, perhatikan bahwa sebagian besar enkripsi hanya bergantung pada key_length dan nilai x, dan selain itu, enkripsi sangat mirip dengan vigenere cipher. Strategi pada dasarnya adalah menebak key_length dan x, dan kemudian membrute key hingga plaintext sesuai. Mengingat bahwa search space sangat kecil, brute force sederhana sudah cukup.
```python=
import string
import re
from tqdm import trange, tqdm
CHARSET = string.ascii_uppercase
class Stacker:
def __init__(self, key: str) -> None:
self.charset = string.ascii_uppercase
self.rounds = 9
self.key, self.x = self.generate_keypair(key)
self.mixed_charset = self.mix_charset()
def reset(self, key: str, x: int) -> None:
self.key = key
self.x = x
self.mixed_charset = self.mix_charset()
def generate_keypair(self, key: str):
key = key.upper()
x = 0
for k in key:
x += self.charset.index(k)
return key, x % len(self.charset)
def mix_charset(self):
charset_length = len(self.charset)
key_length = len(self.key)
blocks = [self.charset[i:i+key_length] for i in range(0, charset_length, key_length)][::-1]
final_charset = ""
for i in range(key_length):
for block in blocks:
try:
final_charset += block[i]
except IndexError:
pass
return final_charset
def sub(self, text: str):
result = ""
for i, p in enumerate(text):
key_loc = self.mixed_charset.index(self.key[i%len(self.key)])
plain_loc = self.mixed_charset.index(p)
result += self.mixed_charset[(plain_loc + key_loc) % 26]
return result
def invsub(self, text: str):
result = ""
for i, p in enumerate(text):
key_loc = self.mixed_charset.index(self.key[i%len(self.key)])
plain_loc = self.mixed_charset.index(p)
result += self.mixed_charset[(plain_loc - key_loc) % 26]
return result
def flip(self, text: str):
result = ""
for c in text:
c_loc = self.charset.index(c)
result += self.mixed_charset[c_loc]
return result
def invflip(self, text: str):
result = ""
for c in text:
c_loc = self.mixed_charset.index(c)
result += self.charset[c_loc]
return result
def shift(self, text: str):
result = ""
for c in text:
c_loc = self.charset.index(c)
result += self.charset[(c_loc + self.x) % len(self.charset)]
return result
def invshift(self, text: str):
result = ""
for c in text:
c_loc = self.charset.index(c)
result += self.charset[(c_loc - self.x) % len(self.charset)]
return result
def encrypt(self, plaintext: str):
data = plaintext.upper()
for _ in range(self.rounds):
data = self.sub(data)
data = self.flip(data)
data = self.shift(data)
return data
def decrypt(self, ciphertext: str):
data = ciphertext.upper()
for _ in range(self.rounds):
data = self.invshift(data)
data = self.invflip(data)
data = self.invsub(data)
return data
sub = Stacker('abced')
pts = []
with open("./plain.txt") as f:
references = f.read().split("\n")
for line in references:
plain = "".join(re.findall(r'[a-zA-Z]', line))
pts.append(plain.upper())
cts = []
with open("./plain.enc", "r") as h:
for line in h.read().split("\n"):
plain = "".join(re.findall(r'[a-zA-Z]', line))
cts.append(plain)
def brute():
for len_guess in trange(2, 15):
for x_guess in range(len(CHARSET)):
known = ""
while len(known) != len_guess:
cur_len = len(known)
for c in CHARSET:
pw_guess = known + c + 'A' * (len_guess - len(known) - 1)
assert(len(pw_guess) == len_guess)
sub.reset(pw_guess, x_guess)
f = True
for pt, ct in zip(pts, cts):
dec = sub.decrypt(ct)
if not f: break
for i in range(0, len(pt), len_guess):
for j in range(i, i + len(known) + 1):
if j >= len(pt): break
if dec[j] != pt[j]:
f = False
break
if f:
known += c
print(f"FOUND: {known}")
sub.__init__(known)
break
if cur_len == len(known):
break
if len(known) == len_guess:
print(f'FOUND: {known}')
return
brute()
flag_enc = "NLPDNZGMMEPPXQNYRJDVKWJENTKLLKWJGRK"
print(f"slashroot8{{{sub.decrypt(flag_enc)}}}")
```
## Cry/Sprei Gratis
Chall cukup straightforward, sepertinya hanya RSA dengan beberapa leak fungsi dari secret bits.
```python=
from Crypto.Util.number import *
from base64 import urlsafe_b64encode
from hashlib import sha256
import secrets
SPREI = "slashroot{omke_gas}"# if "SPREI" not in os.environ else os.environ['SPREI']
class OMKE_GAS:
def __init__(self, bit):
self.bit = bit
self.p = getPrime(self.bit//2)
self.q = getPrime(self.bit//2)
self.n = self.p * self.q
self.e = 0x10001
self.d = inverse(self.e, (self.p-1)*(self.q-1))
def fufufafa(self, x, y):
a = x ^ (y >> 25)
b = y ^ (x << 52) & ((1 << 64) - 1)
a ^= ((b << 11) & ((1 << 64) - 1)) | 0xf0f0fAfAf0f0fAfA
b ^= ((a >> 17) & ((1 << 64) - 1)) & 0xf4f4f4f4f0f0f0f0
return a, b
def makan_gratis(self, m):
h = int(sha256(m.encode()).hexdigest(), 16)
mp = pow(h, self.d % (self.p-1), self.p)
mq = pow(h, self.d % (self.q-1), self.q)
c = mq + self.q * ((mp - mq) * pow(self.q, -1, self.p) % self.p)
a, b = self.fufufafa(mq >> ((self.bit//2)-64), secrets.randbits(64))
return b'.'.join([urlsafe_b64encode(long_to_bytes(i)).strip(b'=') for i in [c, a, b]]).decode()
def sprei_gratis(self, sprei):
self.cipher = pow(bytes_to_long(sprei), self.e, self.n)
return b'.'.join([urlsafe_b64encode(long_to_bytes(i)).strip(b'=') for i in [self.cipher, self.n]]).decode()
chal = OMKE_GAS(1024)
print("plizzzz wokkk π£π£π’π’ aku mau sprei gratis ibuku itu agak miskin πππππ kok saya liat livestream mu kenapa kamu ketawa hah π‘π‘π‘π‘βΌοΈβΌοΈmana sprei gratis yang kau janjikan itu hah disaat pendukungmu oke gas oke gas πππkamu malah ketawa πππ€£ itu ga rizzpek banget wokππ dan jangan lupa makanan siang gratisnya wok πππ π π ")
print(f"Sprei gratis tapi dienkripsi fufufafa: {chal.sprei_gratis(SPREI)}")
while True:
choice = int(input("Ketik 1 untuk dapat makan siang gratis: "))
if choice == 1:
m = input("Request lauk makan siang gratis: ")
print(f"Vocer makan siang gratis tapi dienkripsi fufufafa: {chal.makan_gratis(m)}")
else:
break
```
Pertama, perhatikan fungsi fufufafa. Dua operasi xor terakhir dapat di-reverse dengan mudah, cukup dengan melakukan xor yang sama. Dari situ, kita peroleh `a = x ^ (y >> 25)`. Dengan asumsi x dan y adalah 64 bit, kita dapat peroleh 39 bit dari x.
Pada fungsi makan_gratis, kita diberikan nilai a dan b dari output fufufafa, dan nilai `c = mq + q * pi` untuk suatu `pi < p`. Welp, sepertinya ini approximate GCD klasik, dimana kita diberikan 39 bit MSB dari residu mq. Solver sebenarnya dapat dioptimasi lebih lanjut dengan memanfaatkan nilai `n = pq` dalam lattice reduction, tetapi setelah mencoba reduksi dengan 80 equation `c`, jawaban sudah diperoleh sehingga optimasi lebih lanjut tidak diperlukan.
### Solver
```python=
from secrets import randbits
from Crypto.Util.number import *
from base64 import urlsafe_b64encode, urlsafe_b64decode
from libnum import n2s, s2n
from hashlib import sha256
from sage.all import *
from azunyan.conn import remote
import secrets
r = remote('ctf.slashrootctf.id', 10011, level='debug')
def fufufafa(x, y):
a = x ^ (y >> 25)
b = y ^ (x << 52) & ((1 << 64) - 1)
a ^= ((b << 11) & ((1 << 64) - 1)) | 0xf0f0fAfAf0f0fAfA
b ^= ((a >> 17) & ((1 << 64) - 1)) & 0xf4f4f4f4f0f0f0f0
return a, b
def invfufufafa(a, b):
b ^= ((a >> 17) & ((1 << 64) - 1)) & 0xf4f4f4f4f0f0f0f0
a ^= ((b << 11) & ((1 << 64) - 1)) | 0xf0f0fAfAf0f0fAfA
return a
ms = [str(i) for i in range(80)]
ct = []
def deserialize(ct: str):
nums = ct.split('.')
res = []
for num in nums:
res.append(s2n(urlsafe_b64decode(num + '=======')))
return res
def makan(s: str):
r.sendlineafter(b'Ketik 1 untuk dapat makan siang gratis:', b'1')
r.sendlineafter(b'Request lauk makan siang gratis:', s.encode())
r.recvuntil(b'Vocer makan siang gratis tapi dienkripsi fufufafa: ')
return r.recvtype(str)
r.recvuntil(b'Sprei gratis tapi dienkripsi fufufafa:')
sprei = r.recvtype(str)
ctflag, n = deserialize(sprei)
lost_bits = 25
l = 64
for m in ms:
c, a, b = deserialize(makan(m))
a = invfufufafa(a, b)
a = (a >> (l - lost_bits)) << (l - lost_bits)
a = a << (512 - 64)
ct.append(c - a)
# approximate gcd
arr = [[1] + [ct[i] for i in range(1, len(ct))]]
arr += [[0 if i!=j else -ct[0] for j in range(len(ct))] for i in range(1, len(ct))]
B = matrix(ZZ, arr)
B = B.LLL()
q = abs(B[0, 0])
p = (ct[0]) // q
p = int(p)
print(p.bit_length())
print(n % p == 0)
q = n // p
d = pow(0x10001, -1, (p-1) * (q-1))
d = int(d)
print(n2s(int(pow(ctflag, d, n))))
```