# HCMUS-CTF 2025 writeups
## Forensic
### TLS Challenge

Ở challenge này nhận được file pcap và file log chứa ssl/tls key. Do đó dễ dàng đoán được key này sẽ được dùng để giải mã giao thức TLS có trong file pcap để đọc được plaintext.
Triển khai: mở Wireshark và import file key

Khi đó gói tin HTTP chứa flag sẽ hiện ra
**FLAG: HCMUS-CTF{tls_tr@ffic_@n@lysis_ch@ll3ng3}**
### Trashbin

Nhận được một file pcap chứa các gói tin SMB2

Theo như description và hoạt động của các gói tin thì trong luồng network có hàng loạt các file "trash" được gửi và ẩn trong đó là một file flag thực sự.
Kiểm tra và tải về toàn bộ file zip bắt được.

Sau đó, dùng lệnh bash để unzip và tìm trong file zip với pattern `HCMUS` thì ta được flag
```bash!
for zip in *.zip; do
unzip -p "$zip" '*.txt 2>/dev/null | grep -iH 'HCMUS' && echo "[+] Found in: $zip"
done
```

Kết quả flag nằm trong file %5cflag%5cflagishere_228.zip
**FLAG: HCMUS-CTF{pr0t3ct_y0ur_SMB_0r_d1e}**
### Disk Partition

Nhận được một file image, cùng với đề bài là Disk Partition, mở FTK Imager

image gồm 3 partition và một vùng Unpartitioned Space: Partition 1 chứa file system NTFS, Partition 2 chứa file system HFS+ và Partition 3 chứa ext4
Check với NTFS trong thư mục root thì thấy chứa dữ liệu các file fake flag giống với yêu cầu của đề bài.
Vì là hệ thống NTFS nên nếu flag nhỏ thì có thể sẽ lưu dạng resident trực tiếp trong $MFT, tuy nhiên khi kiểm tra thì cũng chỉ có fake flag, tương tự với $MFTDir.
Do đó nên sẽ kiểm tra tiếp sang HFS+. Ở partition này lại có thêm một vùng unallocated space khá đáng nghi, kiểm tra vùng này trước thì nhận được flag nằm trong một file Unallocated. Ý tưởng của bài này có lẽ là phân vùng bị format lại nhưng dữ liệu chưa bị ghi đè nên vẫn tồn tại ở dạng unallocated text.

**FLAG: HCMUS-CTF{1gn0r3_+h3_n01$3_f1nd_m@c}**
### File Hidden

Chall này cung cấp một file .wav và gợi ý rằng flag được dấu ở sound waves.
Oke, let check phần spectrogram

Không có hình ảnh hiện thị nào có thể khai thác, tuy nhiên có thể thấy âm thanh được chia ra làm 2 stero channels. Có khả năng flag được dấu ở các bit thấp trong 2 stereo.

Thông tin: Stream #0:0: Audio: pcm_s16le ([1][0][0][0] / 0x0001), 44100 Hz, 2 channels, s16, 1411 kb/s
Tiếp theo, check thông tin hex

Ở đây là dạng WAVE và có phần `data`, khả năng cao là dữ liệu được dấu tại đây.
Tìm được nguồn nói khá chi tiết về phần này: https://medium.com/analytics-vidhya/get-secret-message-from-audio-file-8769421205c3 và https://github.com/x-vespiary/writeup/tree/master/2020/05-defenit/baby_steganography
Tập trung vào nguồn LSB, thử trích xuất chuỗi thông tin này dưới dạng LSB.

Oke, ta đã đi đúng hướng, kết quả thấy có ký tự "PK" và "flag" thì khả năng cao đây là một file zip chứa file flag.
Sửa đổi lại code để xuất ra được các file flag
```python!
#!/usr/bin/env python3
# extract_zip_from_wav.py
# ------------------------------------------------------------
# Giải bài CTF stego–audio: LSB của WAV chứa file ZIP (flag).
#
# Usage:
# python extract_zip_from_wav.py problem.wav
# ------------------------------------------------------------
import sys, re, wave, zipfile, io
from pathlib import Path
MAGIC_ZIP = b"PK\x03\x04"
MAGIC_EOCD = b"PK\x05\x06" # End‑of‑central‑directory
def extract_lsb(bs: bytes) -> bytes:
"""
Ghép bit thấp nhất của từng byte (big‑endian trong 1 byte dữ liệu).
8 mẫu → 1 byte result.
"""
out, cur = bytearray(), 0
for i, b in enumerate(bs):
cur |= (b & 1) << (7 - (i & 7))
if (i & 7) == 7:
out.append(cur)
cur = 0
return bytes(out)
def find_zip_blob(raw: bytes) -> bytes:
"""
Tìm đoạn ZIP gói trong raw LSB stream.
Trả về bytes của ZIP; nếu thiếu footer thì trả toàn bộ từ header.
"""
start = raw.find(MAGIC_ZIP)
if start == -1:
sys.exit("❌ Không tìm thấy header ZIP (PK\\x03\\x04) trong stream!")
# cố tìm EOCD (footer) để cắt gọn
m = re.search(MAGIC_EOCD + b".{18}", raw[start:], flags=re.DOTALL)
end = start + m.end() if m else len(raw)
return raw[start:end]
def main(wav_path: str):
wav = Path(wav_path)
if not wav.is_file():
sys.exit("❌ Không tìm thấy file WAV!")
# 1) Đọc toàn bộ byte mẫu
with wave.open(wav_path, "rb") as wf:
print("Params:", wf.getparams())
samples = wf.readframes(wf.getnframes())
# 2) Extract LSB stream
lsb_stream = extract_lsb(samples)
print("• Đã trích", len(lsb_stream), "byte LSB")
# 3) Tách blob ZIP
zip_blob = find_zip_blob(lsb_stream)
out_zip = wav.with_suffix(".stego.zip")
out_zip.write_bytes(zip_blob)
print(f"• Ghi file ZIP: {out_zip} ({len(zip_blob)} bytes)")
# 4) Thử liệt kê / giải nén luôn (nếu đủ footer)
try:
with zipfile.ZipFile(io.BytesIO(zip_blob)) as zf:
print("• Nội dung ZIP:")
zf.printdir()
# nếu có flag.txt thì in nhanh
for name in zf.namelist():
if "flag" in name.lower():
print("----- flag -----")
print(zf.read(name).decode(errors="ignore"))
print("----------------")
break
except zipfile.BadZipFile:
print("ZIP chưa hoàn chỉnh")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(f"Usage: {sys.argv[0]} problem.wav")
main(sys.argv[1])
```
Kết quả: thu được file flag/flag.txt


**FLAG: HCMUS-CTF{Th13nLy_0i_J4ck_5M1ll10n}**
## Misc
### Is This Bad Apple? - The Sequel

link youtube: https://www.youtube.com/watch?v=X-HSIqgm9Rs
Check metadata ra flag

**FLAG: HCMUS-CTF{Right_under_your_nose_lol}**
## AI
### PixelPingu

Truy cập vào website, bao gồm giao diện vẽ pixel, một nút submit và dựa trên điểm sẽ nhận được flag, upload đầu tiên với pixel rỗng thì nhận được phần đầu của flag

Trong challenge này thì mình còn nhận được một source của server sử dụng hai model để đánh giá một bức tranh pixel được vẽ về chim cánh cụt. Có khả năng càng giống thì càng được điểm cao và tìm được flag.
Tập trung vào file judge.py thì ta thấy rằng flag được chia ra làm 4 part và kết quả mỗi part sẽ dựa trên đánh giá của hai model.
| Giám khảo | Kiến trúc | Điểm mạnh | Khởi tạo & chế độ |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| **Judge One** | **ShuffleNet V2 x2.0** – mạng CNN nhẹ, dùng *channel split* & *channel shuffle* để giảm chi phí tính toán mà vẫn giữ độ chính xác | Rất nhanh, phù hợp tác vụ real‑time | Khởi tạo với `shufflenet_v2_x2_0(weights=None)` rồi nạp trọng số tùy chỉnh (nếu có) và đặt ở chế độ `eval()` |
| **Judge Two** | **RegNet X 1.6 GF** – họ mạng do Facebook AI thiết kế, tối ưu phân bổ kênh & độ sâu, cho hiệu năng cao trên ImageNet | Độ chính xác cao hơn, chịu tính toán nặng hơn | Khởi tạo tương tự với `regnet_x_1_6gf` và đặt `eval()` |

Tiếp theo, quan sát source app.py của server để quan sát cách server xử lý ảnh pixel. Ở đây, ảnh có thể được gửi bằng method POST /submit_artwork với trường canvas_data dạng json, do đó ý tưởng là sử dụng một hình ảnh chim cánh cụt thật rồi gửi đi trực tiếp.

Quan sát các bước chấm điểm trong judge.py
- Chuyển đổi dữ liệu canvas → ảnh RGB
Mảng RGBA được reshape thành 128×128×4, bỏ kênh alpha và tạo PIL.Image RGB
- Suy diễn từng mạng (no‑grad)
Hàm predict_single_model áp dụng transform, thêm batch dim, chạy forward và Softmax để lấy vector xác suất 1000 lớp ImageNet
- Rút trích kết quả & tính điểm
Lấy lớp dự đoán top‑1 và độ tin cậy (x 100 %).
Kiểm tra xem lớp đó có phải “penguin” (chỉ số 145 trong ImageNet) không
Điểm = tổng độ tin cậy của các giám khảo chỉ khi họ nhận diện đúng “penguin”, tối đa 200, rồi scale về thang 0 – 100 %
Tùy tổ hợp đúng/sai của hai giám khảo, hàm get_flag_part chia chuỗi FLAG thành 4 phần và chọn phần tương ứng
| Judge One | Judge Two | Flag part |
| --------- | --------- | --------- |
| ❌ | ❌ | part 0 |
| ✅ | ✅ | part 1 |
| ✅ | ❌ | part 2 |
| ❌ | ✅ | part 3 |
| Mục tiêu | Ý tưởng thực thi (không cần truy cập máy chủ) |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **part 0** – cả hai *không* thấy penguin | Gửi ảnh “rác” (ảnh trắng, nhiễu, hoặc một con mèo). Gần như chắc chắn cả hai model trả về lớp khác. |
| **part 1** – cả hai *đều* thấy penguin | Gửi ảnh penguin chất lượng tốt (thẳng góc, đủ thân & mỏ). Resize về 128×128 rồi nén PNG/JPEG mức vừa phải. |
| **part 2** – ShuffleNet ✅, RegNet ❌ | 1) Bắt đầu từ ảnh penguin gốc (đã đạt ✅/✅).<br>2) Thêm **nhiễu nhẹ** (FGSM ≤ ε = 4/255) chống RegNet nhưng giữ xác suất penguin của ShuffleNet. Bạn có thể tái tạo cả hai model với trọng số ImageNet và dùng kỹ thuật *one‑off adversarial* (foolbox / torchattacks).<br>3) Hoặc làm **ảnh out‑of‑focus**: Gaussian‑blur kernel 3–5 px thường đủ khiến RegNet mất niềm tin trong khi ShuffleNet (nhẹ, ít tầng sâu) vẫn còn. |
| **part 3** – RegNet ✅, ShuffleNet ❌ | 1) Tăng **độ tương phản & chi tiết** (Sharpen + CLAHE). RegNet “kén hình” cao cấp hơn nên thường vững, còn ShuffleNet dễ lạc lớp sang “bird”.<br>2) Hoặc làm ảnh **vùng crop cực hẹp**: căn giữa phần đầu/mỏ của penguin – RegNet (bộ receptive‑field rộng) vẫn nhận, ShuffleNet thì không. |
Tiến hành khai thác: tải về 1-3 ảnh cánh cụt bất kỳ (sử dụng ảnh pixel có sẵn trong server chỉ trả về part 0).
```python!
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
fetch_flag_full.py
------------------
Tự động thu thập đủ 4 flag_part từ /submit_artwork.
• B1: Gửi ảnh gốc penguin (để lấy part1).
• B2: Tạo biến thể Gaussian‑blur (lấy part2).
• B3: Sinh nhiều biến thể mạnh tay (sharpen, crop, saturation) cho tới khi tách được part3.
• B4: Gửi noise ngẫu nhiên (part0).
Cài đặt:
pip install pillow requests numpy
Chạy:
python fetch_flag_full.py -i penguin.jpg -u http://103.199.17.56:25001/submit_artwork
"""
import argparse, sys, random, requests, numpy as np
from pathlib import Path
from PIL import Image, ImageFilter, ImageEnhance, ImageOps
# ------------------------------------------------------------
# Xử lý ảnh
# ------------------------------------------------------------
def load_rgba(path: Path) -> Image.Image:
return Image.open(path).convert("RGBA").resize((128, 128))
def to_flat_rgba(img: Image.Image) -> list[int]:
return np.array(img, dtype=np.uint8).flatten().tolist()
def gaussian_blur_rgba(img: Image.Image, radius: int = 4) -> Image.Image:
return img.convert("RGB").filter(ImageFilter.GaussianBlur(radius)).convert("RGBA")
def sharpen_rgba(img: Image.Image, factor: float = 4.0) -> Image.Image:
rgb = img.convert("RGB")
rgb = ImageEnhance.Sharpness(rgb).enhance(factor)
rgb = ImageEnhance.Contrast(rgb).enhance(1.4)
return rgb.convert("RGBA")
def color_boost_rgba(img: Image.Image, factor: float = 2.0) -> Image.Image:
rgb = img.convert("RGB")
rgb = ImageEnhance.Color(rgb).enhance(factor)
return rgb.convert("RGBA")
def crop_head(img: Image.Image) -> Image.Image:
w, h = img.size
cro = img.crop((w*0.25, h*0.1, w*0.75, h*0.8)).resize((128, 128))
return cro.convert("RGBA")
def random_noise_rgba() -> Image.Image:
arr = np.random.randint(0, 256, (128, 128, 3), dtype=np.uint8)
return Image.fromarray(arr, "RGB").convert("RGBA")
# ------------------------------------------------------------
# Gửi ảnh
# ------------------------------------------------------------
def send(img: Image.Image, url: str, tag: str) -> str:
data = {"canvas_data": to_flat_rgba(img)}
r = requests.post(url, json=data, timeout=15)
r.raise_for_status()
j = r.json()
print(f"[{tag:<7}] score={j.get('judge_score'):>6} | part={j.get('flag_part')}")
return j.get("flag_part") or ""
# ------------------------------------------------------------
# Main
# ------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description="Farm 4 flag parts automatically.")
ap.add_argument("-i", "--image", required=True, help="Ảnh penguin gốc (JPG/PNG)")
ap.add_argument("-u", "--url", default="http://103.199.17.56:25001/submit_artwork",
help="Endpoint submit_artwork")
args = ap.parse_args()
base = load_rgba(Path(args.image))
collected = {} # tag -> part
parts_seen = set()
attempt = 0
# 1. Ảnh gốc (kỳ vọng part1)
collected["orig"] = send(base, args.url, "orig")
parts_seen.add(collected["orig"])
# 2. Gaussian blur (kỳ vọng part2)
collected["blur"] = send(gaussian_blur_rgba(base), args.url, "blur")
parts_seen.add(collected["blur"])
# 3. Noise (part0) – gửi trước để chắc chắn có
collected["noise"] = send(random_noise_rgba(), args.url, "noise")
parts_seen.add(collected["noise"])
# 4. Tìm part3 bằng nhiều biến thể cho tới khi mới
print("\n--- Searching for Part3 ---")
variant_funcs = [
lambda img: sharpen_rgba(img, 6),
lambda img: sharpen_rgba(img, 8),
lambda img: color_boost_rgba(img, 2.5),
crop_head,
lambda img: sharpen_rgba(color_boost_rgba(img, 2.5), 6),
]
for fn in variant_funcs:
part = send(fn(base), args.url, fn.__name__)
if part not in parts_seen:
collected["p3"] = part
parts_seen.add(part)
break
print("\n=== SUMMARY ===")
for k, v in collected.items():
print(f"{k:>5}: {v}")
if len(parts_seen) < 4:
print("\n‼️ Chưa thu đủ 4 đoạn. Hãy thử thêm biến thể khác.")
sys.exit(1)
# Theo đề: part0 (noise) -> part1 (orig) -> part2 (blur) -> part3 (p3)
full_flag = collected["noise"] + collected["orig"] + collected["blur"] + collected["p3"]
print("\n★ FULL FLAG:", full_flag)
if __name__ == "__main__":
main()
```
Chạy script trên với ảnh đầu thì thu được 3 part flag, với ảnh thứ 2 thì được flag còn lại


**FLAG: HCMUS-CTF{yOU_ArE_a_M4$7eR_0f_p3NGu!N_dr4W!n9!!}**
## Crypto
### BPCasino - Zenpen

**Category:** Crypto (IND-CPA / distinguishing game)
**Goal:** Thắng đủ **111 vòng** đoán bit `c` để nhận flag.
Người chơi gửi một chuỗi hex tùy chọn (plaintext)
Server mã hoá plaintext theo một cơ chế nội bộ rồi ngẫu nhiên chọn:
- **In ciphertext thật** (deterministic) với xác suất 1/2, hoặc
- **In một dãy random bytes** cùng độ dài (uniform) với xác suất 1/2
Sau đó, server yêu cầu người chơi đoán:
- `1` nếu nghĩ là ciphertext thật
- `0` nếu nghĩ là random
Đoán đúng → sang vòng tiếp theo. Đoán sai → kết thúc phiên (“May you be lucky next time, x != y”).
Sau đủ số vòng (theo thiết kế: 111 vòng = 3 × 37) sẽ trả flag
**Phân tích cơ chế**
- Mã hoá dạng CBC tự chế với **IV = 0**:
- `C1 = E(P1)` (deterministic) khi block đầu `P1` giữ nguyên.
- Server đôi khi trả về random cùng độ dài (RoR game).
- Kiểm tra trùng chỉ trên *toàn bộ* message → có thể giữ nguyên block đầu, thay đổi block sau.
**Lỗ hổng**
- IV cố định + hàm mã hoá deterministic ⇒ cùng `P1` cho cùng `C1`.
- Cho phép chosen-plaintext nhiều lần reuse `P1`.
- Người chơi quan sát collision `C1` để phân biệt thật/giả.
**Chiến lược tấn công**
**Phase học:**
1. Giữ block đầu 16 byte = `00...00`.
2. Gửi plaintext 2-block:
- Block1: 16 byte 0x00.
- Block2: cùng một byte lặp: `01`, `02`, `03`, ...
3. Lưu `C1` (32 hex đầu). Khi một `C1` xuất hiện lần 2 ⇒ **SIGNATURE**.
**Phase khai thác:**
- Với mỗi vòng tiếp:
- Gửi block2 tăng tiếp (`04`, `05`, …).
- Nếu 16 byte đầu output == SIGNATURE ⇒ đoán `1`; else `0`.
**Xác suất**
- Cần 2 lần “thật” để collision: kỳ vọng ~4 vòng.
- Sai sót sau khi có SIGNATURE ≈ 2⁻¹²⁸ (bỏ qua).
Sau khi có được SIGNATURE thì các vòng sau đã biết được kết quả.
**Script khai thác (tự học + exploit)**
```python
# exploit_full.py
from pwn import remote, context
import time
HOST, PORT = "chall.blackpinker.com", 33879
context.log_level = "error"
ROUND_TARGET = 111
BLOCK1 = b'\x00'*16
def make_plain(k): return (BLOCK1 + bytes([k])*16).hex()
def run():
io = remote(HOST, PORT)
k = 1; seen = {}; sig=None; rounds=0
def recv_plain():
try: io.recvuntil(b"Plaintext (hex)", timeout=0.3)
except: pass
def send_plain_get_c1():
nonlocal k
recv_plain()
io.sendline(make_plain(k).encode())
line = io.recvline(timeout=1.0)
if not line: raise EOFError
ct_line = line.strip()
if b"Guess" not in line:
try: io.recvuntil(b"Guess", timeout=0.5)
except: pass
return ct_line.decode()[:32]
def guess(g):
io.sendline(str(g).encode())
for _ in range(3):
try: r=io.recvline(timeout=0.5)
except: r=b''
if not r: continue
if b"May you be lucky" in r: return False
if b"HCMUS-CTF{" in r: print(r.decode()); return True
return True
# learn
while sig is None:
c1 = send_plain_get_c1()
seen[c1]=seen.get(c1,0)+1
g = 1 if seen[c1]==2 else (rounds & 1)
if not guess(g): io.close(); return False
if seen[c1]==2: sig=c1
rounds+=1; k+=1
print("[+] SIGN =", sig)
# exploit
while rounds < ROUND_TARGET:
if k>255: k=1
c1 = send_plain_get_c1()
g = 1 if c1==sig else 0
if not guess(g): io.close(); return False
rounds+=1; k+=1
try:
tail=io.recv(timeout=0.5)
if tail and b"HCMUS-CTF{" in tail: print(tail.decode())
except: pass
io.close(); return True
if __name__=="__main__":
for a in range(1,51):
print(f"[Attempt {a}]")
if run(): break
```

**FLAG: HCMUS-CTF{g3tting_st4rted_w1th_CBC}**
## Not Solved

Video youtube black-white noise
ffmpeg ra 161 .png. Try to xor... but nothing works :cry:

Chall dạng dấu thông tin trong high note của file sus, xem bằng MikuMikuWorld, Osint ra bài "ロストワンの号哭" dựa trên metadata nhưng cũng no hope :crying_cat_face:
# Conclusion
Khá tiếc vì không làm được hết các chall Misc, khá stuck ở nhiều chỗ, team happy với event này :+1:
