جستجو
برای جستجو متن مورد نظر وارد کنید و Enter بزنید برای بستن Esc بزنید.
یک ورودی ساده در سایت میتواند به نقطه شروع یک حمله جدی به پایگاه داده تبدیل شود. SQL Injection یکی از آسیبپذیریهای شناختهشده وب است که با یک پیادهسازی نادرست میتواند اطلاعات حساس را در معرض خطر قرار دهد. اما این حمله چگونه شکل میگیرد و چطور میتوان جلوی آن را گرفت؟
SQL Injection یکی از مهمترین آسیبپذیریهای امنیتی در برنامههای تحت وب است. این آسیبپذیری زمانی شکل میگیرد که دادهای که از کاربر، URL، فرم، API یا منبع خارجی دریافت شده، بدون جداسازی صحیح وارد دستور SQL شود.
مشکل اصلی زمانی ایجاد میشود که برنامه نتواند بین داده ورودی و بخش اجرایی Query مرز مشخصی ایجاد کند. در این وضعیت، ورودی کنترلنشده میتواند روی منطق Query اثر بگذارد.
OWASP برای مقابله با SQL Injection استفاده از Prepared Statements با Parameterized Queries را به عنوان راهکار اصلی پیشنهاد میکند. در این روش، ساختار SQL از مقادیر ورودی جدا میشود و داده کاربر به عنوان بخشی از دستور SQL تفسیر نمیشود.
فرض کنید برنامهای برای دریافت اطلاعات یک کاربر، Query زیر را تولید میکند:
SELECT id, username
FROM users
WHERE username = 'USER_INPUT';
اگر برنامه مقدار USER_INPUT را با اتصال مستقیم رشتهها تولید کند، داده ورودی وارد ساختار SQL شده است.
یک پیادهسازی ناامن در PHP میتواند چنین شکلی داشته باشد:
$username = $_GET['username'];
$sql = "SELECT id, username
FROM users
WHERE username = '$username'";
$result = mysqli_query($connection, $sql);
اینجا مشکل اصلی این است که مقدار دریافتشده از کاربر مستقیماً داخل Query قرار گرفته است.
OWASP نیز نمونه مشابهی را به عنوان الگوی رایج SQL Injection معرفی میکند که در آن پارامتر کنترلنشده به Query متصل میشود.
SQL یک زبان اجرایی است. اگر داده و کد بدون مرزبندی مناسب کنار یکدیگر قرار بگیرند، پایگاه داده ممکن است بخشی از ورودی را به عنوان syntax مربوط به SQL تفسیر کند.
در معماری امن، برنامه باید Query را از قبل مشخص کند و مقدار کاربر را به عنوان پارامتر به آن تحویل دهد.
Prepared Statement روشی است که در آن ساختار SQL از مقادیری که قرار است در Query قرار بگیرند جدا میشود.
برای مثال در PDO:
$stmt = $pdo->prepare(
'SELECT id, username
FROM users
WHERE username = :username'
);
$stmt->execute([
'username' => $username
]);
در اینجا :username یک Parameter است و مقدار واقعی هنگام اجرای Statement ارسال میشود.
مستندات PHP نیز استفاده از پارامترها برای ورودیهای کاربر را توصیه میکنند و تأکید دارند که داده کاربر نباید مستقیماً داخل Query قرار گیرد.
یکی از روشهای استاندارد در PHP استفاده از PDO است.
نمونه کاملتر:
<?php
$pdo = new PDO(
'mysql:host=localhost;dbname=app;charset=utf8mb4',
'app_user',
'password'
);
$pdo->setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION
);
$username = $_POST['username'] ?? '';
$stmt = $pdo->prepare(
'SELECT id, username
FROM users
WHERE username = :username'
);
$stmt->execute([
'username' => $username
]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
در این ساختار، مقدار $username مستقیماً به Query متصل نشده است.
PDO::prepare() الگوی Query را دریافت میکند و execute() مقادیر واقعی را برای پارامترها ارسال میکند. مستندات PHP همچنین تأکید میکنند که Placeholderها برای مقادیر دادهای هستند و نمیتوان از آنها برای جایگزینی نام جدول، نام ستون یا بخش دلخواهی از ساختار SQL استفاده کرد.
این روش صحیح است:
$stmt = $pdo->prepare(
'SELECT * FROM users WHERE id = :id'
);
$stmt->execute([
'id' => $id
]);
اما نمیتوان انتظار داشت چنین چیزی برای نام جدول کار کند:
$table = 'users';
$stmt = $pdo->prepare(
'SELECT * FROM :table'
);
Parameter برای یک مقدار دادهای است، نه Identifier.
مستندات PDO نیز این محدودیت را صراحتاً مشخص کردهاند.
در PHP میتوان از MySQLi نیز برای Prepared Statement استفاده کرد.
نمونه استاندارد:
<?php
$mysqli = new mysqli(
'localhost',
'app_user',
'password',
'app'
);
$mysqli->set_charset('utf8mb4');
$username = $_POST['username'] ?? '';
$stmt = $mysqli->prepare(
'SELECT id, username
FROM users
WHERE username = ?'
);
$stmt->bind_param('s', $username);
$stmt->execute();
$result = $stmt->get_result();
$user = $result->fetch_assoc();
در MySQLi، علامت ? به عنوان Placeholder استفاده میشود. این پارامتر باید قبل از اجرای Query با bind_param() به یک متغیر متصل شود. مستندات رسمی PHP همین الگوی prepare، bind_param و execute را برای Prepared Statement تعریف میکنند.
پارامتر اول bind_param() نوع داده را مشخص میکند.
i = integer
d = double
s = string
b = binary
برای نمونه:
$stmt->bind_param('i', $user_id);
برای یک شناسه عددی استفاده میشود.
Validation یکی دیگر از لایههای امنیتی مهم است، اما نباید آن را جایگزین Prepared Statement دانست.
فرض کنید پارامتر id باید حتماً یک عدد صحیح باشد:
$id = filter_input(
INPUT_GET,
'id',
FILTER_VALIDATE_INT
);
if ($id === false || $id === null) {
http_response_code(400);
exit('Invalid ID');
}
حالا Query نیز باید به صورت پارامتری اجرا شود:
$stmt = $pdo->prepare(
'SELECT id, name, price
FROM products
WHERE id = :id'
);
$stmt->execute([
'id' => $id
]);
در اینجا دو کنترل داریم.
Validation بررسی میکند که ورودی از نظر نوع و قواعد مورد انتظار معتبر باشد.
Parameterized Query مانع آن میشود که مقدار ورودی به عنوان syntax مربوط به SQL تفسیر شود.
OWASP نیز تأکید میکند که Validation به تنهایی تضمین نمیکند داده برای قرار گرفتن در یک Query ساختهشده با String Concatenation امن باشد.
همه بخشهای SQL را نمیتوان با Placeholder پارامتری کرد.
برای مثال تصور کنید کاربر باید یکی از گزینههای مرتبسازی را انتخاب کند:
name
price
date
نمیتوان صرفاً انتظار داشت نام ستون را با یک Parameter معمولی جایگزین کنیم.
در این شرایط، Allowlist راهکار مناسبی است.
$allowedSorts = [
'name' => 'name',
'price' => 'price',
'date' => 'created_at'
];
$sort = $_GET['sort'] ?? 'date';
$orderBy = $allowedSorts[$sort] ?? 'created_at';
$stmt = $pdo->prepare(
"SELECT id, name, price
FROM products
ORDER BY $orderBy"
);
$stmt->execute();
کاربر فقط میتواند یکی از کلیدهای تعریفشده را انتخاب کند.
مقدار ورودی مستقیماً به SQL منتقل نمیشود.
OWASP استفاده از Allowlist را برای بخشهایی مانند نام جدول، نام ستون و ترتیب ASC یا DESC که معمولاً محل مناسبی برای Bind Variable نیستند، توصیه میکند.
در WordPress توسعهدهندگان معمولاً برای ارتباط با دیتابیس از کلاس $wpdb استفاده میکنند.
اگر مقدار کاربر وارد Query میشود، استفاده از $wpdb->prepare() اهمیت زیادی دارد.
نمونه:
<?php
global $wpdb;
$user_id = 123;
$query = $wpdb->prepare(
"SELECT ID, user_login
FROM {$wpdb->users}
WHERE ID = %d",
$user_id
);
$user = $wpdb->get_row($query);
در این مثال %d برای مقدار عددی استفاده شده است.
WordPress برای wpdb::prepare() Placeholderهای زیر را مستند کرده است:
%d = integer
%f = float
%s = string
%i = identifier
Placeholderها باید بدون کوتیشن داخل Query قرار بگیرند. همچنین %i از WordPress 6.2 برای Identifierهایی مانند نام جدول و ستون اضافه شده است.
%i در WordPressدر WordPress 6.2 و نسخههای جدیدتر میتوان برای Identifierها از %i استفاده کرد.
برای مثال:
global $wpdb;
$table = $wpdb->prefix . 'products';
$field = 'product_id';
$value = 123;
$query = $wpdb->prepare(
'SELECT * FROM %i WHERE %i = %d',
$table,
$field,
$value
);
$results = $wpdb->get_results($query);
در اینجا $table و $field به عنوان Identifier و $value به عنوان مقدار دادهای پردازش میشوند.
مستندات رسمی WordPress اضافه شدن %i را از نسخه 6.2 ثبت کردهاند. برای پروژههایی که با نسخههای قدیمی WordPress سازگار هستند، باید این تفاوت نسخهای در نظر گرفته شود.
wpdb->prepare()Placeholder را نباید داخل کوتیشن قرار داد.
صحیح:
$query = $wpdb->prepare(
'SELECT * FROM users WHERE username = %s',
$username
);
نادرست:
$query = $wpdb->prepare(
"SELECT * FROM users WHERE username = '%s'",
$username
);
مستندات WordPress تصریح میکنند که Placeholderهای wpdb::prepare() باید بدون کوتیشن در Query قرار بگیرند.
mysqli_real_escape_string() برای جلوگیری از SQL Injection کافی است؟خیر.
این یکی از مهمترین نکاتی است که در آموزش SQL Injection باید به شکل دقیق توضیح داده شود.
تابع:
mysqli_real_escape_string()
برای Escape کردن رشتهها در شرایط مشخص کاربرد دارد، اما نباید راهکار اصلی طراحی Queryهای دارای ورودی کاربر محسوب شود.
OWASP Escape کردن تمام ورودیهای کاربر را به عنوان راهکار اصلی توصیه نمیکند و آن را نسبت به Prepared Statement ضعیفتر و مستعد خطا میداند.
اولویت باید این باشد:
Prepared Statement
↓
Parameter Binding
↓
Input Validation
↓
Allowlist در موارد لازم
↓
Least Privilege
فرض کنید یک برنامه وب با یک حساب کاربری دیتابیس به MySQL متصل است.
اگر این حساب دسترسی کامل مدیریتی داشته باشد، یک آسیبپذیری در برنامه میتواند پیامد بسیار بزرگتری ایجاد کند.
به همین دلیل باید حساب دیتابیس برنامه فقط دسترسیهایی را داشته باشد که واقعاً برای اجرای برنامه نیاز دارد.
برای نمونه، برنامهای که فقط به خواندن داده نیاز دارد، نباید الزاماً مجوزهای مدیریتی یا دسترسی گسترده به تمام جداول داشته باشد.
OWASP توصیه میکند حسابهای مورد استفاده برنامهها حداقل سطح دسترسی لازم را داشته باشند و از اختصاص دسترسی DBA یا Administrator به حساب برنامه اجتناب شود.
پیامهای خام دیتابیس نباید به کاربر نمایش داده شوند.
یک خطای SQL ممکن است اطلاعاتی درباره ساختار دیتابیس، نام جدول، ستون یا نوع DBMS افشا کند.
در محیط Production بهتر است جزئیات خطا در سیستم Logging ثبت شود و کاربر یک پیام عمومی دریافت کند.
برای مثال:
try {
$stmt = $pdo->prepare(
'SELECT id, username
FROM users
WHERE username = :username'
);
$stmt->execute([
'username' => $username
]);
} catch (PDOException $e) {
error_log($e->getMessage());
http_response_code(500);
exit('Database error');
}
در این ساختار، جزئیات فنی برای تیم توسعه ثبت میشود و مستقیماً به کاربر نمایش داده نمیشود.
SQL Injection محدود به فرمهای HTML نیست.
APIهایی که داده را از Query String، JSON، Header یا سایر منابع دریافت میکنند نیز باید همین اصول را رعایت کنند.
برای مثال:
GET /api/products?id=123
در سمت سرور نباید مقدار id مستقیماً داخل Query قرار بگیرد.
ساختار امن:
$id = filter_input(
INPUT_GET,
'id',
FILTER_VALIDATE_INT
);
if ($id === false || $id === null) {
http_response_code(400);
exit('Invalid ID');
}
$stmt = $pdo->prepare(
'SELECT id, name, price
FROM products
WHERE id = :id'
);
$stmt->execute([
'id' => $id
]);
این رویکرد برای API، پنل مدیریت و صفحات معمول سایت یک اصل مشترک دارد. ورودی خارجی نباید مستقیماً وارد ساختار Query شود.
ORMهایی مانند Eloquent، Hibernate، Entity Framework و Django ORM معمولاً امکان ساخت Queryهای پارامتری را فراهم میکنند.
اما ORM به خودی خود تضمین نمیکند که برنامه هیچ SQL Injection نداشته باشد.
مشکل زمانی میتواند ایجاد شود که توسعهدهنده از Raw SQL استفاده کند و ورودی کاربر را با اتصال رشتهای وارد آن کند.
بنابراین در پروژههای مبتنی بر ORM نیز باید Raw Queryها بررسی شوند.
قاعده ساده است:
هر جا SQL به صورت Dynamic ساخته میشود، مسیر ورود داده به Query باید بررسی شود.
SQL Injection و XSS هر دو میتوانند از پردازش نادرست ورودی ناشی شوند، اما هدف و محل اجرای آنها متفاوت است.
در SQL Injection، هدف اصلی منطق Query و پایگاه داده است.
در XSS، مهاجم تلاش میکند محتوای مخرب را به Contextی برساند که مرورگر آن را به عنوان HTML یا JavaScript پردازش کند.
بنابراین راهکارهای دفاعی این دو نیز یکسان نیستند.
Prepared Statement برای SQL Injection اهمیت دارد، در حالی که Output Encoding و کنترل Context برای جلوگیری از بسیاری از سناریوهای XSS اهمیت دارند.
WAF یا Web Application Firewall میتواند برخی الگوهای شناختهشده حملات را شناسایی و مسدود کند.
اما WAF نباید جایگزین اصلاح کد شود.
معماری دفاعی مناسب بهتر است چند لایه داشته باشد:
Client
↓
WAF
↓
Web Server
↓
Application
↓
Input Validation
↓
Parameterized Query
↓
Database
اگر مهاجم بتواند یک Rule مربوط به WAF را دور بزند، کنترلهای امنیتی داخل برنامه همچنان باید از دیتابیس محافظت کنند.
در Code Review باید مسیر داده از ورودی کاربر تا دیتابیس دنبال شود.
مثلاً این کد نیازمند بررسی امنیتی است:
$id = $_GET['id'];
$sql = "SELECT * FROM products WHERE id = " . $id;
$result = mysqli_query($connection, $sql);
مسیر داده چنین است:
$_GET['id']
↓
$id
↓
String Concatenation
↓
SQL Query
↓
Database
مشکل در همین نقطه ایجاد شده است.
نسخه بهتر:
$id = filter_input(
INPUT_GET,
'id',
FILTER_VALIDATE_INT
);
if ($id === false || $id === null) {
http_response_code(400);
exit('Invalid ID');
}
$stmt = $mysqli->prepare(
'SELECT id, name, price
FROM products
WHERE id = ?'
);
$stmt->bind_param('i', $id);
$stmt->execute();
در این نسخه، Validation انجام شده و Query نیز با Prepared Statement اجرا میشود.
امنیت SQL Injection نباید فقط در مرحله نهایی تست شود.
در پروژههای حرفهای بهتر است کنترلهای امنیتی در چرخه توسعه قرار بگیرند.
Secure Coding
↓
Code Review
↓
Static Analysis
↓
Automated Tests
↓
Dependency Scanning
↓
DAST
↓
Production Monitoring
هدف این است که آسیبپذیری هرچه زودتر در چرخه توسعه شناسایی شود.
برای تیمهای توسعه، بررسی Raw SQL، تست مسیرهای ورودی و کنترل Queryهای Dynamic اهمیت ویژهای دارد.
برای بررسی امنیت یک پروژه میتوان این موارد را کنترل کرد:
SQL Injection آسیبپذیریای است که در اثر ساخت ناامن Query و ترکیب داده کنترلنشده با دستور SQL ایجاد میشود. مهاجم در شرایط آسیبپذیر میتواند روی منطق Query اثر بگذارد.
استفاده از Prepared Statements یا Parameterized Queries مهمترین راهکار است. Validation، Allowlist و Least Privilege نیز به عنوان لایههای دفاعی مکمل استفاده میشوند.
خیر. Validation باید در کنار Parameterized Query استفاده شود. OWASP نیز هشدار میدهد که داده اعتبارسنجیشده لزوماً برای قرار گرفتن مستقیم در یک Query ساختهشده با String Concatenation امن نیست.
Escape کردن در برخی شرایط کاربرد دارد، اما نباید روش اصلی مقابله با SQL Injection باشد. OWASP آن را نسبت به Prepared Statement راهکاری ضعیفتر و آخرین گزینه دفاعی میداند.
PDO به خودی خود تضمینکننده امنیت نیست. استفاده صحیح از prepare() و execute() با پارامترهای جداشده از Query میتواند از SQL Injection جلوگیری کند.
بله. MySQLi از Prepared Statements و Parameter Binding پشتیبانی میکند. الگوی prepare()، bind_param() و execute() روش استاندارد استفاده از این قابلیت است.
WordPress APIهایی مانند $wpdb->prepare() را برای ساخت Queryهای پارامتری در اختیار توسعهدهندگان قرار میدهد، اما افزونهها و قالبهای دارای Queryهای سفارشی همچنان باید به شکل صحیح از این API استفاده کنند.
%i در WordPress برای نام جدول استفاده میشود؟بله. wpdb::prepare() از WordPress 6.2 از %i برای Identifierهایی مانند نام جدول و ستون پشتیبانی میکند.
خیر. WAF یک لایه دفاعی اضافی است. آسیبپذیری موجود در کد باید در خود برنامه اصلاح شود.
خیر. ORMها بسیاری از Queryها را به شکل امن تولید میکنند، اما استفاده نادرست از Raw SQL یا Queryهای Dynamic همچنان میتواند آسیبپذیری ایجاد کند.
مرز بین کد SQL و داده کاربر باید حفظ شود. Prepared Statement این جداسازی را برای مقادیر Query فراهم میکند و در بخشهایی که Parameterization ممکن نیست، باید از طراحی مناسب یا Allowlist استفاده شود.