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.

Ảnh chụp đoạn mã nền tối minh hoạ so sánh hằng thời gian vì sao dùng bằng bằng để so token là lỗ hổng, so sánh thoát sớm rò rỉ thông tin qua thời gian func naiveEqual a b byte bool for i range a if a i khác b i return false thoát ngay byte sai đầu tiên return true khớp càng nhiều byte đầu chạy càng lâu mới thoát kẻ tấn công đo thời gian đoán được từng byte của bí mật, tấn công byte-by-byte timing oracle thử byte 0 từ 00 tới FF cái nào cho phản hồi chậm hơn chút là byte đúng khoá xong byte 0 chuyển sang byte 1 bí mật 32 byte từ 256 mũ 32 khả năng còn khoảng 256 nhân 32 lần thử, cách đúng luôn duyệt hết không thoát sớm subtle ConstantTimeCompare a b Go hmac compare_digest a b Python crypto timingSafeEqual a b Node.js duyệt tất cả byte gộp khác biệt bằng OR XOR thời gian không phụ thuộc dữ liệu dùng cho token chữ ký MAC hash

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:

Ảnh chụp bảng kết quả chạy thật Go đo trung vị 2001 nhân 20000 lần output thật secret 32 byte guess khớp k byte đầu byte thứ k cộng 1 sai, naiveEqual thoát sớm thời gian tăng theo số byte đúng khớp 0 byte đầu 1.185 ns mỗi lần khớp 8 byte đầu 2.767 ns khớp 16 byte đầu 4.987 ns khớp 24 byte đầu 6.996 ns khớp 31 byte đầu 8.571 ns tăng đều là timing oracle, subtle ConstantTimeCompare thời gian phẳng khớp 0 byte đầu 9.188 ns khớp 8 byte đầu 9.365 ns khớp 16 byte đầu 9.281 ns khớp 24 byte đầu 9.185 ns khớp 31 byte đầu 9.192 ns không đổi dù khớp bao nhiêu, kết naive thời gian dốc theo prefix đúng lộ từng byte bí mật constant-time phẳng kẻ tấn công không học được gì từ đồng hồ

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õ:

  • naiveEqual là 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.ConstantTimeCompare phẳ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ề

  1. So sánh thoát sớm là timing oracle: đo thật bằng Go, naiveEqual tă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).
  2. Hàm hằng thời gian dập tắt kênh phụ: đo thật subtle.ConstantTimeCompare phẳ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ồ.
  3. 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

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.