Loạt bài phòng thủ: hiểu lỗ hổng để viết code an toàn, demo trong lab cô lập.

JWT (JSON Web Token) phổ biến vì nó giải một bài toán gọn gàng: làm sao server tin một token do client gửi mà không cần tra database mỗi lần? Câu trả lời là chữ ký mật mã. Server ký token khi phát hành; mỗi lần nhận lại, nó verify chữ ký — nếu khớp, nội dung token (ai, quyền gì, hết hạn khi nào) là đáng tin, vì không ai sửa được mà không có khoá bí mật.

Nhưng "chữ ký bảo vệ" chỉ đúng khi bạn verify cho đúng. Và JWT có một bẫy nổi tiếng đến mức thành bài học kinh điển: nếu verifier tin trường alg ghi trong chính token (thay vì ép thuật toán nó mong đợi), kẻ tấn công đặt alg: "none" và bỏ chữ ký — một verifier ngây thơ sẽ "ồ, token này không cần chữ ký" và chấp nhận token giả. Bài này (phần 6 loạt Bảo mật web) tự cài JWT để thấy rõ chữ ký bảo vệ ra sao, và bẫy alg=none nguy hiểm thế nào.

Cơ chế: chữ ký, và vì sao đừng tin "alg" trong token

Ảnh chụp đoạn mã nền tối minh hoạ JWT chữ ký giả mạo và bẫy alg=none, khối cấu trúc JWT và cách ký HS256 header chấm payload chấm signature 3 phần base64url header alg HS256 payload sub alice role user chữ ký bằng HMAC-SHA256 header.payload secret header cộng payload chỉ được mã hoá base64 không mã hoá ai cũng đọc được nhưng không ai sửa được mà không có secret để ký lại, khối bẫy alg=none kẻ tấn công đổi alg thành none bỏ chữ ký header alg none payload role admin không chữ ký verifier ngây thơ đọc alg từ header thấy none bỏ qua chữ ký chấp nhận token giả leo thang quyền, khối verify đúng ép alg mong đợi từ chối none so constant-time if header.alg khác HS256 return err không tin alg trong token if không hmac.Equal expected got return err verify chữ ký

Hình 1: JWT = header.payload.signature (base64url). Chữ ký HS256 = HMAC-SHA256 của header.payload với secret — header/payload chỉ mã hoá base64 (ai cũng đọc được) nhưng không ai sửa được mà không có secret. Bẫy alg=none: kẻ tấn công đổi alg thành none, bỏ chữ ký; verifier ngây thơ tin header → bỏ qua chữ ký → chấp nhận token giả. Verify đúng: ép alg mong đợi, verify chữ ký constant-time.

Tái hiện thật trong go-lab

Mình tự cài JWT HS256 tối giản trong go-lab (golang 1.23, chỉ dùng crypto/hmac + base64 + json — để kiểm soát hoàn toàn và minh hoạ bẫy), rồi thử ba kịch bản.

Ảnh chụp bảng kết quả tái hiện thật trong go-lab output thật golang 1.23 JWT HS256 tự cài crypto hmac, mục một JWT hợp lệ role user verify role bằng user err nil chữ ký khớp chấp nhận, mục hai giả mạo payload role admin giữ chữ ký cũ verifyStrict err bằng chữ ký không khớp sửa payload nhưng không có secret để ký lại chữ ký cũ không khớp payload mới bị phát hiện, mục ba bẫy alg=none bỏ chữ ký role admin cùng token hai verifier verifyNaive tin alg header role bằng admin err nil giả mạo thành công verifyStrict ép HS256 role nil err bằng alg không được chấp nhận none chặn, chú thích hai chữ ký bảo vệ payload sửa role admin mà không ký lại được không có secret thì bị phát hiện ngay ba nhưng nếu verifier tin alg trong token kẻ tấn công đặt alg none và bỏ chữ ký verifier ngây thơ chấp nhận leo thang thành admin chữ ký chỉ bảo vệ khi bạn ép thuật toán mong đợi đừng để token tự khai alg

Hình 2: Kết quả thật — ① JWT hợp lệ: verify role=user, không lỗi; ② giả mạo payload role=admin giữ chữ ký cũ: verifyStrict → "chữ ký không khớp" (phát hiện); ③ bẫy alg=none (cùng token, hai verifier): verifyNaive (tin alg header) → role=admin chấp nhận (nguy hiểm), verifyStrict (ép HS256) → "alg không được chấp nhận: none" (chặn).

Ba kịch bản làm rõ cả sức mạnh lẫn cạm bẫy:

  • Chữ ký bảo vệ payload khỏi bị sửa. Token hợp lệ verify ra role=user. Khi kẻ tấn công sửa payload thành role: "admin" nhưng giữ chữ ký cũ (vì không có secret để ký lại), verify phát hiện ngay: chữ ký cũ được tính trên payload cũ, không khớp payload mới → "chữ ký không khớp" → từ chối. Đây là điều JWT làm tốt: không ai sửa được nội dung mà không có khoá.
  • Nhưng bẫy alg=none biến verifier thành kẻ phản bội. Kẻ tấn công đổi header thành {"alg":"none"} và bỏ hẳn chữ ký. Với verifier ngây thơ — đọc alg từ header rồi làm theo — nó thấy "none" nghĩa là "không cần chữ ký" và chấp nhận token! Kết quả: role=admin, không lỗi — giả mạo thành công, kẻ tấn công leo thang quyền chỉ bằng cách sửa text. Lỗ hổng không nằm ở JWT mà ở cách verify.
  • Verify đúng ép thuật toán mong đợi. Verifier nghiêm ngặt không tin alg trong token — nó biết trước mình dùng HS256 và ép: nếu alg khác "HS256" → từ chối ngay ("alg không được chấp nhận: none"). Cùng token alg=none đó bị chặn. Bài học cốt lõi: chữ ký chỉ bảo vệ khi bạn kiểm soát thuật toán verify, đừng để token tự khai nó cần verify kiểu gì.

Đánh đổi cần cân nhắc

Luôn verify chữ ký + ép alg, và dùng thư viện đã kiểm chứng. Hai nguyên tắc bắt buộc: (1) luôn verify chữ ký (đừng chỉ base64-decode payload rồi tin), và (2) ép thuật toán mong đợi, từ chối "none" và từ chối thuật toán khác dự kiến. Một bẫy liên quan của RS256/HS256 lẫn lộn: nếu server dùng RS256 (verify bằng public key) mà verifier chấp nhận alg tuỳ token, kẻ tấn công có thể gửi token HS256 ký bằng chính public key (vốn công khai) — verifier dùng public key làm secret HMAC và chấp nhận. Vì các bẫy tinh vi này, dùng thư viện JWT uy tín (và cấu hình nó ép alg) thay vì tự cài cho production — mình tự cài ở đây chỉ để minh hoạ cơ chế.

JWT KHÔNG mã hoá — đừng để bí mật trong payload. Một hiểu lầm phổ biến: nghĩ JWT "mã hoá". Không. Header và payload chỉ mã hoá base64url — bất kỳ ai có token đều đọc được nội dung (dán vào jwt.io là thấy). Chữ ký chỉ chống sửa đổi, không chống đọc. Vì vậy không bao giờ để thông tin nhạy cảm (mật khẩu, số thẻ, khoá) trong payload JWT. Nếu cần giấu nội dung, dùng JWE (encrypted) — nhưng với token xác thực thông thường, chỉ cần nhớ: payload là công khai.

JWT khó thu hồi trước hạn — thiết kế thời hạn ngắn + refresh. Nhược điểm cố hữu của JWT so với session trong DB: vì server không tra DB để verify, nó cũng không dễ huỷ một token trước khi hết hạn (ví dụ khi người dùng đăng xuất hay bị khoá). Token hợp lệ tới exp dù bạn muốn chặn ngay. Hai cách chữa: (1) đặt thời hạn ngắn cho access token (vài phút) + dùng refresh token (lưu DB, thu hồi được) để lấy access token mới; (2) duy trì blocklist các token bị thu hồi (nhưng điều này phần nào triệt tiêu lợi thế "không tra DB"). Và luôn kiểm exp, iss, aud khi verify — nhiều lỗ hổng đến từ việc quên kiểm token đã hết hạn hay đúng đối tượng.

Ba ý mang về

  1. Chữ ký bảo vệ payload khỏi sửa đổi: tái hiện thật, token hợp lệ verify role=user; sửa payload thành role=admin mà không có secret để ký lại → verify phát hiện "chữ ký không khớp" (chữ ký cũ tính trên payload cũ) → từ chối. Không ai sửa nội dung mà không có khoá.
  2. Bẫy alg=none: đừng tin alg trong token: tái hiện thật, kẻ tấn công đặt alg=none và bỏ chữ ký — verifier ngây thơ (tin header) chấp nhận role=admin (giả mạo thành công), verifier đúng (ép HS256) từ chối; chữ ký chỉ bảo vệ khi bạn kiểm soát thuật toán verify.
  3. Verify đúng + hiểu giới hạn JWT: luôn verify chữ ký, ép alg mong đợi, dùng thư viện uy tín (cẩn thận RS256/HS256 lẫn lộn); JWT không mã hoá payload (đừng để bí mật trong đó); khó thu hồi trước hạn (thời hạn ngắn + refresh token + kiểm exp/iss/aud).

Nguồn

Phần sau ta bàn hai lỗ hổng "chèn" khác: command injection (nối chuỗi vào lệnh shell) và path traversal (dùng ../ để thoát khỏi thư mục cho phép) — tái hiện và cách chặn bằng tách tham số và làm sạch đường dẫn.