Cả một giao thức HTTP đồ sộ dựng cấu trúc của nó trên một ký tự bạn không nhìn thấy: cặp xuống dòng \r\n ngăn cách các header, và \r\n\r\n báo "hết header, thân bắt đầu từ đây". Nó giống như dấu chấm câu vô hình trong một lá thư đánh máy — bỏ đúng chỗ thì thư mạch lạc, nhưng nếu ai đó lén gõ thêm một dấu xuống dòng vào giữa một câu, cả phần sau bị đọc thành một đoạn mới. Chèn tiêu đề HTTP (CRLF injection) chính là trò lén gõ thêm đó: kẻ tấn công đưa được \r\n vào một giá trị mà máy chủ đặt vào header, tạo ra một ranh giới giả, rồi từ đó chèn header tuỳ ý hoặc cả một phản hồi giả. Bài này tôi dựng lại lỗ và đo cách các thư viện hiện đại chặn nó.
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.
Cuộc tấn công
Giả sử máy chủ đặt một giá trị người dùng vào header Set-Cookie — ví dụ một mã phiên. Nếu nó nối chuỗi giá trị đó vào response thô, và giá trị chứa \r\n, ranh giới header bị phá. Payload:
phien=abc\r\nSet-Cookie: admin=true\r\n\r\n<html>trang giả mạo
Ba lần chạy, kết quả giống hệt. Với server ngây thơ (nối chuỗi), response bị chèn và chia:
HTTP/1.1 200 OK
Set-Cookie: phien=abc
Set-Cookie: admin=true <- HEADER GIẢ do kẻ tấn công chèn
<- dòng trống: kết thúc header, bắt đầu thân
<html>trang giả mạo <- THÂN GIẢ
Content-Type: text/html
...
Từ một giá trị, kẻ tấn công tạo ra hai header Set-Cookie (đáng lẽ chỉ một) và một thân phản hồi giả xen vào giữa. Còn với thư viện HTTP hiện đại thì nó bị chặn ngay ở tầng:
| Cách đặt header | Kết quả |
|---|---|
http.client với giá trị chứa \r\n |
ValueError: Invalid header value |
Werkzeug (Flask) với giá trị chứa \r\n |
ValueError: Header values must not contain newline characters |
Ba điều đáng nhớ
Cùng một payload: chèn được header giả và chia được phản hồi. Hai Set-Cookie trong kết quả cho thấy sức mạnh — kẻ tấn công đặt được cookie tuỳ ý (ví dụ admin=true) vào trình duyệt nạn nhân. Và \r\n\r\n giữa chừng còn tệ hơn: nó kết thúc phần header sớm và bắt đầu một thân phản hồi do kẻ tấn công viết. Đây gọi là HTTP response splitting — từ một phản hồi, tách ra nội dung giả mà trình duyệt hiển thị như thật; kết hợp với cache, một phản hồi giả có thể bị lưu và phục vụ cho nhiều người dùng khác.
Thư viện hiện đại đã vá lỗ này ở tầng — lại một mặc-định-an-toàn. Giống XXE và cái bẫy Referer ở các bài trước, đây là kết quả đáng mừng: cả http.client chuẩn của Python lẫn Werkzeug (nền của Flask) đều từ chối đặt một header chứa \r hay \n, ném ValueError ngay. Bạn không thể vô tình chèn header injection nếu dùng các thư viện này qua API bình thường — chúng kiểm mọi giá trị header và chặn ký tự xuống dòng. Xu hướng an-toàn-theo-mặc-định lặp lại: các lỗ kinh điển được đóng ở tầng thư viện, không để lập trình viên phải nhớ.
Nhưng nó thủng ngay khi bạn tự dựng response bằng nối chuỗi. Phần một của phép đo là một server "tự viết HTTP" — nối trực tiếp giá trị vào chuỗi response, và đó là nơi lỗ mở. Ngày nay ít ai tự viết HTTP thô, nhưng nó vẫn xảy ra: một proxy tự chế, một script sinh header thủ công, một đoạn mã cũ đặt Location: hay Set-Cookie: bằng cách ghép chuỗi. Ở đâu giá trị người dùng chạm vào một header mà không qua thư viện kiểm, ở đó CRLF injection sống lại.
Hậu quả không dừng ở một phản hồi
Đáng nói vì sao response splitting nguy hiểm hơn vẻ ngoài "chèn một cookie". Ba hậu quả leo thang: đặt cookie tuỳ ý như trong phép đo (Set-Cookie: admin=true giả), mà kết hợp với session fixation thì kẻ tấn công gieo được một session ID nó biết trước; chèn nội dung giả mà trình duyệt tin, vì thân phản hồi giả đến từ chính tên miền của bạn nên qua được mọi kiểm same-origin — một dạng XSS phản chiếu qua header thay vì qua thân; và đầu độc cache — nếu một cache (CDN, proxy) lưu phản hồi bị chia, nó phục vụ nội dung giả cho mọi người dùng sau đó, biến một lỗ ảnh hưởng một nạn nhân thành một lỗ ảnh hưởng hàng loạt, và đây là biến thể nguy hiểm nhất vì nó không cần dụ từng nạn nhân. Điểm chung: một \r\n chèn được không chỉ thêm một dòng — nó cho kẻ tấn công viết lại cấu trúc của phản hồi, và mọi thứ dựa vào cấu trúc đó (trình duyệt, cache) đều bị lừa theo.
Vì sao
CRLF injection là một biến thể hoàn hảo của bài học xuyên suốt sê-ri: ký tự điều khiển của giao thức lẫn vào dữ liệu người dùng. Danh sách các lỗ cùng cấu trúc giờ đã dài:
- SQL injection:
'là ký tự điều khiển của SQL. - XSS:
<là ký tự điều khiển của HTML. - Command injection:
;và ký tự xuống dòng là ký tự điều khiển của shell. - Header injection:
\r\nlà ký tự điều khiển của HTTP.
Mọi trường hợp đều là cùng một sai lầm: một chuỗi vừa mang dữ liệu (giá trị người dùng) vừa mang cấu trúc (cú pháp giao thức), và không có ranh giới rõ giữa hai thứ. Cách chặn cũng cùng nguyên tắc: hoặc tách dữ liệu khỏi cấu trúc (dùng API thư viện, không nối chuỗi), hoặc vô hiệu hoá ký tự điều khiển trong dữ liệu (loại bỏ \r\n) — thư viện HTTP hiện đại chọn cách sau, kiểm và từ chối \r\n trong mọi giá trị header.
Ra thực tế thì đặt header qua API thư viện, đừng nối chuỗi response thô: response.set_cookie(...), response.headers[...] = ... kiểm CRLF cho bạn, còn tự ghép "Set-Cookie: " + gia_tri là bỏ qua lớp bảo vệ đó. Cảnh giác với header lấy từ đầu vào người dùng — Location (redirect), Set-Cookie, các header tuỳ biến; nếu giá trị đến từ tham số hay dữ liệu người dùng, đảm bảo nó đi qua một API kiểm CRLF. Loại bỏ \r và \n khỏi mọi giá trị trước khi đưa vào header như một lớp phòng thủ thêm, kể cả khi bạn tin thư viện đã làm. Và kiểm phiên bản thư viện — "mặc định an toàn" là thành quả cập nhật, thư viện HTTP cũ có thể vẫn để lọt CRLF.
Mẫu số chung
Đây đúng là một con quái vật mặc thêm bộ da thứ tư: trộn dữ liệu không tin cậy vào một chuỗi rồi giao cho thứ sẽ phân tích cấu trúc chuỗi đó — chỉ khác lần này trình phân tích là chính giao thức HTTP.
Trong thế giới Java, đây là lý do HttpServletResponse.setHeader(name, value) của các servlet container hiện đại (Tomcat, Jetty) từ chối giá trị chứa \r/\n — cùng phản xạ với Werkzeug. Và Java từng có một CVE kinh điển đúng kiểu này ở HttpURLConnection/URL khi CRLF lọt vào; bài học cứng lại thành nguyên tắc: đặt header qua API, đừng bao giờ tự ghép response bằng PrintWriter với giá trị người dùng.
Trong Go, thư viện chuẩn net/http chặn ở tầng: Header.Set và cả tầng ghi response sẽ lỗi hoặc lọc nếu giá trị chứa ký tự điều khiển, đúng tinh thần an-toàn-mặc-định mà stdlib Go theo đuổi. Muốn dính CRLF injection trong Go bạn gần như phải cố ý tự viết response thô — cùng đúng một ngoại lệ với Python.
Còn ở cơ sở dữ liệu, cùng con quái vật là SQL injection, và cùng cách chặn đúng: tham số hoá (prepared statement) tách dữ liệu khỏi cấu trúc câu lệnh sao cho ' trong dữ liệu không bao giờ được đọc như ký tự điều khiển của SQL — đúng như API header kiểm CRLF tách giá trị khỏi cú pháp HTTP.
Mẫu số chung: mọi lỗ dạng injection sinh ra khi một chuỗi mang cả dữ liệu lẫn cấu trúc mà không có ranh giới cứng giữa hai thứ — và cách chặn ở mọi tầng là cùng một ý tưởng: đưa dữ liệu qua một kênh tách khỏi cấu trúc (API, tham số hoá) sao cho ký tự điều khiển của giao thức không bao giờ được parse từ phần dữ liệu. Vì thế câu hỏi đúng không phải "tôi đã chặn CRLF chưa" mà là "trong hệ này còn chỗ nào tôi nối chuỗi đầu vào người dùng vào một thứ sẽ được diễn giải theo cú pháp không" — SQL, HTML, shell, hay HTTP, mỗi chỗ như vậy là cùng một con quái vật, chỉ khác ký tự điều khiển.
Muốn tự soát trong ba mươi giây, tìm trong mã mọi chỗ đặt giá trị động vào một HTTP header — thường là Location cho redirect, Set-Cookie, hay header tuỳ biến mang thông tin từ request. Với mỗi chỗ, xem giá trị đó đi qua API của framework (redirect(...), set_cookie(...)) hay được ghép chuỗi thủ công; nếu là ghép chuỗi, thử đưa vào một giá trị chứa %0d%0a (CRLF sau khi giải mã URL) theo sau là một header giả. Nếu header giả xuất hiện trong phản hồi, bạn có một CRLF injection — và với header Set-Cookie, đó là đường đặt cookie tuỳ ý vào trình duyệt nạn nhân.