XSS وحقن الأوامر في المختبر
كيف يصبح تعليق بريء سكربتًا يسرق جلسات الزوار، وكيف نمنعه؟ ⏱ نحو 50 دقيقة.
- تشرح كيف يتحوّل مدخل المستخدم إلى سكربت في
XSS. - تميّز بين
XSSالمخزَّن والمنعكس وفيDOM. - تصلح
XSSبترميز المخرجات، وتدعمه بـCSP. - تتجنّب حقن أوامر النظام بتمرير الوسائط منفصلة.
١XSS: حين يصبح تعليق المستخدم كودًا
تطبيق الجذر نفسه على صفحة HTML بدل قاعدة البيانات يعطي XSS (البرمجة عبر المواقع): مدخل المستخدم يُدمج في الصفحة فيصبح HTML — وربما script — يُنفَّذ في متصفح زائر آخر.
هذا خادم تعليقات يخزّن ما يُرسل ثم يعرضه مدموجًا في HTML. تعليق عادي يُعرض نصًّا:
const express = require('express');const app = express();app.use(express.json());app.use(express.urlencoded({ extended: false })); let comments = []; // ✗ يعرض التعليقات بدمجها في HTML مباشرةapp.post('/comment', (req, res) => { comments.push(req.body.text || ''); res.json({ ok: true }); });app.get('/comments', (req, res) => { const html = '<ul>' + comments.map(c => '<li>' + c + '</li>').join('') + '</ul>'; res.type('html').send(html);}); app.listen(3000);
التعليق البريء ظهر كما هو داخل عنصر القائمة.
لكن إن احتوى التعليق وسمًا، يُدمج في الصفحة فيصير جزءًا منها. هنا نضع وسمًا ظاهرًا للتوضيح؛ ولو كان <script> لنُفِّذ في متصفح كل من يفتح صفحة التعليقات:
const express = require('express');const app = express();app.use(express.json());app.use(express.urlencoded({ extended: false })); let comments = []; // ✗ يعرض التعليقات بدمجها في HTML مباشرةapp.post('/comment', (req, res) => { comments.push(req.body.text || ''); res.json({ ok: true }); });app.get('/comments', (req, res) => { const html = '<ul>' + comments.map(c => '<li>' + c + '</li>').join('') + '</ul>'; res.type('html').send(html);}); app.listen(3000);
الوسم صار فعّالًا في الصفحة. هذا XSS مخزَّن: الحمولة تُحفظ في الخادم وتضرب كل زائر لاحق. ولو كان script لسرق جلسات الزوار أو غيّر الصفحة أمامهم.
٢أنواعه ولماذا هو خطير
| النوع | أين تُحفظ الحمولة | الضحية |
|---|---|---|
| مخزَّن Stored | في الخادم (تعليق، اسم ملف) | كل من يفتح الصفحة |
| منعكس Reflected | في الرابط، تنعكس في الرد | من يُفتَح له الرابط |
| في DOM | يبنيه جافاسكربت في المتصفح | المستخدم الحالي |
خطره أن الكود يعمل داخل جلسة الضحية وصلاحياتها: يقرأ رمز الجلسة فيسرق الحساب، أو ينفّذ أفعالًا باسم الضحية، أو يزرع صفحة دخول مزيفة. السرية والسلامة معًا في خطر.
٣الإصلاح: ترميز المخرجات
الدفاع الجذري هو الترميز عند الإخراج (الدرس 4): قبل دمج أي قيمة في HTML، حوّل محارفها الخاصة إلى صورتها الحرفية، فتُعرض نصًّا لا وسمًا:
const express = require('express');const app = express();app.use(express.json());app.use(express.urlencoded({ extended: false })); let comments = [];function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));}app.post('/comment', (req, res) => { comments.push(req.body.text || ''); res.json({ ok: true }); });app.get('/comments', (req, res) => { const html = '<ul>' + comments.map(c => '<li>' + esc(c) + '</li>').join('') + '</ul>'; res.type('html').send(html);}); app.listen(3000);
الآن يظهر النص «اقرأ هذا» حرفيًّا؛ رُمِّزت < و > فلم يعُد وسمًا. وفي أطر الواجهة مثل React يتم هذا الترميز تلقائيًّا، ما لم تستخدم dangerouslySetInnerHTML.
٤حقن أوامر النظام
النمط نفسه مع أوامر نظام التشغيل: إن بنيت أمر صدفة بدمج مدخل المستخدم، قد يضيف المهاجم أمرًا آخر بفاصل مثل ; أو &&. القاعدة:
| الخطر | الأأمن |
|---|---|
exec('ping ' + req.query.host) | تجنّب الصدفة أصلًا |
| بناء أمر نصيّ | execFile('ping', ['-c', '1', host]) (وسائط منفصلة) |
| الثقة بالمدخل | قائمة مسموح للقيم المتوقعة |
القاعدة العامة: لا تبنِ أمرًا أو استعلامًا أو صفحة بدمج نصيّ. مرّر القيم منفصلة (وسائط، معاملات)، أو رمّزها للسياق.
توقّع وتحقّق
XSS المخزَّن يضرب:
الدفاع الجذري عن XSS:
لتجنّب حقن الأوامر نفضّل:
رأس CSP دوره:
الخلاصة
XSS: مدخل المستخدم يُدمج في الصفحة فيصير كودًا يعمل في متصفح الضحية.- المخزَّن يضرب كل زائر؛ والمنعكس يضرب من يُفتح له الرابط.
- الدفاع: ترميز المخرجات لكل سياق، وأطر الواجهة ترمّز تلقائيًّا، و
CSPطبقة ثانية. - حقن الأوامر النمط نفسه؛ تجنّب الصدفة ومرّر الوسائط منفصلة.
تحدٍّ برمجي: سدّ ثغرة XSS
متقدمخادم الملاحظات يخزّن ما يرسله المستخدم ويعرضه مدموجًا في HTML، فهو عرضة لـ XSS المخزَّن. أصلحه بترميز المخرجات دون تغيير سلوكه الطبيعي.
- رمّز كل ملاحظة قبل دمجها في HTML.
- يظل حفظ الملاحظة وعرضها يعملان.
- ملاحظة فيها
<b>يجب أن تظهر نصًّا حرفيًّا لا وسمًا فعّالًا.
👀 اعرض الاستجابات المطلوبة
POST /notes{"text": "ملاحظة عادية"}POST /notes{"text": "<b>خطير</b>"}GET /notes
- الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
- حفظ الملاحظة ← 201
- العدّاد يزيد
- عرض الملاحظات ← 200
- الملاحظة العادية تظهر
- الوسم الخطر مُرمَّز (يظهر <b>)
- لا يظهر وسم b فعّال
💡 تلميح 1
esc من مثال الدرس التي ترمّز < > & " '.💡 تلميح 2
notes.map(n => '' + esc(n) + ' ').💡 تلميح 3
حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.
const express = require('express');const app = express();app.use(express.json());app.use(express.urlencoded({ extended: false })); let notes = [];function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));}app.post('/notes', (req, res) => { notes.push(req.body.text || ''); res.status(201).json({ count: notes.length }); });app.get('/notes', (req, res) => { const html = '<h2>الملاحظات</h2><ul>' + notes.map(n => '<li>' + esc(n) + '</li>').join('') + '</ul>'; res.type('html').send(html);});app.listen(3000);