Hãy tưởng tượng một con dấu chống giả trên công văn: người nhận nhìn con dấu là tin công văn thật. Bây giờ hình dung một quy tắc kỳ quặc: chính tờ công văn ghi ở góc "hãy kiểm con dấu của tôi bằng phương pháp X" — và người nhận ngoan ngoãn làm theo. Kẻ gian chỉ việc viết "phương pháp X = không cần kiểm gì cả", và tờ giả trôi qua. Nghe vô lý, nhưng đó chính xác là cách rất nhiều máy chủ xử lý JWT: chúng để token tự khai nó muốn được kiểm bằng thuật toán nào. Bài này tôi khai thác đúng sự vô lý đó, bốn kiểu.
JSON Web Token (JWT) là một chuỗi ba phần — header, payload, chữ ký — mà máy chủ cấp cho client sau khi đăng nhập, và client trình lại ở mỗi yêu cầu. Sức hấp dẫn của nó là không trạng thái: máy chủ không cần lưu phiên, chỉ cần kiểm chữ ký. Nhưng "chỉ cần kiểm chữ ký" là nơi bốn lỗi kinh điển ẩn nấp. Tôi dựng một ứng dụng JWT và tấn công thật vào từng lỗi.
Toàn bộ chạy trên ứng dụng tôi tự dựng trong container, tự dọn. Tôi chỉ tấn công tiến trình của chính mình, không nhắm vào hệ thống của ai.
Bốn cuộc tấn công
Cùng một mục tiêu: tôi muốn một token nói {"ai":"an","vai":"admin"} được máy chủ chấp nhận, dù tôi chỉ là người dùng thường. Bốn đường:
alg:none— đặt thuật toán thànhnone, bỏ trống chữ ký. Nếu máy chủ chấp nhận, không cần ký gì cả.- Nhầm HS/RS — máy chủ dùng RSA (khoá công khai để kiểm), tôi ký bằng HMAC-SHA256 với chính khoá công khai đó làm mật khẩu. Khoá công khai ai cũng lấy được.
- Bỏ qua
exp— dùng lại một token đã hết hạn, nếu máy chủ không kiểm thời hạn. - Secret yếu — nếu máy chủ ký HMAC bằng một mật khẩu đoán được, tôi tự ký token admin.
Số đo
Ba lần chạy, kết quả giống hệt:
| Lỗi | Cấu hình tấn công | Kết quả |
|---|---|---|
alg:none |
máy chủ tự vô hiệu kiểm chữ ký | THÀNH CÔNG — thành admin |
| HS/RS confusion | thư viện PyJWT kiểm | chặn |
| HS/RS confusion | verify tự viết | THÀNH CÔNG — thành admin |
Bỏ qua exp |
máy chủ tắt kiểm hạn | THÀNH CÔNG — token hết hạn vẫn dùng |
| Secret yếu | bruteforce từ điển | THÀNH CÔNG — tìm ra secret |
Bốn điều đáng gằn giọng
alg:none là lỗ chết người vì nó biến chữ ký thành tuỳ chọn. JWT cho phép trường alg nằm trong header của token — nghĩa là kẻ tấn công tự khai token của mình dùng thuật toán gì. Đặt alg:none là bảo máy chủ "token này không có chữ ký, đừng kiểm". Máy chủ ngây thơ tin lời khai đó, đọc payload, và thấy vai:admin. Cách chặn: máy chủ phải tự quyết định thuật toán, không đọc từ token. Route đúng của tôi khai algorithms=["HS256"], và khi gặp alg:none nó trả "alg value is not allowed" — nó không bao giờ hỏi token muốn dùng thuật toán gì. Đúng con dấu không để tờ công văn tự chọn cách kiểm.
HS/RS confusion thủng đúng khi bạn tự viết verify — và đó là bài học lớn nhất. Với RSA, máy chủ giữ khoá bí mật để ký và công bố khoá công khai để kiểm. HMAC thì dùng cùng một mật khẩu để ký và kiểm. Nếu máy chủ chấp nhận cả hai thuật toán với cùng một khoá, kẻ tấn công ký một token HMAC bằng khoá công khai — thứ ai cũng có — và máy chủ "kiểm" bằng đúng khoá đó, thấy khớp. Điều đáng chú ý trong số đo: PyJWT hiện đại tự chặn việc này ("asymmetric key should not be used" cho HMAC), nhưng cái route tôi tự viết verify thì thủng hoàn toàn. Thư viện trưởng thành đã học được lỗ này qua nhiều năm CVE; verify tự viết thì bắt đầu lại từ số không.
Token hết hạn vẫn dùng được nếu không ai kiểm exp. Trường exp chỉ là một con số trong payload — nó không tự có hiệu lực. Máy chủ phải chủ động so nó với thời gian hiện tại. Route đúng của tôi trả "Signature has expired" cho token hết hạn một giờ trước; route bỏ verify_exp chấp nhận nó như thường. Một token bị lộ mà "hết hạn sau 5 phút" chỉ an toàn nếu máy chủ thực thi 5 phút đó.
Secret secret bị bẻ trong một vòng lặp bảy phần tử. Vì HMAC dùng cùng mật khẩu để ký và kiểm, ai đoán được mật khẩu là ký được token bất kỳ. Tôi thử một từ điển bảy mật khẩu phổ biến và tìm ra ngay. Đây không phải phá mã — chữ ký HMAC-SHA256 vẫn mạnh; cái yếu là mật khẩu. Secret JWT phải là chuỗi ngẫu nhiên dài (RFC khuyến nghị tối thiểu 32 byte cho SHA-256 — và thư viện thậm chí cảnh báo khi khoá ngắn hơn).
Một điều JWT không làm, mà nhiều người tưởng nó làm
Ba phần của JWT được nối bằng dấu chấm và mã hoá Base64URL — không phải mã hoá bí mật. Bất kỳ ai cầm token đều giải Base64 ra và đọc được toàn bộ payload: tên, vai, thời hạn, mọi thứ. Trong lúc dựng tấn công alg:none, tôi không phá gì cả — tôi chỉ lấy header và payload, đổi vai thành admin, đổi alg thành none, nối lại bằng dấu chấm, để trống phần chữ ký. Cả bốn cuộc tấn công đều dùng chung thao tác đó ở bước đầu.
Bài học đi kèm: đừng bao giờ để dữ liệu bí mật trong payload JWT. Nó không được mã hoá, chỉ được ký. Chữ ký chống sửa, không chống đọc. Một token chứa số thẻ tín dụng là số thẻ nằm công khai trong trình duyệt mọi người dùng.
Vì sao ba trong bốn lỗi cùng một gốc
Ba trong bốn lỗi có chung một gốc: tin vào thứ do kẻ tấn công kiểm soát.
alg:nonevà HS/RS confusion đều bắt nguồn từ việc tin trườngalgtrong token. Kẻ tấn công viết header, nên kẻ tấn công chọn thuật toán. Máy chủ đúng phải áp đặt thuật toán từ phía mình.- Bỏ qua
explà tin rằng "token còn hạn" mà không kiểm — một giả định thay cho một phép so sánh. - Secret yếu là chỗ khác: không phải lỗi logic mà lỗi entropy — chữ ký chỉ mạnh bằng cái bí mật đằng sau nó.
Sợi chỉ xuyên suốt: JWT dịch chuyển niềm tin từ phiên trên máy chủ sang chữ ký trong token. Điều đó chỉ an toàn nếu việc kiểm chữ ký chặt chẽ tuyệt đối. Một khe hở trong khâu kiểm biến "token không giả được" thành "token ai viết cũng được".
Nghĩa là gì trong công việc hằng ngày
- Luôn khai thuật toán tường minh phía máy chủ, không đọc từ token:
algorithms=["HS256"], một giá trị duy nhất. Đây là dòng chặn được cảalg:nonelẫn HS/RS confusion. - Dùng thư viện được bảo trì, đừng tự viết verify. Số đo cho thấy đúng ranh giới này: PyJWT chặn HS/RS confusion, verify tự viết thì không. Mọi CVE mà thư viện đã vá là mã bạn không phải tự viết đúng.
- Kiểm
exp— và đừng tắt nó. Nếu framework kiểm mặc định, đừng thêmverify_exp: Falseđể "cho tiện lúc dev"; nó hay bị quên bật lại. - Secret HMAC phải ngẫu nhiên, tối thiểu 32 byte. Sinh bằng
openssl rand -hex 32, không phải gõ tay. Hoặc dùng RSA/ECDSA để khoá kiểm (công khai) tách khỏi khoá ký (bí mật).
Cùng những lỗ này, đổi ngăn xếp
Chuyện "kiểm JWT lỏng thì token ai viết cũng được, và tự viết verify là bắt đầu lại từ số không" không riêng gì PyJWT — đây là những lỗ đã cắn mọi hệ ngôn ngữ, và cách chặn giống hệt nhau.
Trong thế giới Java, alg:none từng là một CVE thật của nhiều thư viện JWT Java thời đầu. Chuẩn an toàn hiện nay với Nimbus JOSE / jjwt là pin thuật toán tường minh: Jwts.parser().verifyWith(key) với một loại khoá cụ thể, hoặc dùng SignatureAlgorithm cố định — không để parseSignedClaims tự suy từ header. Spring Security OAuth2 Resource Server mặc định làm đúng: nó cấu hình JwtDecoder với thuật toán và khoá xác định trước, đóng luôn cả alg:none lẫn HS/RS confusion.
Trong Go, golang-jwt có một API mà lịch sử cho thấy đúng cái bẫy này: jwt.Parse gọi một callback trả về khoá, và cái callback đó phải kiểm token.Method là loại mong đợi (*jwt.SigningMethodHMAC hay *SigningMethodRSA) — quên bước kiểm đó là mở cửa HS/RS confusion. Thư viện thậm chí thêm jwt.WithValidMethods([]string{"HS256"}) để ép tường minh, đúng tinh thần algorithms=["HS256"].
Còn ở cơ sở dữ liệu, họ hàng gần nhất là bài học "token tự chứng vs phiên có trạng thái" mà bài trước đã đo: một JWT giả được (vì kiểm lỏng) đi thẳng qua mà không tra DB, trong khi một session id lưu trong bảng luôn bị đối chiếu. Đây là lý do nhiều hệ vẫn giữ một bảng session/refresh token trong DB làm lưới thứ hai — cái không trạng thái nhanh nhưng chỉ an toàn bằng đúng khâu kiểm chữ ký.
Mẫu số chung: khi bạn dịch niềm tin từ trạng thái phía máy chủ sang một chữ ký trên chính dữ liệu, an toàn của cả hệ dồn hết vào khâu kiểm chữ ký — và kẻ tấn công điều khiển được mọi thứ trong token, nên máy chủ tuyệt đối không được để token tự khai cách nó được kiểm. Thuật toán, khoá, thời hạn — tất cả phải do phía máy chủ áp đặt và đối chiếu, không đọc từ thứ kẻ tấn công viết. Nên câu hỏi đúng khi kiểm bất kỳ token tự-chứng nào không phải "chữ ký có khớp không" mà là "tôi có đang tin bất kỳ mẩu nào do chính người gửi token quyết định về cách tôi kiểm nó không".
Muốn tự soát, tìm chỗ mã của bạn gọi hàm decode/verify JWT. Nó có truyền một danh sách thuật toán cố định không (algorithms=["HS256"]), hay để trống / đọc từ token? Nếu để trống, thử gửi một token alg:none với payload bạn tự chọn tới máy chủ thử của mình. Nếu nó chấp nhận, bạn có một lỗ cho phép bất kỳ ai tự phong bất kỳ vai nào — không cần biết secret, không cần bẻ gì cả.