SQL Injection یکی از قدیمی‌ترین آسیب‌پذیری‌های وب است که با وجود گذشت چند دهه از معرفی آن، هنوز در فهرست OWASP و در گزارش‌های نشت داده جایگاه ثابتی دارد. دلیل این پایداری ساده است: SQL Injection از یک تصمیم معماری بنیادین ناشی می‌شود — مرز میان کد و داده، که اگر به‌درستی مدیریت نشود، هر ورودی کاربر به یک فرمان اجرایی تبدیل می‌شود. SQL Injection (تزریق SQL) از یک سو ساده‌ترین حمله برای اجراست و از سوی دیگر، مخرب‌ترین پیامد را می‌سازد: دسترسی کامل به پایگاه داده، افشای اطلاعات کاربران، حذف یا تغییر داده و در سناریوهای پیشرفته، اجرای فرمان روی سرور. در این راهنما، مکانیزم دقیق تزریق، انواع حمله، ریشه‌ی فنی، الگوهای دفاعی مقاوم و اشتباهات رایج در پیاده‌سازی بررسی می‌شود.

در یکی از پروژه‌های فروشگاهی که برای بازبینی امنیتی به دست ما رسید، یک گزارش جستجوی سفارشی وجود داشت که کوئری را با الحاق رشته‌ای می‌ساخت. با یک ورودی ساده، خروجی سرور همه‌ی محصولات پنهان و حتی بخشی از جدول کاربران را بازگرداند. از آن روز، هر بار کدی می‌بینیم که کوئری را با الحاق می‌سازد، پیش از هر چیز به این فکر می‌کنیم که این کوئری، کد است یا داده.

SQL Injection چیست و چرا هنوز زنده است

SQL Injection یا SQL Injection نوعی حمله است که در آن مهاجم، با دستکاری ورودی‌هایی که به کوئری SQL می‌رسند، دستورات دلخواه خود را در پایگاه داده اجرا می‌کند. این حمله از یک فرض بنیادین غلط در طراحی برنامه ناشی می‌شود: اینکه ورودی کاربر هرگز بخشی از دستور نیست، بلکه فقط داده است. اگر برنامه‌نویس این مرز را رعایت نکند، SQL Injection شکل می‌گیرد.

چرا با وجود تمام پیشرفت‌ها، SQL Injection هنوز در گزارش‌ها ظاهر می‌شود؟ سه دلیل اصلی:

  • کد قدیمی و انباشته: بسیاری از برنامه‌های در حال بهره‌برداری، سال‌ها پیش نوشته شده‌اند و بخشی از کوئری‌های آن‌ها هنوز با الحاق ساخته می‌شود.
  • توسعه‌ی سریع و فراموشی لایه‌ی امنیت: در تیم‌هایی که فشار زمانی زیاد است، گاهی کوئری‌های خام به‌عنوان راه‌حل سریع جایگزین ORM می‌شوند.
  • ورودی‌های غیرمنتظره: هدرها، Cookieها، پارامترهای مسیر و حتی فیلدهای فرم‌های JSON می‌توانند به کوئری برسند و از دید توسعه‌دهنده پنهان بمانند.

SQL Injection در دسته‌ی آسیب‌پذیری‌هایی قرار می‌گیرد که به آن‌ها «Injection Flaws» می‌گویند. در این دسته، داده‌ی کاربر از یک کانال ورودی به یک مفسر (Interpreter) می‌رسد و مفسر آن را به‌عنوان دستور تلقی می‌کند. مشابه این الگو را در حملات XSS (تزریق جاوااسکریپت) و Code Injection می‌بینیم.

مکانیزم دقیق تزریق، گام‌به‌گام

برای درک دقیق، یک نمونه‌ی ساده‌ی PHP را در نظر بگیرید:

$id = $_GET["id"];
$query = "SELECT * FROM users WHERE id = " . $id;
$result = mysqli_query($conn, $query);

اگر مهاجم مقدار id را برابر 1 OR 1=1 قرار دهد، کوئری نهایی می‌شود:

SELECT * FROM users WHERE id = 1 OR 1=1

این کوئری، همه‌ی رکوردهای جدول را بازمی‌گرداند، نه فقط رکورد کاربر با شناسه ۱. اما این تنها شروع کار است. مهاجم می‌تواند با دستکاری ورودی، ساختار کوئری را تغییر دهد:

1 UNION SELECT username, password FROM users --

که منجر به کوئری زیر می‌شود:

SELECT * FROM users WHERE id = 1 UNION SELECT username, password FROM users --

در این حالت، نام کاربری و رمز عبور همه‌ی کاربران به مهاجم بازگردانده می‌شود. کاراکترهای -- در MySQL یک کامنت خطی ایجاد می‌کنند و بقیه‌ی کوئری اصلی را بی‌اثر می‌سازند.

مراحل دقیق حمله:

  1. شناسایی یک ورودی که به کوئری SQL می‌رسد.
  2. تعیین نوع و ساختار کوئری با تکنیک‌های آزمون و خطا.
  3. ساخت payload مناسب برای استخراج داده یا اجرای فرمان.
  4. استفاده از خروجی برای افشای اطلاعات یا تشدید حمله.

نکته‌ی مهم این است که SQL Injection فقط محدود به ورودی GET نیست. هدر User-Agent، فیلدهای Cookie، پارامترهای JSON، و حتی نام فایل‌ها می‌توانند به کوئری برسند. هر کانال ورودی که بدون تمیزکاری به SQL برسد، یک نقطه‌ی ورودی بالقوه است.

SQL Injection در لحظه‌ای متولد می‌شود که مرز میان «کد» و «داده» در ذهن توسعه‌دهنده فرو می‌ریزد. هر راه‌حلی که این مرز را بازسازی کند، دفاع مؤثر است.

انواع حمله SQL Injection

SQL Injection یک حمله‌ی تک‌شکلی نیست؛ بسته به بستر، نوع کوئری و بازخورد سرور، شکل‌های متفاوتی می‌گیرد.

1. In-band SQLi (Classic)

در این حالت، مهاجم داده را از طریق همان کانال درخواست/پاسخ استخراج می‌کند. دو زیرشاخه دارد:

  • Error-based: مهاجم از پیام‌های خطای دیتابیس برای استخراج اطلاعات استفاده می‌کند.
  • UNION-based: مهاجم با ترکیب کوئری مخرب با کوئری اصلی، نتایج را از همان مسیر دریافت می‌کند.

2. Blind SQLi

وقتی سرور پیام خطا یا خروجی مستقیم برنمی‌گرداند، مهاجم از رفتار برنامه برای استنتاج اطلاعات استفاده می‌کند. دو زیرشاخه دارد:

  • Boolean-based: مهاجم با پرسش‌های بله/خیر، اطلاعات را استخراج می‌کند.
  • Time-based: مهاجم با تزریق دستور SLEEP()، تأخیر ایجاد می‌کند و بر اساس زمان پاسخ، اطلاعات را استنتاج می‌کند.

3. Out-of-band SQLi

در این حالت، مهاجم از کانال دیگری مانند DNS یا HTTP برای دریافت داده استفاده می‌کند. این روش در سناریوهایی که سرور به اینترنت خارجی دسترسی دارد، بسیار خطرناک است.

4. Second-order SQL Injection

payload مهاجم ابتدا در دیتابیس ذخیره می‌شود و در یک مرحله‌ی بعدی، بدون تمیزکاری به کوئری برمی‌گردد. این نوع حمله از دید ابزارهای اسکنر معمولی پنهان می‌ماند، چون در مرحله‌ی اول هیچ خطای مشخصی رخ نمی‌دهد.

5. NoSQL Injection

در پایگاه‌های داده‌ی NoSQL مانند MongoDB، الگوی مشابهی وجود دارد. اگر ورودی کاربر به‌عنوان یک Query Object تفسیر شود، مهاجم می‌تواند با ساختارهای تودرتو مانند $ne یا $gt، نتایج را دستکاری کند.

ریشه‌ی فنی: مرز کد و داده

ریشه‌ی تمام انواع SQL Injection یک چیز است: الحاق رشته‌ای. وقتی کوئری به‌صورت یک رشته‌ی واحد ساخته می‌شود، مفسر SQL هیچ راهی ندارد که بفهمد کدام بخش کد است و کدام بخش داده. بنابراین، هر ورودی که ظاهر دستور داشته باشد، به‌عنوان دستور اجرا می‌شود.

سه ویژگی محیط، این مشکل را تشدید می‌کند:

  • دسترسی بیش‌ازحد کاربر دیتابیس: اگر اتصال برنامه با کاربر root برقرار شود، مهاجم می‌تواند فرمان‌های سیستمی هم اجرا کند.
  • نمایش پیام‌های خطای دیتابیس: پیام‌های خطا، ساختار جدول و کوئری را برای مهاجم آشکار می‌کنند.
  • نبود پارامترگذاری در سطح کتابخانه: برخی کتابخانه‌های دیتابیس، پارامترگذاری واقعی ندارند و با جایگزینی رشته‌ای کار می‌کنند.

در سطح معماری، دفاع مؤثر باید هم‌زمان در سه لایه اتفاق بیفتد: لایه‌ی کد (پارامترگذاری)، لایه‌ی دیتابیس (اصل کمترین دسترسی)، و لایه‌ی ناظر (مانیتورینگ و لاگ‌گیری). نبود هرکدام از این لایه‌ها، پنجره‌ی بهره‌برداری را باز می‌گذارد.

برای مطالعه‌ی معماری امنیتی دیتابیس و اصول سطح پایین آن، پست‌های امنیت دیتابیس چیست و بهترین روش‌های امنیت MySQL منبع کاملی هستند.

Prepared Statements: چرا بهترین دفاع است

Prepared Statements (دستورهای آماده) بهترین و مقاوم‌ترین دفاع در برابر SQL Injection هستند، چون ساختار کوئری را از داده جدا می‌کنند. در این الگو، کوئری ابتدا با placeholderهای پارامتری به دیتابیس فرستاده می‌شود، ساختار آن در موتور دیتابیس ثبت می‌شود، و سپس مقادیر به‌عنوان پارامتر ارسال می‌شوند. موتور دیتابیس، این مقادیر را فقط به‌عنوان داده تفسیر می‌کند و هرگز به‌عنوان دستور اجرا نمی‌کند.

نمونه در PHP با PDO:

$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(["email" => $email]);
$user = $stmt->fetch();

نمونه در PHP با MySQLi:

$stmt = $mysqli->prepare("SELECT * FROM users WHERE email = ?");
$stmt->bind_param("s", $email);
$stmt->execute();

نمونه در پایتون با MySQL Connector:

cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
user = cursor.fetchone()

نکته‌ی حیاتی این است که در کتابخانه‌ی MySQL Connector پایتون، پارامترگذاری با %s به‌عنوان placeholder انجام می‌شود، نه با ?. درک صحیح این تفاوت‌ها برای هر کتابخانه، بخشی از مهارت مهندسی است.

چه زمانی Prepared Statements کافی نیست

Prepared Statements در برابر مقادیری که به‌عنوان «کد» به کوئری می‌رسند، مقاوم نیستند. مثال‌های رایج:

  • نام جدول یا نام ستون که از ورودی کاربر می‌آید و با ORDER BY ? یا FROM ? ترکیب می‌شود.
  • عبارت LIMIT ? در برخی کتابخانه‌ها با پارامترگذاری رشته‌ای ترکیب می‌شود و مقاوم نیست.
  • ساخت داینامیک WHERE که با الحاق رشته‌ای ساخته می‌شود.

در این موارد، باید از یک allow-list (لیست مجاز) استفاده کرد؛ یعنی نام‌های مجاز جدول و ستون از پیش تعریف شوند و ورودی کاربر با آن‌ها مقایسه شود. این الگو در سطح کتابخانه‌های سطح بالا مانند Doctrine یا Eloquent به‌صورت داخلی مدیریت می‌شود، اما در کوئری‌های خام باید دستی پیاده شود.

Prepared Statements ساختار کوئری را قفل می‌کند؛ اگر بخشی از ساختار خود کوئری از ورودی کاربر بیاید، باید با allow-list قفل شود، نه با پارامترگذاری.

اعتبارسنجی و پاک‌سازی: مرزها و محدودیت‌ها

اعتبارسنجی (Validation) و پاک‌سازی (Sanitization) مکمل Prepared Statements هستند، اما جایگزین آن نمی‌شوند. تفاوت این دو باید روشن باشد:

  • Validation: بررسی اینکه ورودی با الگوی مورد انتظار مطابقت دارد یا نه. اگر مطابقت نداشت، درخواست رد می‌شود.
  • Sanitization: تغییر ورودی برای حذف یا خنثی‌سازی بخش‌های خطرناک.

در PHP، توابع filter_var، filter_input و ثابت‌هایی مانند FILTER_VALIDATE_EMAIL و FILTER_VALIDATE_INT برای اعتبارسنجی استفاده می‌شوند. در وردپرس، sanitize_text_field، absint، intval، esc_sql، $wpdb->prepare و wp_kses ابزارهای اصلی هستند.

نکته‌ی مهم: esc_sql و mysqli_real_escape_string تنها در شرایطی که Prepared Statements در دسترس نباشد، استفاده می‌شوند. این توابع در برابر خطاهای کاراکترست و Collation خاص، گاهی رفتار ضعیفی دارند. بنابراین، Prepared Statements باید گزینه‌ی پیش‌فرض باشد.

مطالعه‌ی اعتبارسنجی داده‌ها در کدنویسی وردپرس و پاک‌سازی داده‌ها در کدنویسی وردپرس می‌تواند تصویر کاملی از این مرزها ارائه دهد.

SQL Injection در وردپرس و اکوسیستم آن

وردپرس یک لایه‌ی انتزاعی به نام $wpdb دارد که کار با دیتابیس را ساده می‌کند. اگر از این لایه به‌درستی استفاده شود، SQL Injection به‌سادگی رخ نمی‌دهد. اما در عمل، دو الگوی خطرناک را زیاد می‌بینیم:

1. استفاده از $wpdb->query بدون prepare

$id = $_GET["id"];
$wpdb->query("DELETE FROM {$wpdb->prefix}my_table WHERE id = $id");

این کد، مستقیم‌ترین شکل SQL Injection است. نسخه‌ی امن:

$id = absint($_GET["id"]);
$wpdb->query(
    $wpdb->prepare(
        "DELETE FROM {$wpdb->prefix}my_table WHERE id = %d",
        $id
    )
);

2. استفاده نادرست از esc_sql

تابع esc_sql در برخی سناریوها با کاراکترست‌های غیر UTF-8 رفتار ضعیفی دارد. توصیه‌ی عملی این است که به‌جای آن از $wpdb->prepare استفاده شود.

3. کوئری‌های خام در افزونه‌ها

بسیاری از افزونه‌های وردپرس، کوئری‌های خام را با الحاق رشته‌ای می‌سازند. اگر افزونه‌ای قدیمی یا بدون نگهداری باشد، احتمال وجود SQL Injection در آن بالاست. این یکی از دلایل مهم توصیه به بررسی سازگاری افزونه‌ها و بررسی امنیت قالب و افزونه است.

4. کوئری‌های سفارشی در تم‌ها

در تم‌های سفارشی، گاهی از $wpdb مستقیم استفاده می‌شود و پارامترگذاری نادیده گرفته می‌شود. این الگو در پروژه‌هایی که توسعه‌دهنده با وردپرس آشنا نیست، شایع‌تر است.

برای مطالعه‌ی جزئیات بیشتر درباره‌ی دفاع SQL در وردپرس، پست‌های چگونه دیتابیس وردپرس را امن کنیم و تزریق SQL چیست و چگونه می‌توان از آن دفاع کرد منبع کاملی هستند.

ORM و فریم‌ورک‌ها: امنیت خودکار یا توهم امنیت

ORMهایی مانند Eloquent در Laravel، Doctrine در Symfony و Django ORM در Django، به‌صورت پیش‌فرض از پارامترگذاری استفاده می‌کنند. اما این به معنای ایمنی کامل نیست. اگر از بخش‌های «Raw Query» این ORMها استفاده شود، همان آسیب‌پذیری بازمی‌گردد.

نمونه‌ی خطرناک در Django ORM:

# ناامن
User.objects.raw("SELECT * FROM users WHERE id = " + user_input)

# امن
User.objects.raw("SELECT * FROM users WHERE id = %s", [user_input])

در Laravel Eloquent:

// ناامن
DB::select("SELECT * FROM users WHERE id = " . $id);

// امن
DB::select("SELECT * FROM users WHERE id = ?", [$id]);

توصیه‌ی عملی این است که تیم‌ها یک قاعده‌ی داخلی داشته باشند: هر کوئری خام، باید در بازبینی کد به‌عنوان یک نقطه‌ی حساس علامت‌گذاری شود. این ساده‌ترین راه برای جلوگیری از بازگشت SQL Injection در پروژه‌های بزرگ است.

پرسش‌های پرتکرار در مورد SQL Injection

آیا SQL Injection فقط روی MySQL کار می‌کند؟

خیر. SQL Injection روی هر دیتابیس SQL (PostgreSQL، SQL Server، Oracle، SQLite) کار می‌کند و در دیتابیس‌های NoSQL، شکل مشابهی با نام NoSQL Injection وجود دارد.

آیا استفاده از HTTPS از SQL Injection جلوگیری می‌کند؟

خیر. HTTPS فقط کانال انتقال را امن می‌کند. SQL Injection از یک کانال ورودی معتبر (فرم، API، هدر) سوءاستفاده می‌کند و HTTPS مانع آن نمی‌شود.

آیا ORMها کاملاً ایمن هستند؟

خیر. ORMها فقط تا زمانی که از بخش Raw Query استفاده نکنید، ایمن هستند. هر استفاده از کوئری خام، باید با پارامترگذاری همراه باشد.

آیا محدود کردن طول ورودی کافی است؟

خیر. محدود کردن طول ورودی، حمله را سخت‌تر می‌کند اما جلوی آن را نمی‌گیرد. تنها راه‌حل قطعی، پارامترگذاری است.

آیا فیلتر کردن کاراکترهای خاص مانند تک‌کوتیشن کافی است؟

خیر. فیلتر کاراکترها با تغییر Encoding، کاراکترست‌های غیر استاندارد، یا خطاهای پیاده‌سازی دور زده می‌شود. این روش، دفاع ضعیفی است.

آیا SQL Injection روی کوئری‌های INSERT یا UPDATE هم کار می‌کند؟

بله. SQL Injection روی هر کوئری که ورودی کاربر داشته باشد، کار می‌کند؛ SELECT، INSERT، UPDATE، DELETE و حتی DDL.

تست و شناسایی آسیب‌پذیری

پس از پیاده‌سازی دفاع، باید آسیب‌پذیری به‌صورت فعالانه تست شود. فرآیند تست شامل این مراحل است:

  1. شناسایی نقاط ورودی: همه‌ی پارامترهای GET، POST، هدرها، Cookieها و پارامترهای مسیر.
  2. آزمون دستکاری ساده: تزریق ' و " برای مشاهده‌ی تغییر رفتار.
  3. آزمون Time-based: استفاده از SLEEP() برای تشخیص کوئری‌های آسیب‌پذیر.
  4. آزمون Boolean-based: مقایسه‌ی پاسخ‌ها برای ورودی‌های درست و غلط.
  5. استفاده از ابزارهای خودکار: sqlmap، OWASP ZAP، Burp Suite.

نکته‌ی کلیدی این است که تست‌های خودکار، تنها بخشی از سطح حمله را پوشش می‌دهند. SQL Injectionهای Second-order، که در مرحله‌ی اول ذخیره می‌شوند و در مرحله‌ی بعد فعال می‌شوند، اغلب از دید اسکنرهای خودکار پنهان می‌مانند. برای مطالعه‌ی روش‌های تست امنیت وب، پست تست امنیت وب‌سایت چگونه انجام می‌شود منبع کاملی است.

اشتباهات رایج در دفاع SQL Injection

نشانه علت ریشه‌ای راه‌حل
کوئری با الحاق رشته ساخته می‌شود الگوی قدیمی کدنویسی Prepared Statements
parmeterها با addslashes فرار داده می‌شوند اعتماد به توابع ناکافی پارامترگذاری واقعی
ORDER BY داینامیک از ورودی می‌آید نادیده گرفتن مرز کد/داده در ساختار کوئری Allow-list برای نام ستون
اتصال با کاربر root اصل کمترین دسترسی نادیده گرفته شده کاربر با دسترسی محدود
نمایش پیام خطای دیتابیس به کاربر پیکربندی نامناسب محیط تولید غیرفعال کردن نمایش خطا در Production
ORM با Raw Query ترکیب می‌شود توهم ایمنی خودکار بازبینی کد برای هر Raw Query

یک اشتباه دیگر که کمتر به آن توجه می‌شود، نادیده گرفتن ورودی‌های غیرمعمول مانند هدر User-Agent است. برخی برنامه‌ها این هدر را در لاگ ذخیره می‌کنند و کوئری درج را با الحاق می‌سازند. برای مطالعه‌ی فهرست کامل‌تری از اشتباهات، پست‌های اشتباهات رایج امنیت دیتابیس و بهترین روش‌های امنیت MySQL مراجع مفیدی هستند.

ملاحظات معماری پیشرفته

در معماری‌های توزیع‌شده و پروژه‌های بزرگ، دفاع SQL Injection پیچیدگی‌های خاص خود را پیدا می‌کند. چند نکته‌ی کلیدی:

1. تفکیک کاربران دیتابیس: هر سرویس باید کاربر دیتابیس اختصاصی خودش را داشته باشد، با حداقل دسترسی لازم. کاربر خواندن، نباید دسترسی نوشتن داشته باشد. این الگو، دامنه‌ی نفوذ در صورت آسیب‌پذیری را محدود می‌کند.

2. رمزنگاری داده‌های حساس: حتی اگر SQL Injection رخ دهد، داده‌های رمزنگاری‌شده (مانند رمز عبور هش‌شده) افشای مستقیم اطلاعات کاربر را دشوار می‌کند. برای مطالعه‌ی جزئیات بیشتر، پست رمزنگاری دیتابیس چگونه انجام می‌شود مفید است.

3. محدود کردن دسترسی شبکه‌ای دیتابیس: دیتابیس نباید از اینترنت عمومی قابل دسترسی باشد. برای مطالعه‌ی بیشتر، پست محدود کردن دسترسی خارجی به دیتابیس راهنمای عملی است.

4. مانیتورینگ و لاگ‌گیری کوئری‌های مشکوک: لاگ‌های دیتابیس باید برای الگوهای مشکوک، مانند کوئری‌های طولانی یا پرتکرار، پایش شوند. برای مطالعه‌ی بیشتر، پست لاگ‌های دیتابیس چگونه بررسی می‌شوند مفید است.

5. Web Application Firewall (WAF): یک WAF می‌تواند الگوهای شناخته‌شده‌ی SQL Injection را در سطح شبکه مسدود کند. اما WAF جایگزین دفاع در سطح کد نیست، بلکه لایه‌ی مکمل است.

6. تست نفوذ دوره‌ای: تست نفوذ باید بخشی از چرخه‌ی توسعه باشد، نه یک رویداد یک‌باره. هر تغییر در ساختار کوئری‌ها، یک بازبینی امنیتی جدید می‌طلبد.

SQL Injection از بین نمی‌رود؛ شکل آن عوض می‌شود. هر نسل از فناوری، لایه‌ی جدیدی از ورودی ایجاد می‌کند که اگر توسعه‌دهنده آن را جدی نگیرد، همان آسیب‌پذیری قدیمی را بازمی‌گرداند.

در انتها، باید پذیرفت که دفاع SQL Injection یک وظیفه‌ی مستمر است، نه یک تنظیم ثابت. پیشرفت‌های فناوری، فریم‌ورک‌ها و ابزارهای ORM، دفاع را ساده‌تر کرده‌اند اما مسئولیت را از دوش توسعه‌دهنده برنمی‌دارند. هر کوئری خام، یک نقطه‌ی تصمیم است.

اگر این تجربه را در یک پروژه‌ی واقعی داشته‌اید، برای ما جالب است بدانیم کدام بخش از مهاجرت به Prepared Statements بیشترین زمان را از تیم شما گرفت: بازنویسی کوئری‌ها، آموزش تیم یا سازگاری با ORM. تجربه‌ی خودتان را در دیدگاه‌ها بنویسید؛ به‌خصوص اگر راه‌حل متفاوتی برای کنترل کوئری‌های خام پیدا کرده‌اید که می‌تواند برای خواننده‌ی بعدی مفید باشد.

💡 نکته‌ی پایانی: هر کوئری خام در کد، یک بدهی امنیتی بالقوه است. اگر نمی‌توانید آن را حذف کنید، حداقل آن را علامت‌گذاری، بازبینی و تست کنید.