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. Bài này 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ự dựng trong container, tự dọn. 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 |
Điều đáng nhớ
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ì.
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ó 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ủ (bài trước) 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 thực tế
- 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).
Chỗ tôi không kết luận được
Tôi không đo tấn công qua jku/x5u (header trỏ tới URL chứa khoá — kẻ tấn công trỏ về khoá của mình) hay kid injection. Chúng là biến thể tinh vi hơn của cùng một chủ đề "đừng tin metadata trong token", và mỗi cái xứng một phép đo riêng.
Và tôi đo với secret HMAC. JWT ký bằng RSA/ECDSA có mô hình khoá khác (khoá kiểm công khai được), tránh hẳn lỗ secret-yếu và HS/RS confusion — nhưng thêm phức tạp về quản lý khoá. "HS hay RS" là một lựa chọn kiến trúc, không chỉ là cấu hình.
Thử ba mươi giây
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ả.