Hình dung một phiếu gửi xe. Bảo vệ đưa bạn cái phiếu, và bất cứ ai cầm phiếu đó đều lấy được xe ra — nên nếu bạn đánh rơi phiếu, kẻ nhặt được lái xe của bạn đi mất. Bây giờ tưởng tượng một phiếu thông minh hơn: lúc gửi xe, bạn thì thầm một mật khẩu với bảo vệ, và lúc lấy xe phải đọc đúng mật khẩu đó cùng với cái phiếu. Giờ kẻ nhặt được phiếu chẳng làm gì được, vì nó thiếu cái mật khẩu chỉ bạn biết. PKCE trong OAuth2 chính là cái mật khẩu thì thầm đó, và bài này tôi đo cho thấy thiếu nó thì một cái phiếu bị nhặt là đủ mất cả tài khoản.

OAuth2 là chuẩn đằng sau nút "Đăng nhập bằng Google". Nó cho phép một ứng dụng truy cập tài khoản của bạn ở một dịch vụ khác mà không thấy mật khẩu của bạn. OIDC (OpenID Connect) là lớp mỏng trên OAuth2 thêm phần "bạn là ai" (danh tính) vào phần "được làm gì" (quyền). Tôi đo cơ chế của luồng OAuth2 chuẩn — authorization code — và ba chỗ hay bị dùng sai, bằng một máy chủ OAuth2 mô phỏng đầy đủ cơ chế bảo mật.

Toàn bộ chạy trong container tôi tự dựng, 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.

Luồng authorization code, rút gọn

Ba bên: bạn (trình duyệt), ứng dụng (muốn truy cập dữ liệu của bạn), và máy chủ ủy quyền (Google, giữ tài khoản của bạn). Luồng:

  1. Ứng dụng chuyển bạn tới máy chủ ủy quyền để đăng nhập và đồng ý.
  2. Máy chủ chuyển bạn về ứng dụng kèm một authorization code ngắn hạn.
  3. Ứng dụng đổi code đó lấy token ở kênh sau (trực tiếp server-tới-server).

Điểm tinh tế: code đi qua trình duyệt của bạn (bước 2), nên nó có thể bị lộ; token thì đi ở kênh sau (bước 3), an toàn hơn. Cả cơ chế bảo mật xoay quanh việc làm cho một code bị lộ trở nên vô dụng — biến cái phiếu gửi xe thành cái phiếu cần mật khẩu.

Số đo

Ba lần chạy, kết quả giống hệt:

Tình huống Kết quả
Đổi code lấy token (verifier đúng) thành công
Dùng lại cùng một code bị chặn — code đã dùng
Đổi redirect_uri sang trang khác bị chặn — không khớp
Kẻ tấn công có code nhưng verifier sai (có PKCE) bị chặn
Kẻ tấn công có code (không PKCE) thành công — lỗ hổng!

Ba lớp chặn, đọc từ bảng

Authorization code chỉ dùng được một lần. Dòng thứ hai: đổi code lần đầu thành công, dùng lại cùng code bị chặn. Điều này giới hạn thiệt hại nếu code bị lộ — kẻ tấn công phải dùng nó trước ứng dụng thật, và ngay khi một bên dùng, bên kia mất cơ hội. Máy chủ ủy quyền đánh dấu code "đã dùng" ngay lần đổi đầu tiên.

redirect_uri phải khớp chính xác — đây là hàng rào chống chuyển hướng. Máy chủ chỉ trả code về đúng địa chỉ đã đăng ký trước. Nếu kẻ tấn công thử đổi redirect_uri sang trang của nó để code rơi vào tay mình, máy chủ từ chối vì không khớp. Đây là lý do các máy chủ OAuth2 bắt đăng ký trước danh sách redirect URI chính xác, và không chấp nhận khớp mờ (*, tiền tố) — một khớp lỏng lẻo ở đây là một lỗ hổng chiếm tài khoản.

PKCE là thứ chặn tấn công chặn-code — và không có nó là một lỗ hổng thật. Đây là hai dòng cuối bảng, và chúng đối xứng nhau một cách sáng tỏ đến mức tôi thấy cần dừng lại. PKCE (Proof Key for Code Exchange) hoạt động thế này: trước khi bắt đầu, ứng dụng sinh một bí mật ngẫu nhiên (verifier), gửi bản băm của nó (challenge) kèm yêu cầu. Khi đổi code lấy token, ứng dụng phải trình bản gốc của bí mật. Máy chủ băm lại và so.

Kết quả đo: có PKCE, kẻ tấn công chặn được code nhưng không có verifier gốc (nó chỉ thấy bản băm đi qua trình duyệt) — bị chặn. Không PKCE, chỉ cần cầm code là đổi được token — thành công, một lỗ hổng đầy đủ. PKCE biến "chặn được code là thắng" thành "chặn được code vẫn vô dụng", vì code giờ phải đi kèm một bí mật mà chỉ ứng dụng gốc có. Đúng cái phiếu gửi xe cần mật khẩu.

Vì sao PKCE hoạt động: băm một chiều

Điều làm PKCE hiệu quả là một tính chất đã gặp ở bài băm mật khẩu: băm đi một chiều. Ứng dụng gửi challenge = SHA256(verifier) qua trình duyệt — phần này công khai, kẻ tấn công thấy được. Nhưng để đổi code lấy token, phải trình verifier gốc, và từ challenge không suy ngược ra verifier (đó là bản chất của hàm băm một chiều).

Nên dù kẻ tấn công chặn được cả authorization code lẫn challenge đi qua trình duyệt, nó vẫn thiếu đúng một thứ: verifier gốc, thứ duy nhất không bao giờ rời khỏi ứng dụng. Trong phép đo, khi tôi cho kẻ tấn công thử với một verifier đoán bừa, máy chủ băm nó ra và so với challenge đã lưu — không khớp, từ chối. Đây là cùng một ý tưởng "chứng minh mình biết bí mật mà không tiết lộ bí mật" đứng sau rất nhiều giao thức bảo mật.

Mỗi cơ chế bịt một đường tấn công cụ thể vào cái code đi qua trình duyệt: dùng một lần giới hạn cửa sổ; redirect_uri khớp chính xác chặn kẻ tấn công điều hướng code về mình ngay từ bước cấp; PKCE chặn kẻ tấn công đã có code (qua malware, qua log, qua một ứng dụng độc trên cùng máy) đổi nó lấy token. Ba lớp này giải thích vì sao authorization code + PKCE là luồng khuyến nghị cho mọi loại ứng dụng hiện nay — kể cả ứng dụng di động và SPA, vốn trước đây dùng implicit flow (trả token thẳng qua URL trình duyệt). Implicit flow bỏ bước đổi code, nên token rơi thẳng vào lịch sử trình duyệt và header Referer — nó bị loại bỏ chính vì thiếu đúng những lớp bảo vệ mà bài này đo.

Nghĩa là gì trong công việc hằng ngày

  • Luôn dùng authorization code + PKCE. Đây là mặc định đúng cho web app, mobile, và SPA. Đừng dùng implicit flow (đã lỗi thời) hay password grant (ứng dụng thấy mật khẩu — phá vỡ mục đích của OAuth2).
  • Đăng ký redirect URI chính xác, không khớp mờ. Một redirect_uri chấp nhận tiền tố hay wildcard là một lỗ chiếm tài khoản chờ sẵn.
  • Luôn kiểm state (không đo ở đây nhưng bắt buộc): một giá trị ngẫu nhiên gửi đi và kiểm khi quay về, chống CSRF trên chính luồng đăng nhập.
  • Với OIDC, kiểm chữ ký của id_token và trường aud/iss. id_token là một JWT — mọi lỗi kiểm JWT đều áp dụng ở đây, cộng thêm phải kiểm token đúng là cấp cho ứng dụng của bạn (aud) bởi máy chủ bạn tin (iss).

Cùng một ý tưởng "chứng minh biết bí mật mà không lộ bí mật", đổi ngăn xếp

Cái lõi của PKCE — gửi băm công khai, giữ bản gốc bí mật, chứng minh bằng cách trình bản gốc — không riêng gì OAuth2. Nó là một mẫu mật mã lặp lại ở khắp nơi.

Trong thế giới Java, các thư viện OAuth trưởng thành (Spring Authorization Server, Nimbus) đều hỗ trợ PKCE sẵn, và điểm đáng nói là Spring Authorization Server bắt buộc PKCE cho public client — đúng hướng "an toàn theo mặc định". Nhưng cùng ý tưởng "trình bằng chứng của bí mật, không trình bí mật" còn xuất hiện ở chỗ khác trong Java: xác thực challenge-response (như CRAM-MD5, hay cơ chế SCRAM mà JDBC driver dùng để nối Postgres) — client chứng minh biết mật khẩu mà không gửi mật khẩu qua dây.

Trong Go, golang.org/x/oauth2 cho PKCE qua oauth2.GenerateVerifier() và S256ChallengeOption — và cộng đồng đã chuẩn hoá dùng nó cho mọi CLI/desktp OAuth, chính vì những công cụ đó chạy trên máy người dùng nơi code dễ bị chương trình khác trên cùng máy đọc lén. Đây đúng kịch bản "kẻ tấn công đã có code" mà PKCE sinh ra để chặn.

Còn ở cơ sở dữ liệu, họ hàng gần nhất là SCRAM — cơ chế xác thực mặc định của PostgreSQL hiện đại. Thay vì gửi mật khẩu (hay cả băm mật khẩu) qua dây, client và server trao đổi challenge-response để client chứng minh nó biết mật khẩu, mà kẻ nghe lén trên dây không học được gì tái dùng được. Cùng nguyên tắc "một chiều": cái đi qua kênh không đủ để mạo danh.

Mẫu số chung: an toàn thật đến từ việc chứng minh mình sở hữu một bí mật bằng một bằng chứng phái sinh (băm, challenge-response), sao cho kẻ chặn được bằng chứng đi qua kênh vẫn không tái tạo được bí mật gốc. Cái đi qua đường công khai luôn phải là thứ vô dụng nếu bị trộm; cái có giá trị thật (verifier, mật khẩu) không bao giờ rời chủ của nó. Nên câu hỏi đúng khi thiết kế bất kỳ luồng xác thực nào không phải "kênh này có mã hoá không" mà là "nếu ai đó chặn được toàn bộ những gì đi qua kênh, họ có mạo danh được không" — và PKCE trả lời "không", còn implicit flow trả lời "có".

Muốn tự soát, nếu ứng dụng của bạn dùng "Đăng nhập bằng Google/GitHub/...", tìm trong mã chỗ cấu hình luồng OAuth. Nó dùng response_type=code (authorization code) hay response_type=token (implicit)? Nếu là token, bạn đang dùng luồng đã lỗi thời để token rơi vào URL trình duyệt — chuyển sang authorization code với PKCE. Nếu là code, kiểm tiếp: có tham số code_challenge không? Nếu không, bạn đang thiếu lớp chặn tấn công chặn-code mà dòng cuối bảng trong bài này cho thấy là một lỗ hổng đầy đủ.