حقن SQL في المختبر
كيف يتحوّل حقل بحث بريء إلى مفتاح يفتح كل الحسابات، وكيف نغلقه نهائيًّا؟ ⏱ نحو 50 دقيقة.
- تشرح كيف يتحوّل المدخل إلى أمر في حقن
SQL. - تتعرّف على نمط تجاوز تسجيل الدخول.
- تصلح الثغرة بالاستعلامات المعلَّمة.
- تضيف أقل الصلاحيات طبقة دفاع ثانية.
١كيف ينقلب المدخل أمرًا
تطبيق الجذر من الدرس 4 على قاعدة البيانات. هذا الخادم يبني استعلام تسجيل الدخول بدمج الاسم وكلمة المرور نصًّا. الدخول الصحيح يعمل:
const express = require('express');const app = express();app.use(express.json());app.use(express.urlencoded({ extended: false })); const sqlite3 = require('sqlite3');const db = new sqlite3.Database(':memory:');db.serialize(() => { db.run(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, pass TEXT, role TEXT, email TEXT)`); db.run(`INSERT INTO users (name, pass, role, email) VALUES ('سارة', 's@rah123', 'customer', 'sara@mail.sa'), ('خالد', 'kh@led456', 'customer', 'khaled@mail.sa'), ('المدير', 'Adm!n#2025', 'admin', 'admin@mail.sa')`); db.run(`CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER)`); db.run(`INSERT INTO products (name, price) VALUES ('سماعة', 120), ('لوحة مفاتيح', 90), ('كاميرا ويب', 210)`);}); // ✗ يبني الاستعلام بدمج المدخل نصًّاapp.post('/login', (req, res) => { const sql = "SELECT name, role FROM users WHERE name = '" + req.body.name + "' AND pass = '" + req.body.pass + "'"; db.get(sql, (err, row) => { if (err) return res.status(500).json({ error: 'خطأ في الاستعلام' }); if (!row) return res.status(401).json({ error: 'بيانات الدخول خاطئة' }); res.json({ welcome: row.name, role: row.role }); });}); app.listen(3000);
الاستعلام المبني: … WHERE name = 'سارة' AND pass = 's@rah123'. كل شيء طبيعي ما دام المدخل بريئًا.
لكن المدخل بيد المرسِل. ماذا لو كتب في خانة الاسم نصًّا يغلق علامة التنصيص ويضيف شرطًا دائم الصحة؟ المدخل يصبح جزءًا من منطق الاستعلام:
const express = require('express');const app = express();app.use(express.json());app.use(express.urlencoded({ extended: false })); const sqlite3 = require('sqlite3');const db = new sqlite3.Database(':memory:');db.serialize(() => { db.run(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, pass TEXT, role TEXT, email TEXT)`); db.run(`INSERT INTO users (name, pass, role, email) VALUES ('سارة', 's@rah123', 'customer', 'sara@mail.sa'), ('خالد', 'kh@led456', 'customer', 'khaled@mail.sa'), ('المدير', 'Adm!n#2025', 'admin', 'admin@mail.sa')`); db.run(`CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER)`); db.run(`INSERT INTO products (name, price) VALUES ('سماعة', 120), ('لوحة مفاتيح', 90), ('كاميرا ويب', 210)`);}); // ✗ يبني الاستعلام بدمج المدخل نصًّاapp.post('/login', (req, res) => { const sql = "SELECT name, role FROM users WHERE name = '" + req.body.name + "' AND pass = '" + req.body.pass + "'"; db.get(sql, (err, row) => { if (err) return res.status(500).json({ error: 'خطأ في الاستعلام' }); if (!row) return res.status(401).json({ error: 'بيانات الدخول خاطئة' }); res.json({ welcome: row.name, role: row.role }); });}); app.listen(3000);
الاستعلام صار: … WHERE name = 'x' OR '1'='1' AND pass = '…'. الشرط OR '1'='1' يجعل الناتج صحيحًا، فيرجع أول مستخدم (سارة) ويدخل المهاجم بلا كلمة مرور. هذا جوهر حقن SQL.
٢لماذا نجح الهجوم؟
السبب الوحيد: خُلطت البيانات بالأمر. القاعدة لا تفرّق بين ما كتبه مطوّرك وما حقنه المهاجم؛ تلقّت نصًّا واحدًا فنفّذته كله أمرًا. لذلك لا يكفي «منع» علامة التنصيص أو كلمة OR؛ قوائم المنع تُتجاوَز دائمًا بترميز آخر أو صياغة أخرى.
٣الإصلاح الجذري: الفصل بالاستعلام المعلَّم
الحل أن نفصل القيمة عن نص الأمر بنيويًّا: نكتب ? مكان القيمة، ونمرّر القيم في مصفوفة منفصلة. حينها تعامل القاعدة القيمة بيانات دائمًا، مهما احتوت:
const express = require('express');const app = express();app.use(express.json());app.use(express.urlencoded({ extended: false })); const sqlite3 = require('sqlite3');const db = new sqlite3.Database(':memory:');db.serialize(() => { db.run(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, pass TEXT, role TEXT, email TEXT)`); db.run(`INSERT INTO users (name, pass, role, email) VALUES ('سارة', 's@rah123', 'customer', 'sara@mail.sa'), ('خالد', 'kh@led456', 'customer', 'khaled@mail.sa'), ('المدير', 'Adm!n#2025', 'admin', 'admin@mail.sa')`); db.run(`CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER)`); db.run(`INSERT INTO products (name, price) VALUES ('سماعة', 120), ('لوحة مفاتيح', 90), ('كاميرا ويب', 210)`);}); // ✓ يفصل القيمة عن نص الأمر بعلامات ? (استعلام معلَّم)app.post('/login', (req, res) => { const sql = "SELECT name, role FROM users WHERE name = ? AND pass = ?"; db.get(sql, [req.body.name, req.body.pass], (err, row) => { if (err) return res.status(500).json({ error: 'خطأ في الاستعلام' }); if (!row) return res.status(401).json({ error: 'بيانات الدخول خاطئة' }); res.json({ welcome: row.name, role: row.role }); });}); app.listen(3000);
الدخول الصحيح يعمل، والهجوم نفسه الآن يفشل (401): النص "x' OR '1'='1" بُحث عنه اسمًا حرفيًّا فلم يوجد. القيمة لم تُفسَّر أمرًا قط.
توقّع وتحقّق
جوهر حقن SQL:
المدخل x' OR '1'='1 ينجح لأنه:
الإصلاح الجذري:
أقل الصلاحيات هنا دوره:
الخلاصة
- حقن
SQLجذره خلط مدخل المستخدم بنص الأمر فينقلب منطقًا. - قوائم المنع (حذف التنصيص، منع
OR) تُتجاوَز؛ ليست حلًّا. - الاستعلام المعلَّم (?) يفصل القيمة عن الأمر فتبقى بيانات دائمًا.
- أقل الصلاحيات طبقة ثانية تحدّ الضرر إن نجح حقن عبر ثغرة أخرى.
تحدٍّ برمجي: سدّ حقن SQL
متقدمهذا الخادم فيه مساران مبنيان بدمج النص: بحث المنتجات، وتسجيل الدخول. كلاهما قابل للحقن. أصلحهما بالاستعلامات المعلَّمة دون تغيير سلوكهما الطبيعي.
- بحث المنتجات: استعلام معلَّم، ويظل البحث بـ LIKE يعمل.
- تسجيل الدخول: استعلام معلَّم بقيمتين.
- يجب أن يفشل الحقن
x' OR '1'='1في تسجيل الدخول(401).
👀 اعرض الاستجابات المطلوبة
GET /products?q=سماعةPOST /login{"name": "سارة", "pass": "s@rah123"}POST /login{"name": "x' OR '1'='1", "pass": "x"}GET /products?q=' OR '1'='1
- الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
- بحث سماعة يرجع منتجًا واحدًا
- الدخول الصحيح ينجح لسارة
- حقن الدخول يفشل ← 401
- حقن البحث لا يسرّب كل المنتجات
- لا دمج نصّي في استعلام تسجيل الدخول
- استخدام علامات ? المعلَّمة
💡 تلميح 1
"… WHERE name = ? AND pass = ?" مع [name, pass].💡 تلميح 2
"… WHERE name LIKE ?" مع ['%' + q + '%']؛ علامات % في القيمة لا في النص.💡 تلميح 3
حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.
const express = require('express');const app = express();app.use(express.json());app.use(express.urlencoded({ extended: false })); const sqlite3 = require('sqlite3');const db = new sqlite3.Database(':memory:');db.serialize(() => { db.run(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, pass TEXT, role TEXT, email TEXT)`); db.run(`INSERT INTO users (name, pass, role, email) VALUES ('سارة', 's@rah123', 'customer', 'sara@mail.sa'), ('خالد', 'kh@led456', 'customer', 'khaled@mail.sa'), ('المدير', 'Adm!n#2025', 'admin', 'admin@mail.sa')`); db.run(`CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER)`); db.run(`INSERT INTO products (name, price) VALUES ('سماعة', 120), ('لوحة مفاتيح', 90), ('كاميرا ويب', 210)`);}); app.get('/products', (req, res) => { const sql = "SELECT id, name, price FROM products WHERE name LIKE ?"; db.all(sql, ['%' + (req.query.q || '') + '%'], (err, rows) => { if (err) return res.status(500).json({ error: 'خطأ في الاستعلام' }); res.json(rows); });}); app.post('/login', (req, res) => { const sql = "SELECT name, role FROM users WHERE name = ? AND pass = ?"; db.get(sql, [req.body.name, req.body.pass], (err, row) => { if (err) return res.status(500).json({ error: 'خطأ في الاستعلام' }); if (!row) return res.status(401).json({ error: 'بيانات الدخول خاطئة' }); res.json({ welcome: row.name, role: row.role }); });}); app.listen(3000);