Loạt bài phòng thủ: hiểu lỗ hổng để viết code an toàn.

Đây là một lỗ hổng tinh vi đến mức nhìn code không thấy gì sai. Khi server kiểm tra một token phiên, một chữ ký HMAC, hay một API key, nó so chuỗi người dùng gửi với chuỗi đúng. Cách tự nhiên nhất: if userToken == correctToken. Trông hoàn toàn bình thường. Nhưng phép so sánh chuỗi thông thường có một đặc tính chết người cho bảo mật: nó thoát sớm — ngay khi gặp byte đầu tiên khác nhau, nó trả về false luôn, không so tiếp.

Hệ quả: thời gian so sánh phụ thuộc vào số byte khớp ở đầu. Nếu token bạn gửi sai ngay từ byte đầu, so sánh kết thúc tức thì. Nếu khớp 10 byte đầu rồi mới sai, so sánh mất lâu hơn một chút. Khác biệt cực nhỏ (nano-giây), nhưng nó tồn tại — và một kênh thời gian như vậy, về lý thuyết, cho phép kẻ tấn công dò ra token từng byte một. Bài này (phần 3 loạt Bảo mật web) đo thật kênh rò rỉ đó, và cách so sánh constant-time bịt nó.

Cơ chế: thoát sớm vs luôn so hết

Ảnh chụp đoạn mã nền tối minh hoạ timing attack so sánh bằng == làm rò rỉ bí mật, khối so sánh thoát sớm bằng bytes.Equal vòng lặp return sớm func naiveEqual a b byte bool for i range a if a i khác b i return false thoát ở byte đầu khác return true khớp càng nhiều byte đầu so càng lâu mới thoát thời gian leak số byte đúng dò token từng byte, khối so sánh constant-time luôn so hết mọi byte import crypto subtle hoặc hmac.Equal cho MAC subtle.ConstantTimeCompare a b bằng 1 XOR hết mọi byte rồi mới kết luận thời gian không phụ thuộc nội dung không rò rỉ gì về secret, khối dùng constant-time khi so token phiên API key chữ ký HMAC CSRF token bất cứ chuỗi bí mật nào dữ liệu công khai thì không cần

Hình 1: So sánh thoát sớm (==, bytes.Equal, hay vòng lặp return sớm) — khớp càng nhiều byte đầu thì so càng lâu mới thoát, thời gian leak số byte đúng. Constant-time (subtle.ConstantTimeCompare / hmac.Equal) — XOR hết mọi byte rồi mới kết luận, thời gian không phụ thuộc nội dung. Dùng cho mọi chuỗi bí mật (token, API key, chữ ký HMAC, CSRF token).

Đo thật trong go-lab

Để kênh thời gian ns thành đo được, mình dùng một secret dài (200.000 byte) trong go-lab (golang 1.23), lặp 20.000 vòng lấy trung bình, so các ứng viên khớp 0%, 25%, 50%, 75%, 100% byte đầu.

Ảnh chụp bảng kết quả chạy thật trong go-lab output thật golang 1.23 secret 200.000 byte 20.000 vòng lấy TB, khối một naiveEqual bằng bằng thoát sớm thời gian tăng theo số byte khớp khớp 0 phần trăm byte đầu 1.2 ns khớp 25 phần trăm 12.733 ns khớp 50 phần trăm 25.321 ns khớp 75 phần trăm 38.145 ns khớp 100 phần trăm 50.692 ns tuyến tính theo số byte khớp đầu đo thời gian là đoán được đã đúng mấy byte dò token từng byte, khối hai ConstantTimeCompare thời gian phẳng không rò rỉ khớp 0 phần trăm 54.792 ns khớp 50 phần trăm 54.875 ns khớp 100 phần trăm 54.832 ns luôn so hết mọi byte thời gian không phụ thuộc nội dung khoảng 54.700 ns bất kể khớp bao nhiêu dù chênh lệch chỉ ns qua mạng với đủ mẫu thống kê thì vẫn khai thác được luôn dùng constant-time cho bí mật

Hình 2: Kết quả thật — naiveEqual (thoát sớm): thời gian tuyến tính theo số byte khớp (1.2ns ở 0% → 12.733 → 25.321 → 38.145 → 50.692ns ở 100%); ConstantTimeCompare: phẳng ~54.700ns bất kể khớp 0% hay 100%.

Kết quả không thể rõ hơn:

  • Thoát sớm: thời gian tuyến tính theo số byte khớp. Khi ứng viên sai ngay byte đầu (khớp 0%), so sánh xong trong 1.2ns. Khi khớp 25% byte đầu, mất 12.733ns. 50% → 25.321ns. 75% → 38.145ns. Khớp gần hết (100%) → 50.692ns. Đường thẳng tắp: thời gian tỉ lệ với số byte khớp. Nghĩa là chỉ cần đo thời gian phản hồi, kẻ tấn công biết được mình đã đoán đúng bao nhiêu byte đầu — và từ đó dò từng byte: thử 256 giá trị cho byte đầu, cái nào làm thời gian tăng là đúng, rồi chuyển sang byte sau. Một token tưởng không thể đoán trở nên dò được tuyến tính.
  • Constant-time: thời gian phẳng. subtle.ConstantTimeCompare cho ~54.792ns ở 0%, ~54.875ns ở 50%, ~54.832ns ở 100% — chênh lệch trong khoảng nhiễu đo, không phụ thuộc số byte khớp. Vì nó XOR tất cả các byte rồi mới kết luận (không thoát sớm bao giờ), thời gian là hằng số với mọi input. Không có kênh thời gian nào để khai thác.

Điểm cần nhấn mạnh về cách đo: khác biệt ở đây rõ vì mình cố ý dùng secret 200.000 byte. Với token thật (vài chục byte), khác biệt chỉ cỡ nano-giây và chìm trong nhiễu — nên timing attack thực tế rất khó, nhất là qua mạng. Nhưng "khó" không phải "bất khả thi": với đủ mẫu thống kê (hàng triệu request) và phân tích nhiễu, kênh này đã được khai thác thành công trong nghiên cứu. Nguyên tắc phòng thủ không dựa vào "khó khai thác" mà dựa vào "triệt tiêu kênh rò rỉ".

Đánh đổi cần cân nhắc

Luôn dùng constant-time cho chuỗi bí mật — chi phí gần như bằng không. Quy tắc đơn giản: bất cứ khi nào so sánh một thứ bí mật mà kẻ tấn công có thể điều khiển một vế và đo thời gian, dùng subtle.ConstantTimeCompare (Go), hmac.compare_digest (Python), crypto.timingSafeEqual (Node)... Áp dụng cho: token phiên, API key, CSRF token, và đặc biệt là so sánh chữ ký HMAC (dùng hmac.Equal, không bao giờ ==). Chi phí của constant-time không đáng kể (vài chục ns), và nó loại bỏ hẳn cả một lớp lỗ hổng — không có lý do gì không dùng.

Không phải cái gì cũng cần constant-time — chỉ bí mật. Đừng vàng hoá: so sánh dữ liệu công khai (tên người dùng để hiển thị, so chuỗi trong logic nghiệp vụ không nhạy cảm) không cần constant-time — dùng == bình thường cho đơn giản và nhanh. Constant-time chỉ cần khi (a) một vế là bí mật, và (b) kẻ tấn công điều khiển vế kia và quan sát được thời gian. Nhận đúng ranh giới này để không làm code phức tạp vô ích ở chỗ không cần.

Timing attack không chỉ ở so sánh chuỗi — cảnh giác các kênh phụ khác. So sánh byte là ví dụ dễ thấy nhất, nhưng kênh phụ thời gian (timing side-channel) rộng hơn: thời gian xử lý phụ thuộc dữ liệu bí mật ở bất cứ đâu đều có thể rò rỉ — ví dụ "username không tồn tại" trả về nhanh hơn "username đúng, mật khẩu sai" (lộ username nào tồn tại). Phòng thủ chung: với các thao tác nhạy cảm, cố gắng làm thời gian độc lập với bí mật (ví dụ luôn chạy bcrypt kể cả khi user không tồn tại, để thời gian đăng nhập không lộ username hợp lệ). Tư duy "thời gian cũng là một kênh thông tin" là bài học lớn nhất ở đây.

Ba ý mang về

  1. So sánh == thoát sớm làm rò rỉ bí mật qua thời gian: đo thật, so sánh thoát sớm cho thời gian tuyến tính theo số byte khớp đầu (1.2ns ở 0% → 50.692ns ở 100%) — đo thời gian là biết đã đoán đúng mấy byte, về lý thuyết cho phép dò token từng byte một.
  2. Constant-time triệt tiêu kênh rò rỉ: đo thật, subtle.ConstantTimeCompare cho thời gian phẳng ~54.700ns bất kể khớp 0% hay 100%, vì luôn so hết mọi byte; dùng hmac.Equal cho chữ ký, ConstantTimeCompare cho token/API key.
  3. Dùng đúng chỗ và cảnh giác kênh phụ nói chung: luôn constant-time khi so chuỗi bí mật mà kẻ tấn công điều khiển một vế + đo được thời gian (chi phí ~0); không cần cho dữ liệu công khai; và nhớ timing side-channel rộng hơn so sánh chuỗi (ví dụ thời gian đăng nhập lộ username tồn tại) — làm thời gian độc lập với bí mật.

Nguồn

Phần sau ta sang XSS: khi dữ liệu người dùng được nhúng vào HTML mà không mã hoá, nó biến thành mã chạy trong trình duyệt nạn nhân; đo thật cách html/template tự escape vô hiệu hoá payload, và vì sao mã hoá phải theo ngữ cảnh.