Bạn viết một endpoint kiểm API key: if token == token_dung { ... }. Trông không thể vô hại hơn. Nhưng dấu == đó — hay bất kỳ so sánh chuỗi thoát sớm nào — là một lỗ hổng bảo mật thật, và quan trọng hơn: đo được. Vì phép so byte-theo-byte dừng lại ngay khi gặp byte khác nhau đầu tiên, thời gian nó chạy tiết lộ bạn đã đoán đúng mấy byte đầu. Kẻ tấn công dùng thông tin đó để dò từng byte của bí mật. Bài này (phần 17 loạt Mật mã) đo hiện tượng này bằng Go với số liệu thật, rồi cho thấy so sánh hằng thời gian dập tắt nó thế nào.
Vì sao so sánh "thoát sớm" rò rỉ thông tin
Một hàm so bằng ngây thơ duyệt từng byte và trả false ngay khi thấy byte đầu tiên khác:
func naiveEqual(a, b []byte) bool {
for i := range a {
if a[i] != b[i] { return false } // thoát ngay byte sai đầu tiên
}
return true
}
Hệ quả tinh vi: nếu guess của kẻ tấn công khớp k byte đầu rồi mới sai, vòng lặp chạy đúng k+1 bước mới thoát. Guess khớp nhiều byte đầu → chạy lâu hơn. Sự chênh lệch thời gian này chính là kênh phụ (side-channel) — một cái đồng hồ đo được bí mật.

Hình 1: So sánh thoát sớm chạy k+1 bước khi khớp k byte đầu → thời gian rò rỉ số byte đúng, cho phép tấn công dò từng byte. Cách đúng: luôn duyệt hết mọi byte (ConstantTimeCompare / compare_digest / timingSafeEqual).
Đo thật: timing oracle hiện rõ
Mình đo bằng Go (đồng hồ nano giây, lấy trung vị của 2001 lần × 20000 vòng để lọc nhiễu). Bí mật 32 byte; mỗi guess khớp k byte đầu rồi byte thứ k+1 sai:

Hình 2: Chạy thật — naiveEqual tăng đều theo số byte đúng: 1,185 ns (0 byte) → 2,767 (8) → 4,987 (16) → 6,996 (24) → 8,571 ns (31). ConstantTimeCompare phẳng ~9,2 ns ở mọi mức khớp.
Con số nói rõ:
naiveEquallà một timing oracle thẳng thớm: 0 byte đúng mất 1,185 ns; 31 byte đúng mất 8,571 ns — tăng gần như tuyến tính theo số byte khớp. Thời gian chạy tỉ lệ với độ đúng của guess.subtle.ConstantTimeComparephẳng lì: 9,188 / 9,365 / 9,281 / 9,185 / 9,192 ns — dao động chỉ do nhiễu đo, không hề theo số byte khớp. Kẻ tấn công cầm đồng hồ cũng không học được gì.
Cách tấn công dùng oracle này
Với naiveEqual, kẻ tấn công dò byte đầu tiên: thử cả 256 giá trị 00..FF, cái nào cho phản hồi chậm hơn một chút (vì lọt qua byte 0) chính là byte đúng. Khoá xong byte 0, lặp lại cho byte 1, byte 2... Thay vì phải thử 256^32 tổ hợp (bất khả thi), họ chỉ cần khoảng 256 × 32 lần thử — hoàn toàn khả thi. Đây là lý do các lỗ hổng "so sánh không hằng thời gian" từng xuất hiện trong xác minh HMAC, so token đặt lại mật khẩu, và kiểm chữ ký của không ít thư viện thật.
Cách đúng: luôn duyệt hết
Hàm so hằng thời gian luôn kiểm tất cả byte rồi gộp mọi khác biệt lại (thường bằng OR các XOR), nên thời gian không phụ thuộc chỗ nào khác nhau. Mọi ngôn ngữ đều có sẵn:
subtle.ConstantTimeCompare(a, b) == 1 // Go
hmac.compare_digest(a, b) # Python
crypto.timingSafeEqual(a, b) // Node.js
Dùng chúng cho mọi so sánh dữ liệu bí mật: API key, token phiên, mã đặt lại mật khẩu, tag HMAC, chữ ký. Đây chính là lý do các bài trước trong loạt (JWT, ChaCha20-Poly1305) đều nhấn mạnh so tag bằng hàm hằng thời gian.
Đánh đổi cần cân nhắc
Chênh lệch nano giây khó khai thác qua mạng — nhưng đừng dựa vào đó. Nhiễu mạng lớn hơn nhiều so với vài ns chênh lệch, nên tấn công timing từ xa cần lấy trung bình rất nhiều mẫu và không phải lúc nào cũng thành công. Tuy vậy, nó đã được chứng minh khả thi qua mạng LAN trong nghiên cứu, và trên cùng máy (đa người dùng, container chung host) thì dễ hơn hẳn. Nguyên tắc phòng thủ theo lớp: chi phí dùng hàm hằng thời gian gần như bằng 0, nên không có lý do gì để mạo hiểm.
Constant-time không chỉ là "duyệt hết". Trình biên dịch có thể tối ưu làm hỏng tính hằng thời gian, và nhiều thao tác khác (tra bảng phụ thuộc bí mật, rẽ nhánh theo bí mật) cũng rò rỉ. Vì vậy hãy dùng hàm thư viện chuẩn (ConstantTimeCompare...) thay vì tự viết — chúng được thiết kế và kiểm để chống cả những tối ưu ngầm.
So sánh hash mật khẩu là trường hợp riêng. Khi so mật khẩu, bạn không so trực tiếp mà so hash (bcrypt/argon2 — bài sau). Bản thân hàm băm mật khẩu đã chậm có chủ đích; nhưng khi so kết quả hash vẫn nên dùng hàm hằng thời gian để nhất quán. Đừng bao giờ so mật khẩu thô bằng ==.
Ba ý mang về
- So sánh thoát sớm là timing oracle: đo thật bằng Go,
naiveEqualtăng đều 1,2 → 8,6 ns theo số byte đầu khớp — thời gian rò rỉ độ đúng của guess, cho phép dò bí mật từng byte (256×32 lần thay vì 256^32). - Hàm hằng thời gian dập tắt kênh phụ: đo thật
subtle.ConstantTimeComparephẳng ~9,2 ns bất kể khớp bao nhiêu, vì nó luôn duyệt hết và gộp khác biệt — kẻ tấn công không học được gì từ đồng hồ. - Luôn dùng hàm chuẩn cho dữ liệu bí mật:
ConstantTimeCompare(Go) /hmac.compare_digest(Python) /crypto.timingSafeEqual(Node) cho token, MAC, chữ ký — chi phí gần như bằng 0, đừng tự viết và đừng dùng==.
Nguồn
- Go — crypto/subtle.ConstantTimeCompare: https://pkg.go.dev/crypto/subtle#ConstantTimeCompare
- Python — hmac.compare_digest: https://docs.python.org/3/library/hmac.html#hmac.compare_digest
- NCC Group — Double HMAC verification / timing attacks in practice: https://www.nccgroup.com/us/research-blog/double-hmac-verification/
Phần sau khép lại loạt với băm mật khẩu hiện đại: Argon2 — vì sao băm mật khẩu phải chậm và tốn bộ nhớ có chủ đích, và cách chỉnh tham số để chống dò mật khẩu bằng GPU.