AppSec 3 — Injection & Xác thực
AppSec 3 — Injection & Xác thực
Trong bài OWASP Top 10 chúng ta đã lướt qua toàn bộ mười nhóm rủi ro. Bài này đào sâu vào hai nhóm mà đội dữ liệu ngân hàng gặp nhiều nhất và trả giá đắt nhất: A03 Injection và A07 Identification & Authentication Failures. Đây không phải lựa chọn ngẫu nhiên — API và ứng dụng của bạn hằng ngày nhận input từ client rồi ghép nó vào câu truy vấn cơ sở dữ liệu khách hàng, và cánh cửa duy nhất bảo vệ dữ liệu đó là lớp xác thực. Hai lớp này hỏng thì mọi thứ phía sau hỏng theo.
Injection: nguyên lý chung
Bản chất của mọi lỗ hổng injection nằm gọn trong một câu: dữ liệu do người dùng cung cấp bị hệ thống diễn giải nhầm thành lệnh hoặc mã lệnh (code), thay vì được coi là dữ liệu thuần tuý.
Máy tính không "hiểu" ý định — nó chỉ thực thi những gì được đưa vào một trình thông dịch (interpreter). Khi bạn ghép chuỗi input của người dùng vào một câu SQL, một lệnh shell, một truy vấn LDAP hay một template, bạn vô tình để người dùng viết chung một "câu văn" với hệ thống. Nếu input chứa các ký tự điều khiển của ngôn ngữ đích (dấu nháy, dấu chấm phẩy, ký tự đóng thẻ...), ranh giới giữa "dữ liệu" và "lệnh" bị xoá nhoà, và kẻ tấn công chiếm quyền viết lệnh.
Vì vậy nguyên tắc phòng thủ gốc rễ của tất cả injection là như nhau: tách biệt dữ liệu khỏi lệnh (separate data from code). Mọi biện pháp cụ thể bên dưới chỉ là biến thể của nguyên tắc này.
SQL Injection chi tiết
SQL injection (SQLi) là injection kinh điển và nguy hiểm nhất với đội dữ liệu, vì đích tấn công chính là kho dữ liệu khách hàng.
Lỗ hổng bắt đầu từ đâu
Gần như mọi ca SQLi đều sinh ra từ nối chuỗi (string concatenation) để dựng câu truy vấn. Ví dụ một endpoint đăng nhập viết bằng mã giả:
query = "SELECT * FROM users WHERE user='" + input + "' AND pass='" + pw + "'"
Nếu người dùng nhập bình thường user=an, câu lệnh vô hại. Nhưng nếu kẻ tấn công nhập vào ô user:
' OR '1'='1' --
thì câu lệnh trở thành:
SELECT * FROM users WHERE user='' OR '1'='1' -- ' AND pass='...'
Điều kiện '1'='1' luôn đúng, phần -- biến toàn bộ đoạn còn lại thành comment. Kết quả: đăng nhập thành công mà không cần mật khẩu, thường lấy đúng dòng đầu tiên là tài khoản admin.
Các dạng SQLi
- Union-based: chèn
UNION SELECTđể nối kết quả một truy vấn khác vào output đang hiển thị, ví dụ moi cộtaccount_no, balancetừ bảng khác ra màn hình. Kẻ tấn công phải khớp số cột và kiểu dữ liệu, thường dò bằngORDER BY ntăng dần. - Error-based: cố tình gây lỗi để DB "nôn" dữ liệu ra trong thông báo lỗi (một lý do nữa để không bao giờ show stack trace/lỗi SQL ra client).
- Blind (mù): khi ứng dụng không hiển thị dữ liệu lẫn lỗi. Kẻ tấn công suy ra dữ liệu từng bit qua phản hồi đúng/sai (boolean-based: trang đổi nội dung khi điều kiện đúng) hoặc qua độ trễ (time-based: chèn hàm ngủ, đo thời gian phản hồi để đoán ký tự). Chậm nhưng đủ để rút cả bảng bằng script tự động (như sqlmap).
Tác động trong ngân hàng
Với hệ thống ngân hàng, hậu quả không dừng ở "lộ dữ liệu": đọc trái phép số dư, giao dịch, thông tin định danh khách hàng (vi phạm bảo mật thông tin khách hàng); sửa dữ liệu (đổi số dư, đổi trạng thái giao dịch); xoá dữ liệu; và trong trường hợp tệ nhất, leo thang chiếm quyền máy chủ DB. Một endpoint tra cứu tưởng vô hại có thể là cửa ngõ ra toàn bộ dữ liệu.
Phòng SQL Injection
Xếp theo mức độ ưu tiên:
-
Prepared statement / parameterized query — biện pháp số 1. Thay vì nối chuỗi, ta gửi câu lệnh với tham số bind (placeholder như
?,$1, hoặc:name) rồi truyền giá trị riêng. DB biên dịch cấu trúc câu lệnh trước, sau đó gắn giá trị vào như dữ liệu thuần — input không bao giờ được diễn giải thành cú pháp SQL. Đây là tách dữ liệu khỏi lệnh ở mức cơ chế, xử lý gần như trọn vẹn SQLi.# AN TOÀN — %s là placeholder bind, KHÔNG phải nối chuỗi cur.execute( "SELECT id, account_no, balance FROM accounts WHERE customer_id = %s", (customer_id,) # giá trị truyền riêng, driver tự escape ) # NGUY HIỂM — tuyệt đối tránh: # cur.execute("SELECT ... WHERE customer_id = " + customer_id) -
Dùng ORM đúng cách. ORM (SQLAlchemy, Django ORM, Hibernate) mặc định sinh parameterized query. Nhưng ORM không miễn nhiễm nếu bạn lạm dụng raw SQL (
text(),.raw(),.extra()) rồi lại nối chuỗi vào đó. Dùng builder/tham số của ORM, đừng luồn chuỗi thô. -
Input validation / allowlist. Xác thực kiểu và định dạng đầu vào (số phải là số, ngày phải là ngày). Đặc biệt với những phần không bind được như tên cột, tên bảng, chiều
ORDER BY— hãy dùng allowlist (danh sách trắng các giá trị hợp lệ), không bao giờ ghép trực tiếp từ input. -
Least-privilege cho tài khoản DB. Tài khoản mà ứng dụng dùng chỉ nên có quyền tối thiểu: một dịch vụ chỉ đọc thì cấp
SELECT, không cấpDROP/DELETE/quyền admin. Nếu SQLi xảy ra, thiệt hại bị giới hạn. -
Không bao giờ tin dữ liệu client, kể cả từ dropdown, hidden field, header, cookie — tất cả đều có thể bị sửa. Validation phía client chỉ để trải nghiệm; kiểm tra thật phải ở server.
Ví dụ truy vấn tham số hoá an toàn
Để hình dung một truy vấn "sạch" trông thế nào, đây là một SELECT hợp lệ liệt kê các giao dịch chi lớn của khách hàng tại một thành phố. Các giá trị city, kind, min_amount trong ứng dụng thật sẽ là tham số bind (không nối chuỗi); dưới đây điền hằng số chỉ để minh hoạ và chạy thử được:
-- ▶ Chạy được
SELECT c.full_name, a.account_no, t.amount, t.created_at
FROM transactions t
JOIN accounts a ON t.account_id = a.id
JOIN customers c ON a.customer_id = c.id
WHERE c.city = 'Hanoi'
AND t.kind = 'debit'
AND t.amount >= 5000000
ORDER BY t.amount DESC
LIMIT 50;
Trong code, 'Hanoi', 'debit', 5000000 được thay bằng $1, $2, $3 (Postgres) và truyền tách rời — dù người dùng có gõ ' OR '1'='1, chuỗi đó chỉ được so sánh như một giá trị thành phố, không phá được cú pháp.
Các dạng injection khác
Cùng nguyên lý, khác trình thông dịch:
- XSS (Cross-Site Scripting) — injection vào trình duyệt: kẻ tấn công chèn mã JavaScript vào nội dung trang, script chạy trong phiên của nạn nhân (đánh cắp cookie/session, thao tác giao diện). Ba loại: Stored (mã lưu trong DB, phát tán cho mọi người xem — nguy hiểm nhất, ví dụ trường ghi chú giao dịch), Reflected (mã phản chiếu ngay từ tham số URL), DOM-based (JS phía client tự ghi input vào DOM không an toàn). Phòng: output encoding (mã hoá ký tự theo ngữ cảnh khi render — HTML, attribute, JS), dùng framework tự escape (React, template auto-escape), và CSP (Content-Security-Policy — chặn script từ nguồn không tin cậy làm lớp phòng thủ chiều sâu). Với cookie session, đặt cờ
HttpOnlyđể JS không đọc được. - Command injection — injection vào shell OS: ứng dụng ghép input vào lệnh hệ điều hành (
os.system,Runtime.exec). Kẻ tấn công thêm; rm -rfhay&& curl .... Phòng: tránh gọi shell; nếu buộc phải, dùng API truyền mảng đối số (không qua shell) và allowlist chặt. - LDAP injection — chèn ký tự đặc biệt LDAP (
*,),() vào bộ lọc thư mục để vượt xác thực hoặc liệt kê người dùng. Phòng: escape theo chuẩn LDAP, dùng API bind tham số. - NoSQL injection — với MongoDB và tương tự, chèn toán tử như
{"$ne": null}để làm điều kiện luôn đúng. Phòng: kiểm tra kiểu dữ liệu (ép về chuỗi, từ chối object ở nơi mong đợi chuỗi), dùng driver an toàn. - Template injection (SSTI) — chèn cú pháp template (Jinja2, Freemarker) vào input được render, dẫn tới thực thi mã máy chủ. Phòng: không bao giờ render template từ input người dùng; nếu cần, dùng sandbox template.
Xác thực (Authentication) an toàn — A07
Nếu injection là cách kẻ tấn công lách qua cửa, thì lỗi xác thực là cửa để mở toang. A07 gom mọi thất bại trong việc xác nhận đúng danh tính người dùng.
Lưu mật khẩu đúng cách
Đây là điều bị làm sai nhiều nhất và hậu quả nặng nhất khi CSDL rò rỉ.
- KHÔNG bao giờ lưu plaintext (mật khẩu trần).
- KHÔNG dùng MD5 hay SHA thô (SHA-1/SHA-256 trần). Chúng được thiết kế để nhanh, nghĩa là kẻ tấn công có thể thử hàng tỷ mật khẩu mỗi giây bằng GPU, và với bảng rainbow/từ điển thì hash không salt bị bẻ gần như tức thì.
- PHẢI dùng hàm hash mật khẩu chuyên dụng, chậm, có salt: bcrypt, argon2 (khuyến nghị hiện đại — argon2id), hoặc scrypt. Đặc điểm chung: chậm có chủ đích (adjustable cost/work factor), tự sinh salt ngẫu nhiên duy nhất cho mỗi mật khẩu (chống rainbow table và chống việc hai người cùng mật khẩu ra cùng hash), và một số còn tốn bộ nhớ (memory-hard) để chống GPU/ASIC.
Khi người dùng đăng nhập, ta hash lại input với cùng salt rồi so sánh, không bao giờ giải ngược (hash một chiều).
Chống brute-force & credential stuffing
Kể cả hash tốt, phải chặn kẻ tấn công đoán mật khẩu qua endpoint đăng nhập:
- Rate limiting: giới hạn số lần thử theo IP/tài khoản trong một khoảng thời gian.
- Account lockout / backoff: khoá tạm hoặc tăng dần độ trễ sau N lần sai (cân nhắc để tránh bị lợi dụng khoá tài khoản người khác — thường ưu tiên backoff + CAPTCHA hơn khoá cứng).
- CAPTCHA sau vài lần thất bại để chặn bot.
- Credential stuffing là dạng riêng: kẻ tấn công dùng cặp email/mật khẩu đã lộ từ site khác thử hàng loạt lên hệ thống của bạn. Phòng bằng MFA, phát hiện đăng nhập bất thường (địa lý/thiết bị lạ), và đối chiếu danh sách mật khẩu đã lộ để buộc đổi.
MFA (xác thực đa yếu tố)
MFA yêu cầu ít nhất hai yếu tố khác loại: cái bạn biết (mật khẩu), cái bạn có (điện thoại/token TOTP, khoá FIDO2), cái bạn là (sinh trắc). Ngay cả khi mật khẩu bị lộ, kẻ tấn công thiếu yếu tố thứ hai. Với ngân hàng, MFA cho tài khoản quản trị và giao dịch nhạy cảm gần như là bắt buộc theo quy định. Ưu tiên TOTP/khoá phần cứng hơn OTP qua SMS (SMS dễ bị SIM-swap).
Quản lý session an toàn
Sau khi xác thực, phiên (session) là "vé thông hành". Bảo vệ vé:
- Session ID/token ngẫu nhiên đủ dài (sinh bằng CSPRNG), không đoán được, không mã hoá thông tin nhạy cảm nếu không ký.
- Cookie có cờ
HttpOnly(JS không đọc được — chống XSS trộm session),Secure(chỉ gửi qua HTTPS),SameSite(giảm CSRF). - Timeout: hết hạn theo thời gian không hoạt động (idle timeout) và tổng thời gian (absolute timeout).
- Chống session fixation: cấp session ID mới ngay sau khi đăng nhập thành công, huỷ ID cũ, để kẻ tấn công không thể "gán" trước một session rồi chiếm.
- Logout phải huỷ session ở server, không chỉ xoá cookie client.
Password reset an toàn
Luồng quên mật khẩu là mục tiêu ưa thích. Nguyên tắc: token reset ngẫu nhiên, dùng một lần, hết hạn ngắn (vài phút–1 giờ), gửi qua kênh đã xác thực (email đăng ký), không tiết lộ email có tồn tại hay không (luôn trả cùng một thông báo để chống liệt kê tài khoản), và vô hiệu các session cũ sau khi đổi mật khẩu.
Use case thực tế
Bối cảnh: NCB có một API nội bộ GET /api/customers/{city}/large-txn cho bộ phận nghiệp vụ tra cứu giao dịch chi lớn theo thành phố. Bản đầu tiên do một dev viết vội, ghép chuỗi:
q = "SELECT ... WHERE c.city = '" + city + "'"
Phát hiện: đội security chạy quét ZAP, gửi city = Hanoi' UNION SELECT full_name, account_no, balance, created_at FROM ... --. API trả về danh sách số tài khoản và số dư của toàn bộ khách hàng — một sự cố lộ dữ liệu nghiêm trọng. Thêm nữa, endpoint đăng nhập cùng hệ thống lưu mật khẩu bằng MD5 không salt, và không có rate limit.
Khắc phục theo bước:
- Chuyển toàn bộ truy vấn sang parameterized query —
citythành tham số bind$1. Payload injection lập tức vô hiệu (chỉ còn là chuỗi tracity). - Cấp cho tài khoản DB của API quyền chỉ
SELECTtrên đúng các bảng cần, thu hồi mọi quyền ghi. - Thêm allowlist cho tham số sắp xếp (
ORDER BY) vì cột không bind được. - Chuyển hash mật khẩu từ MD5 sang argon2id, ép người dùng đổi mật khẩu ở lần đăng nhập kế tiếp; migrate dần bằng cách re-hash khi đăng nhập thành công.
- Bật rate limit 5 lần/phút/tài khoản + CAPTCHA sau 3 lần sai, thêm MFA (TOTP) cho tài khoản quản trị.
- Đặt cookie session
HttpOnly; Secure; SameSite=Lax, cấp session ID mới sau đăng nhập (chống fixation), idle timeout 15 phút.
Kết quả: quét lại ZAP sạch SQLi; kể cả nếu API lại dính lỗi, tài khoản DB least-privilege giới hạn thiệt hại ở mức chỉ đọc. Cross-check thêm với checklist ở OWASP Top 10 và whitelist các cơ chế auth chi tiết ở bài Auth & Security cho API. Các chủ đề còn lại của series: kiểm soát truy cập & mã hoá, secrets & supply chain, API security, DevSecOps tooling, dữ liệu ngân hàng.
Ghi nhớ
- Injection = dữ liệu bị diễn giải thành lệnh. Gốc rễ phòng thủ chung: tách dữ liệu khỏi lệnh.
- SQLi: sinh từ nối chuỗi. Biện pháp số 1 là prepared statement / parameterized query — DB biên dịch cấu trúc trước, input chỉ là giá trị bind.
- Bổ trợ SQLi: ORM đúng cách (đừng nối chuỗi vào raw SQL), input validation/allowlist cho phần không bind được, tài khoản DB least-privilege, không tin dữ liệu client.
- Union-based/error-based/blind (boolean & time) là các cách rút dữ liệu khác nhau — cùng một gốc lỗi.
- XSS: injection vào trình duyệt (stored/reflected/DOM). Phòng bằng output encoding + framework auto-escape + CSP, cookie
HttpOnly. - Command / LDAP / NoSQL / template injection: cùng nguyên lý, dùng API tham số hoá và validation theo từng trình thông dịch.
- Mật khẩu: hash bằng bcrypt/argon2/scrypt có salt — KHÔNG plaintext, KHÔNG MD5/SHA thô.
- Chống đoán mật khẩu: rate limit + lockout/backoff + CAPTCHA + MFA; credential stuffing chống bằng MFA và phát hiện bất thường.
- Session an toàn: token ngẫu nhiên, cookie
HttpOnly/Secure/SameSite, timeout, cấp ID mới sau login (chống fixation), logout huỷ session ở server. - Password reset: token một lần, hết hạn ngắn, không lộ email tồn tại, huỷ session cũ sau khi đổi.
Bài viết liên quan
Continuous Integration/Delivery/Deployment, cấu trúc pipeline, ví dụ GitHub Actions và chiến lược release.
Container vs máy ảo, image/layer, Dockerfile, volume, network, Docker Compose và best practices.
Vì sao cần orchestration; Pod, Deployment, Service, Ingress; scaling, self-healing và cấu hình.
Bản chất DevOps: phá bỏ rào cản Dev–Ops, vòng lặp vô tận, CALMS, và vì sao tự động hoá.