[Writeup] UofTCTF-2026 - NoQuotes & The SUID Escape
Đây là bài thứ 2 mình muốn chia sẻ trong chuỗi writeup UofTCTF-2026. Bài này kết hợp cả SQL Injection và SSTI khá hay.
1. Phân tích mã nguồn (Source Code Analysis)
Bước đầu tiên của mọi bài CTF là đọc kỹ mã nguồn để tìm các điểm yếu tiềm tàng.
Lỗ hổng 1: SQL Injection (SQLi)
Trong hàm login(), ứng dụng nhận dữ liệu từ người dùng và chèn trực tiếp vào chuỗi truy vấn bằng F-string:
query = (
"SELECT id, username FROM users "
f"WHERE username = ('{username}') AND password = ('{password}')"
)
Dù có hàm waf() chặn dấu nháy đơn ' và nháy kép ", nhưng cấu trúc này vẫn cực kỳ nguy hiểm nếu có cách thoát khỏi dấu nháy mà không dùng chính nó.
Lỗ hổng 2: Server-Side Template Injection (SSTI)
Sau khi đăng nhập thành công, giá trị username từ Database được lưu vào session["user"]. Tại hàm home(), giá trị này được đưa vào render_template_string():
return render_template_string(open("templates/home.html").read() % session["user"])
Đây là lỗi SSTI kinh điển trong Flask/Jinja2, cho phép thực thi mã từ phía máy chủ.
Lỗ hổng 3: Leo thang đặc quyền (Privilege Escalation)
Dựa vào file readflag.c, ta thấy một chương trình C được thiết lập để chạy với quyền Root (setuid(0)) và thực hiện lệnh cat /root/flag.txt. File entrypoint.sh cũng cho thấy web app chạy dưới quyền user www-data. Mục tiêu là thực thi file binary biên dịch từ readflag.c để đọc flag.
2. Tư duy khai thác (Exploitation Mindset)
Chúng mình không thể tấn công trực tiếp vào SSTI vì WAF chặn dấu nháy. Do đó, phải đi theo con đường vòng:
- Dùng SQL Injection để vượt qua đăng nhập.
- Dùng kỹ thuật
UNION SELECTđể trả về một chuỗi chứa payload SSTI. - Vì WAF chặn nháy ở đầu vào, mình sẽ mã hóa payload SSTI sang Hex (ví dụ:
0x7b...). Database sẽ tự động giải mã Hex này, giúp payload “sống sót” đi vàosession["user"].
3. Các bước thực hiện chi tiết (Step-by-step)
Bước 1: Bypass WAF bằng kỹ thuật Backslash
WAF chặn nháy đơn, nhưng không chặn dấu gạch chéo ngược \.
- Nếu nhập
username = \, câu truy vấn trở thành:WHERE username = ('\') AND password = ('{password}'). - Dấu
\sẽ escape dấu nháy đơn đóng của username, làm cho toàn bộ đoạn) AND password = (bị coi là một phần của chuỗi username. - Lúc này, dấu nháy mở của password sẽ đóng chuỗi cho username. Phần sau đó hoàn toàn thuộc quyền kiểm soát của chúng mình.
Bước 2: Chèn Payload SSTI qua SQL Injection
Mình muốn session["user"] chứa payload để thực thi lệnh hệ thống. Lệnh cần chạy là thực thi file binary readflag.
Payload SSTI dự kiến: {{config.__init__.__globals__['os'].popen('/readflag').read()}}.
Để bypass WAF, mình chuyển nó sang Hex:
0x7b7b636f6e6669672e5f5f696e69745f5f2e5f5f676c6f62616c735f5f5b276f73275d2e706f70656e28272f72656164666c616727292e7265616428297d7d
Thông tin nhập vào form:
- Username:
\ - Password:
) UNION SELECT 1, 0x7b7b636f...7d7d -- -(Có khoảng trắng sau-)
Bước 3: Thu thập Flag
Khi nhấn Login:
- Hệ thống thực hiện
UNION SELECT, cột thứ 2 (username) sẽ trả về chuỗi SSTI đã giải mã từ Hex. - Ứng dụng lưu chuỗi này vào session và chuyển hướng sang
/home. - Tại
/home, Flask render chuỗi này, thực thi lệnh chạy/readflag. - File
/readflagchạy với quyền Root, đọc/root/flag.txtvà trả về nội dung trên trình duyệt.
4. Tổng kết
- Lỗ hổng chính: SQL Injection do F-string, SSTI do
render_template_string, và SUID Binary. - Kỹ thuật quan trọng: Dùng
\để bypass SQL quotes và dùng Hex encoding để “luồn lách” qua WAF đưa payload SSTI vào hệ thống.
Flag: uoftctf{w0w_y0u_5UcC355FU1Ly_Esc4p3d_7h3_57R1nG!}

Cảm ơn các bạn đã theo dõi!