Bạn đã xem cảnh phim trộm két: kẻ gian xoay từng nấc khoá số, tai áp sát, nghe tiếng "tách" khe khẽ báo một nấc vừa vào đúng vị trí. Hắn không cần thử hết mọi tổ hợp — cái ổ khoá rò rỉ cho hắn biết từng nấc đúng, nên hắn dò tuyến tính thay vì vét cạn. Một so sánh token viết ngây thơ chính là cái ổ khoá đó, và cái "tách" ở đây không phải âm thanh mà là thời gian: mỗi byte đoán đúng làm phản hồi chậm thêm một chút. Bài này tôi đo cái "tách" ấy bằng đồng hồ nano giây.

Bài trước đo rò rỉ qua nội dung thông báo lỗi. Bài này đo một kênh rò tinh vi hơn nhiều: thời gian. Khi máy chủ so sánh một token người dùng gửi với một bí mật, cách so sánh có thể để lộ token đúng tới đâu — không qua nội dung phản hồi, mà qua việc phản hồi mất bao lâu. Đây là timing side channel, và tôi đo nó thật bằng đồng hồ nano giây.

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.

Cơ chế

Một so sánh chuỗi ngây thơ duyệt từng byte và dừng ngay khi gặp byte khác nhau. Nghĩa là: nếu byte đầu đã sai, nó trả về False gần như tức thì; nếu 1000 byte đầu đúng và byte 1001 sai, nó phải duyệt qua 1000 byte trước khi dừng — tốn thời gian hơn. Kẻ tấn công đo thời gian này: đoán byte đầu, thử hết 256 giá trị, giá trị nào làm phản hồi chậm hơn một chút là byte đúng. Rồi chuyển sang byte thứ hai. Thay vì bruteforce cả token (bất khả thi), chúng dò từng byte một — tuyến tính thay vì mũ. Đúng như kẻ trộm nghe tiếng "tách".

Số đo

Tôi đo thời gian so sánh một token 2000 byte, với số byte đầu khớp tăng dần. Ba lần chạy, kết quả nhất quán:

So sánh byte-by-byte dừng sớm:

Số byte đầu đúng Thời gian trung vị
0 125 ns
500 4.833 ns
1000 9.083 ns
1500 13.541 ns
1999 18.000 ns

hmac.compare_digest (hằng thời gian):

Số byte đầu đúng Thời gian trung vị
0 666 ns
1999 666 ns

Ba điều đáng nhớ

Thời gian tăng tuyến tính theo số byte đúng — khoảng 9 nano giây mỗi byte. Từ 125 ns (không byte nào đúng) tới 18.000 ns (gần như toàn bộ đúng), gần như một đường thẳng. Đây chính là tín hiệu kẻ tấn công cần: mỗi byte đoán đúng cộng thêm một lượng thời gian đo được. Với đủ phép đo (lấy trung vị để lọc nhiễu), kẻ tấn công phân biệt được "byte này đúng" với "byte này sai" — và dò được cả bí mật, byte một.

compare_digest giữ thời gian không đổi — 666 ns bất kể số byte đúng. Nó cố tình luôn duyệt hết mọi byte, dù đã tìm thấy khác biệt từ sớm, và gộp kết quả bằng phép toán bit thay vì trả về ngay. Nên thời gian của nó không phụ thuộc token đúng tới đâu. Kẻ tấn công đo thời gian chỉ thấy một hằng số — không có tín hiệu để khai thác. Đây là so sánh hằng thời gian, và nó là cách đúng để so sánh mọi bí mật: token, chữ ký, mã xác thực.

Nhưng có một bất ngờ tôi phải sửa giữa chừng — và nó là bài học quan trọng nhất. Bộ đo đầu tiên của tôi dùng toán tử == của Python trên token ngắn (32 byte), và kết quả cho thấy == không lộ timing — thời gian không đổi bất kể số byte đúng. Tôi suýt kết luận sai rằng "== cũng an toàn". Sự thật: với chuỗi ngắn cùng độ dài, == của Python gọi memcmp — một hàm C tối ưu so cả khối byte cùng lúc (dùng lệnh SIMD), không duyệt từng byte dừng sớm. Nên nó tình cờ không lộ timing trong trường hợp đó. Timing leak kinh điển chỉ hiện ra khi so sánh byte-by-byte thủ công — kiểu nhiều người tự viết bằng vòng lặp — và trên token đủ dài để chênh lệch vượt nhiễu. Tôi phải đổi cả hai điều đó thì tín hiệu mới xuất hiện.

Vì sao kênh này khó thấy đến vậy

Timing side channel khác mọi lỗ trước ở một chỗ cốt yếu: thông tin rò không nằm trong dữ liệu, mà trong hành vi. Máy chủ không nói cho kẻ tấn công token đúng tới đâu; nó để lộ qua việc phản hồi nhanh hay chậm. Đây là lý do nó dễ bị bỏ qua đến vậy — không có gì sai trong nội dung phản hồi, mã chạy đúng chức năng, test chức năng đều xanh. Lỗ nằm ở một chiều mà lập trình viên hiếm khi nghĩ tới: thời gian.

Bài học "phải đo đúng cách mới thấy" cũng là cốt lõi của chính chủ đề này. Timing side channel là một trong những lỗ khó phát hiện nhất, vì tín hiệu chìm trong nhiễu — mạng, lịch trình CPU, bộ nhớ đệm đều thêm dao động lớn hơn nhiều so với 9 ns mỗi byte. Kẻ tấn công thật phải lấy hàng nghìn tới hàng triệu phép đo và dùng thống kê để rút tín hiệu ra khỏi nhiễu. Điều đó khiến timing attack khó thực hiện qua mạng — nhưng "khó" không phải "bất khả": các cuộc tấn công timing thật qua mạng đã được chứng minh.

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

  • Dùng so sánh hằng thời gian cho mọi bí mật. hmac.compare_digest (Python), crypto.timingSafeEqual (Node), MessageDigest.isEqual (Java) — dùng chúng khi so sánh token, chữ ký, mã API, mã xác thực. Đừng dùng == hay vòng lặp tự viết.
  • Không tự viết so sánh byte-by-byte cho bí mật. Đây là chỗ timing leak sinh ra rõ nhất. Nếu bạn thấy một vòng for so sánh token, đó là một cảnh báo.
  • Đừng dựa vào "== tình cờ an toàn". Phép đo cho thấy == của Python không lộ timing cho chuỗi ngắn — nhưng đó là chi tiết cài đặt, không phải đảm bảo. Ngôn ngữ khác, độ dài khác, phiên bản khác có thể khác. Dùng hàm hằng-thời-gian tường minh để không phụ thuộc may rủi.
  • Ưu tiên chống timing cho các bí mật băm. So sánh băm của token (thay vì token thô) cũng giảm rủi ro: kẻ tấn công không thể dò từng byte của token gốc qua timing của phép so sánh băm, vì đổi một byte token đổi toàn bộ băm.

Cùng một cái bẫy "so sánh dừng sớm", đổi ngăn xếp

Chuyện "so sánh bí mật kiểu ngây thơ rò thông tin qua thời gian" không riêng gì Python — mỗi nền tảng có hàm hằng-thời-gian riêng chính vì lỗ này quá phổ biến.

Trong Java, cái bẫy kinh điển là token.equals(secret) trên String — String.equals duyệt từng char và dừng sớm ở char đầu tiên khác nhau, đúng kiểu rò timing. Câu trả lời của thư viện chuẩn là MessageDigest.isEqual(byte[], byte[]), được cố tình sửa để chạy hằng thời gian (các bản JDK cũ không hằng thời gian — một CVE thật). Ai xác thực chữ ký JWT hay webhook trong Java mà dùng equals là để hở đúng kênh này.

Trong Go, thư viện chuẩn cung cấp crypto/subtle.ConstantTimeCompare chính xác cho mục đích này, và tài liệu ghi rõ đừng dùng bytes.Equal hay == cho HMAC/token. Đây là ví dụ đẹp của việc ngôn ngữ đặt công cụ đúng ngay trong stdlib với tên tự nhắc "constant time" — nhưng vẫn phải người viết chọn dùng.

Còn ở cơ sở dữ liệu, cùng cái bẫy hiện ra khi xác thực API key hay token phiên: một truy vấn WHERE token = '<user_input>' không chỉ có thể rò timing ở tầng so sánh, mà tệ hơn, việc tra khớp bằng index có thể lộ qua thời gian truy vấn. Chuẩn an toàn là lưu và so sánh băm của token (kèm so sánh hằng thời gian ở tầng ứng dụng), không bao giờ so token thô — đúng như gạch đầu dòng cuối ở trên.

Mẫu số chung: bất cứ khi nào một so sánh dừng lại sớm dựa trên nội dung bí mật, thời gian thực thi của nó trở thành một kênh rò rỉ chính nội dung đó — và phòng thủ là luôn tiêu thụ cùng một lượng công việc bất kể so sánh đúng hay sai. Đây là một chiều mà mọi phép kiểm chức năng đều mù, vì mã vẫn "đúng"; chỉ đồng hồ mới thấy. Nên câu hỏi đúng cho mỗi chỗ so sánh bí mật không phải "nó trả kết quả đúng chưa" mà là "thời gian nó chạy có phụ thuộc vào giá trị bí mật không".

Muốn tự soát, tìm trong mã của bạn mọi chỗ so sánh một giá trị người dùng với một bí mật — kiểm token API, xác thực chữ ký webhook, so sánh mã đặt lại mật khẩu, kiểm CSRF token. Nếu bạn thấy if token == bi_mat hay một vòng lặp so sánh từng ký tự, đổi sang hàm so sánh hằng thời gian của ngôn ngữ bạn. Nó không làm mã chậm đi đáng kể (666 ns so với 18.000 ns ở trường hợp xấu nhất — thực ra thường nhanh hơn), và nó đóng một kênh rò mà không phép kiểm nội dung nào bắt được.