
استشارة أمنية: حجب الخدمة عن بُعد عبر تأكيد قابل للوصول في معالجة بادئة عنوان URL (rouille)
معرّف CVE المعيَّن: CVE-2026-66754
تختبر Request::remove_prefix عنوان URL المُفكَّك ترميز النسبة المئوية لكنها تؤكِّد على العنوان الخام.
الطلب الذي يبدأ مساره المُفكَّك بالبادئة بينما لا يبدأ مساره الخام بها يجتاز الاختبار ويفشل في التأكيد. ترميز حرف واحد من البادئة بترميز النسبة المئوية كافٍ.
في البناء الافتراضي يتم التقاط الذعر (panic) ويتحول إلى خطأ 500. أما في بناء مع panic = "abort" فلا يوجد ما يلتقطه، وطلب GET واحد غير مصادَق ينهي عملية الخادم.
رابط المستودع: https://github.com/tomaka/rouille
| أول إصدار متأثر | 0.1.6 (2016-09-22)، الإصدار الذي قدَّم remove_prefix |
| آخر إصدار متأثر | 3.6.2 (2023-04-24)، الإصدار الحالي |
| غير متأثر | 0.1.5 والإصدارات الأقدم، والتي لا تحتوي على remove_prefix |
| ثُبِّت في | لا يوجد إصدار مُثبَّت وقت كتابة هذا التقرير |
الدالة لم تتغير عبر جميع الإصدارات في هذا النطاق وفي master الحالي. التطبيقات متأثرة إذا كانت تستدعي Request::remove_prefix، وهو النمط الذي توثّقه rouille لخدمة الملفات الثابتة تحت بادئة URL (الأسطر 66 إلى 75 في src/assets.rs، والأسطر 804 إلى 811 في src/lib.rs).
CWE-617 (تأكيد قابل للوصول)، المؤدي إلى CWE-248 (استثناء غير ملتَقَط).
درجة أساسية 8.2 وفق CVSS 4.0 (عالية)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
عميل بعيد غير مصادَق يمكنه إرسال طلب GET واحد. لا يحتاج إلى بيانات اعتماد ولا إلى أي تفاعل من المستخدم.
panic = "abort" ليس الخيار الافتراضي في Cargo، لكنه اختيار شائع لملفات التعريف الخاصة بالإصدارات (release profiles) للحصول على ملفات ثنائية أصغر ونفقات أقل. تعتمد rouille على unwinding لضمان التوافر، إذ يعتمد Server::process على panic::catch_unwind في السطر 602 من src/lib.rs لتحويل ذعر المعالِج إلى خطأ 500. هذا الاعتماد غير موثَّق.
الأسطر 813 إلى 822 من rouille/src/lib.rs:
813 pub fn remove_prefix(&self, prefix: &str) -> Option<Request> {
814 if !self.url().starts_with(prefix) {
815 return None;
816 }
817
818 // TODO: url-encoded characters in the prefix are not implemented
819 assert!(self.url.starts_with(prefix));
820 Some(Request {
821 method: self.method.clone(),
822 url: self.url[prefix.len()..].to_owned(),
السطر 814 يستدعي self.url()، التي تفكك ترميز النسبة المئوية. السطر 819 يؤكِّد على self.url، الحقل الخام، والسطر 822 يقتطع من هذا الحقل الخام نفسه. التمثيلان يختلفان كلما احتوى المسار على هروب نسبة مئوية داخل البادئة:
نفس عدم التطابق يعني أيضًا أن remove_prefix لا يوجّه أبدًا مسارًا مشفَّرًا بترميز النسبة المئوية بشكل شرعي، لذا فإن خطأ الصحّة وخطأ التوافر يشتركان في الإصلاح نفسه.
الخطوة 1. شغِّل خادمًا باستخدام نمط البادئة الموثَّق.
use rouille::Response;
fn main() {
rouille::start_server("127.0.0.1:8003", |request| {
if let Some(r) = request.remove_prefix("/static") {
return rouille::match_assets(&r, ".");
}
Response::text("home")
});
}
الخطوة 2. أرسل طلبًا سليمًا وطلبًا مكافئًا مع ترميز الحرف s بترميز النسبة المئوية.
--path-as-is يمنع curl من تطبيع المسار.
curl -sS -o /dev/null -w '%{http_code}\n' --path-as-is 'http://127.0.0.1:8003/static/x'
curl -sS -o /dev/null -w '%{http_code}\n' --path-as-is 'http://127.0.0.1:8003/%73tatic/x'
النتيجة على بناء افتراضي. الأولى هي 404 عادية لملف مفقود، والثانية هي ذعر تحول إلى 500:
404
500
مع ما يلي على stderr:
thread '<unnamed>' panicked at src/lib.rs:819:9:
assertion failed: self.url.starts_with(prefix)
الخطوة 3. أعد بناء البرنامج نفسه مع الإجهاض عند الذعر وكرر الطلب الثاني.
[profile.release]
panic = "abort"
cargo build --release
./target/release/<binary> &
curl -sS --path-as-is 'http://127.0.0.1:8003/%73tatic/x'
النتيجة. تنتهي العملية بـ SIGABRT (رمز الخروج 134) ويتوقف الخادم عن الاستجابة. طلب واحد، دون مصادقة، وخسارة كاملة للتوافر.
على البنيات ذات panic = "abort"، يوقف طلب واحد غير مصادَق الخادم. على البنيات الافتراضية، تعود الطلبات التي يحتوي مسارها على هروب نسبة مئوية داخل البادئة بـ 500 بدلًا من أن تُخدَم، لذا يكون التوجيه غير صحيح أيضًا للعملاء الشرعيين.
لاحظ أن الذعر لا يمكن أن يسمم كائن المزامنة (mutex) الذي يلغي لفه Server::process خارج catch_unwind في السطرين 643 و649 من src/lib.rs، لأن remove_prefix تستنسخ Arc فقط ولا تحتفظ أبدًا بحارس (guard). على بنيات unwinding يتعافى خيط العامل بشكل نظيف.
نفِّذ المقارنة والاقتطاع على التمثيل نفسه. أبسط نسخة صحيحة تقارن وتقتطع من URL الخام:
pub fn remove_prefix(&self, prefix: &str) -> Option<Request> {
if !self.url.starts_with(prefix) {
return None;
}
Some(Request {
url: self.url[prefix.len()..].to_owned(),
..
})
}
إذا كان سلوك المقارنة بعد فك الترميز هو المقصود، فكِّك ترميز URL مرة واحدة، واقتطع البادئة من السلسلة المفككة، وأعد ترميز الباقي بدلًا من اقتطاع الحقل الخام.
في كلتا الحالتين يجب إزالة assert!. لا ينبغي لمكتبة أن تُنهي العملية بناءً على مدخلات يتحكم فيها المهاجم. توثيق أن rouille تتطلب panic = "unwind"، أو إزالة الاعتماد على catch_unwind من أجل الصحّة، سيساعد أيضًا.
| URL الخام | url() المُفكَّك | السطر 814 | السطر 819 |
|---|
/static/x | /static/x | ينجح | ينجح |
/%73tatic/x | /static/x | ينجح | يفشل |
/stati%63/x | /static/x | ينجح | يفشل |