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à bài này đo nó thật bằng đồng hồ nano giây.
Toàn bộ chạy trong container tự dựng, tự dọn. 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ũ.
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 |
Đ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
Timing side channel khác mọi lỗ trước ở chỗ: 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 — 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 thực tế
- 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
forso 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.
Chỗ tôi không kết luận được
Tôi đo timing cục bộ trong tiến trình, nơi tín hiệu 9 ns/byte rõ ràng. Qua mạng, nhiễu (độ trễ, jitter, hàng đợi) lớn hơn tín hiệu này nhiều bậc, nên timing attack thực tế qua Internet khó hơn rất nhiều so với con số của tôi gợi ý — cần rất nhiều phép đo và điều kiện thuận lợi. Tôi chứng minh cơ chế, không chứng minh nó dễ khai thác từ xa.
Và tôi chỉ đo so sánh chuỗi. Timing side channel rộng hơn: thời gian đăng nhập khác nhau giữa "user tồn tại" và "không tồn tại", thời gian giải mã phụ thuộc nội dung (padding oracle), thời gian truy vấn phụ thuộc dữ liệu. Mỗi cái là một biến thể của cùng ý tưởng — thời gian là một kênh — mà tôi không đo hết.
Thử ba mươi giây
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.