sql injection

SQL Injection چیست؟ آموزش و روش‌های جلوگیری

SQL Injection چیست؟ بررسی کامل آسیب‌پذیری SQL Injection و روش‌های جلوگیری

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

در ظاهر ممکن است مشکل ساده به نظر برسد؛ اما یک Query که به شکل ناامن ساخته شده می‌تواند به مهاجم اجازه دهد منطق Query را تغییر دهد و به اطلاعاتی دسترسی پیدا کند که نباید در اختیار او باشد.

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

SQL Injection چیست؟

SQL Injection یا به اختصار SQLi نوعی حمله است که در آن مهاجم تلاش می‌کند دستورات یا بخشی از منطق SQL را از طریق ورودی برنامه وارد Query کند.

فرض کنید یک برنامه برای ورود کاربر، Query زیر را ایجاد می‌کند:

SELECT * FROM Users
WHERE Username = 'admin'
AND Password = '123456';

اگر برنامه این Query را با اتصال مستقیم رشته‌ها به ورودی کاربر ایجاد کند، ساختار Query به ورودی کاربر وابسته خواهد بود.

برای مثال در یک پیاده‌سازی ناامن در C# ممکن است چیزی شبیه این نوشته شده باشد:

string username = txtUsername.Text;
string password = txtPassword.Text;

string query =
    "SELECT * FROM Users WHERE Username = '" +
    username +
    "' AND Password = '" +
    password +
    "'";

اینجا مشکل اصلی استفاده از String Concatenation برای ساخت SQL Query است.

برنامه به جای اینکه ورودی کاربر را صرفاً به عنوان یک مقدار در نظر بگیرد، آن را بخشی از متن SQL قرار می‌دهد.

SQL Injection چگونه اتفاق می‌افتد؟

مشکل معمولاً از این الگو شروع می‌شود:

Input User
    ↓
Application
    ↓
String Concatenation
    ↓
SQL Query
    ↓
Database

اگر ورودی کاربر مستقیماً وارد Query شود، مهاجم می‌تواند ورودی‌ای طراحی کند که ساختار مورد انتظار Query را تغییر دهد.

به عبارت ساده‌تر:

ورودی کاربر نباید بتواند تبدیل به بخشی از دستور SQL شود.

برنامه باید بتواند بین این دو تفاوت قائل شود:

SQL Command

و

User Data

این جداسازی یکی از مهم‌ترین اصول جلوگیری از SQL Injection است.

یک مثال ساده

فرض کنید برنامه Query زیر را تولید می‌کند:

SELECT * FROM Products
WHERE Name = 'Laptop';

اگر مقدار Laptop مستقیماً از کاربر دریافت شده باشد و با String Concatenation به Query اضافه شود، ساختار SQL به ورودی وابسته می‌شود.

در چنین شرایطی مهاجم می‌تواند تلاش کند با وارد کردن داده‌ای خاص، Query را از حالت مورد انتظار خارج کند.

نکته مهم این است که مشکل از SQL Server یا MySQL نیست.

مشکل از نحوه ساخت Query در Application است.

Database فقط Queryای را اجرا می‌کند که برنامه به آن تحویل داده است.

چرا SQL Injection خطرناک است؟

شدت SQL Injection به سطح دسترسی Application به Database و نوع آسیب‌پذیری بستگی دارد.

در یک سناریوی واقعی، SQL Injection می‌تواند باعث موارد زیر شود:

  • مشاهده اطلاعات حساس کاربران
  • افشای اطلاعات حساب‌ها
  • دسترسی غیرمجاز به داده‌های مالی
  • تغییر اطلاعات موجود در Database
  • حذف داده‌ها
  • دور زدن برخی محدودیت‌های منطقی برنامه
  • دسترسی به اطلاعاتی که کاربر مجاز به مشاهده آن‌ها نیست
  • در برخی شرایط، گسترش حمله به بخش‌های دیگر سیستم

بنابراین SQL Injection صرفاً یک Bug معمولی نیست؛ می‌تواند یک Security Vulnerability جدی باشد.

مهم‌ترین روش جلوگیری: Parameterized Query

یکی از مطمئن‌ترین و استانداردترین روش‌ها برای جلوگیری از SQL Injection استفاده از Parameterized Queries است.

در ADO.NET به جای اینکه مقدار کاربر را مستقیماً داخل رشته SQL قرار دهیم، از Parameter استفاده می‌کنیم.

مثلاً:

string query = @"
    SELECT *
    FROM Users
    WHERE Username = @Username
    AND Password = @Password";

سپس پارامترها را جداگانه ارسال می‌کنیم:

using var command = new SqlCommand(query, connection);

command.Parameters.AddWithValue("@Username", username);
command.Parameters.AddWithValue("@Password", password);

در این حالت مقدار username و password به عنوان Data در نظر گرفته می‌شوند، نه بخشی از ساختار SQL.

البته در کدهای واقعی بهتر است نوع پارامتر را نیز به صورت صریح مشخص کنیم و صرفاً به AddWithValue وابسته نباشیم:

command.Parameters.Add("@Username", SqlDbType.NVarChar, 100)
                  .Value = username;

command.Parameters.Add("@Password", SqlDbType.NVarChar, 200)
                  .Value = password;

این روش علاوه بر امنیت، کنترل بیشتری روی نوع داده و رفتار Query ایجاد می‌کند.

استفاده از Entity Framework Core

اگر در پروژه از Entity Framework Core استفاده می‌کنید، بسیاری از Queryها به صورت امن و Parameterized تولید می‌شوند.

برای مثال:

var user = await dbContext.Users
    .FirstOrDefaultAsync(x =>
        x.Username == username);

در این حالت مقدار username به عنوان Parameter به Database ارسال می‌شود.

بنابراین این روش بسیار بهتر از ساخت دستی Query است.

روش ناامن

var query =
    $"SELECT * FROM Users WHERE Username = '{username}'";

روش امن‌تر

var user = await dbContext.Users
    .FirstOrDefaultAsync(x => x.Username == username);

در پروژه‌های .NET معمولاً بهتر است تا جای ممکن از قابلیت‌های استاندارد ORM استفاده شود و از ساخت دستی SQL با String Concatenation دوری کنیم.

آیا Stored Procedure جلوی SQL Injection را می‌گیرد؟

این یکی از اشتباهات رایج است.

بعضی‌ها تصور می‌کنند اگر از Stored Procedure استفاده کنیم، دیگر SQL Injection امکان‌پذیر نیست.

این تصور اشتباه است.

اگر Stored Procedure خودش Query را به شکل ناامن و با String Concatenation بسازد، همچنان ممکن است آسیب‌پذیر باشد.

مثلاً:

CREATE PROCEDURE SearchUsers
    @Username NVARCHAR(100)
AS
BEGIN

    DECLARE @sql NVARCHAR(MAX);

    SET @sql =
        'SELECT * FROM Users WHERE Username = ''' +
        @Username +
        '''';

    EXEC(@sql);

END

در اینجا مشکل همچنان وجود دارد، چون ورودی کاربر وارد ساختار SQL شده است.

در مقابل، استفاده صحیح از پارامترها بسیار امن‌تر است.

SQL Injection در Dynamic SQL

Dynamic SQL زمانی کاربرد دارد که بخشی از Query در زمان اجرا ساخته می‌شود.

Dynamic SQL به خودی خود مشکل امنیتی نیست، اما اگر داده‌های کنترل‌نشده کاربر وارد ساختار Query شوند، خطر SQL Injection ایجاد می‌شود.

برای مثال، این روش خطرناک است:

var query =
    "SELECT * FROM Products ORDER BY " + sortColumn;

چون sortColumn مستقیماً وارد Query شده است.

در چنین شرایطی Parameter به تنهایی همیشه راه‌حل مناسبی نیست، چون مثلاً نام Column معمولاً نمی‌تواند مانند یک مقدار معمولی Parameter شود.

راه‌حل بهتر استفاده از Allowlist است.

مثلاً:

var allowedColumns = new Dictionary<string, string>
{
    ["name"] = "Name",
    ["price"] = "Price",
    ["date"] = "CreatedAt"
};

if (!allowedColumns.TryGetValue(sortColumn, out var column))
{
    throw new ArgumentException("Invalid sort column.");
}

var query = $"SELECT * FROM Products ORDER BY {column}";

در اینجا کاربر نمی‌تواند هر رشته‌ای را به عنوان نام Column وارد کند؛ فقط مقادیری که برنامه از قبل مشخص کرده قابل استفاده هستند.

Validation به تنهایی کافی نیست

یکی دیگر از اشتباهات رایج این است که تصور کنیم اگر ورودی کاربر را Validate کنیم، SQL Injection کاملاً حل می‌شود.

Validation مفید است، اما جایگزین Parameterization نیست.

مثلاً ممکن است برنامه بررسی کند که Username فقط شامل حروف و اعداد باشد. این کار خوب است، اما همچنان باید Query با Parameter ساخته شود.

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

Input Validation

برای بررسی اینکه داده ورودی از نظر Business Rule معتبر است.

Parameterized Query

برای جلوگیری از تبدیل شدن داده ورودی به بخشی از SQL Command.

هر دو مهم هستند، اما نقش یکسانی ندارند.

استفاده از ORM به معنی امنیت کامل نیست

استفاده از Entity Framework Core یا ORMهای دیگر به معنی این نیست که برنامه به صورت خودکار در برابر تمام SQL Injectionها ایمن است.

برای مثال، استفاده از APIهای استاندارد ORM معمولاً بسیار امن‌تر است:

var products = await context.Products
    .Where(x => x.Name == productName)
    .ToListAsync();

اما اگر در جایی SQL خام را به شکل ناامن تولید کنیم، دوباره همان مشکل ممکن است ایجاد شود.

بنابراین هنگام استفاده از Raw SQL نیز باید مراقب باشیم.

Raw SQL در Entity Framework Core

گاهی استفاده از SQL خام ضروری است. در این شرایط باید از APIهایی استفاده کنیم که Parameterization را حفظ می‌کنند.

برای مثال:

var users = await context.Users
    .FromSqlInterpolated(
        $"SELECT * FROM Users WHERE Username = {username}")
    .ToListAsync();

در این حالت مقدار username به عنوان Parameter ارسال می‌شود.

اما باید مراقب تفاوت بین APIهای Parameterized و ساختن رشته SQL به صورت دستی باشیم.

مثلاً این الگو خطرناک است:

var sql =
    $"SELECT * FROM Users WHERE Username = '{username}'";

و سپس اجرای مستقیم آن Query.

قاعده ساده است:

هرجا SQL خام می‌نویسید، بررسی کنید داده‌های خارجی چگونه وارد Query می‌شوند.

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

چند اشتباه در پروژه‌های واقعی زیاد دیده می‌شود:

1. اتصال رشته‌ها برای ساخت Query

var sql = "SELECT * FROM Users WHERE Id = " + id;

این یکی از واضح‌ترین الگوهای ناامن است.

2. اعتماد به ورودی کاربر

اینکه کاربر معمولی است یا فرم فقط یک Input ساده دارد، به معنی امن بودن داده نیست.

تمام داده‌هایی که از Client وارد Server می‌شوند باید Untrusted Input در نظر گرفته شوند.

3. تصور اینکه Stored Procedure همیشه امن است

Stored Procedure هم اگر Dynamic SQL ناامن داشته باشد، می‌تواند آسیب‌پذیر باشد.

4. تکیه صرف بر Validation

Validation خوب است، اما جای Parameterized Query را نمی‌گیرد.

5. استفاده نادرست از Dynamic SQL

اگر نام Column، Table، Sort Order یا بخش‌های دیگری از Query از ورودی کاربر گرفته شوند، باید با دقت کنترل شوند.

در این موارد معمولاً Allowlist راهکار مناسبی است.

6. استفاده از حساب Database با دسترسی بیش از حد

حتی اگر یک آسیب‌پذیری در Application وجود داشته باشد، سطح دسترسی Database User می‌تواند میزان خسارت را کاهش دهد.

Application نباید با یک Database Account دارای دسترسی‌های غیرضروری مثل تغییر ساختار Database یا دسترسی گسترده به تمام Schemaها اجرا شود.

این اصل با نام Least Privilege شناخته می‌شود.

چگونه یک برنامه را در برابر SQL Injection مقاوم کنیم؟

برای یک پروژه واقعی، این موارد را به عنوان یک چک‌لیست امنیتی در نظر بگیرید:

  • از Parameterized Query استفاده کنید.
  • از String Concatenation برای ساخت SQL استفاده نکنید.
  • در Entity Framework از Queryهای استاندارد LINQ استفاده کنید.
  • هنگام استفاده از Raw SQL، Parameterization را رعایت کنید.
  • برای Dynamic SQL از Allowlist استفاده کنید.
  • ورودی‌های کاربر را Untrusted در نظر بگیرید.
  • دسترسی Database Account را به حداقل مورد نیاز محدود کنید.
  • Errorهای Database را مستقیماً به کاربر نمایش ندهید.
  • لاگ و Monitoring مناسب داشته باشید.
  • Queryهای حساس را در Code Review بررسی کنید.
  • تست‌های امنیتی را در فرآیند توسعه وارد کنید.

آیا Hash کردن Password از SQL Injection جلوگیری می‌کند؟

خیر.

Hash کردن Password یک اقدام امنیتی مهم برای محافظت از Credentialها است، اما ارتباط مستقیمی با جلوگیری از SQL Injection ندارد.

این دو مسئله متفاوت هستند.

برای Password باید از روش‌های استاندارد Password Hashing مانند الگوریتم‌های مناسب و کتابخانه‌های معتبر استفاده شود.

در طرف دیگر، برای جلوگیری از SQL Injection باید Query را به شکل امن و Parameterized اجرا کنیم.

SQL Injection فقط مربوط به Login نیست

گاهی SQL Injection را فقط با فرم Login مرتبط می‌دانند، در حالی که هر ورودی‌ای که وارد Query شود می‌تواند در صورت پیاده‌سازی اشتباه مشکل‌ساز باشد.

مثلاً:

  • Search
  • Filter
  • Sort
  • Pagination
  • Product ID
  • User ID
  • گزارش‌ها
  • API Parameters
  • Query String
  • داده‌های ارسال‌شده در POST
  • Headerها یا سایر داده‌های خارجی که وارد Query می‌شوند

بنابراین باید به جای تمرکز روی یک فرم خاص، کل مسیر داده از ورودی تا Database را بررسی کنیم.

جمع‌بندی

SQL Injection بیشتر از اینکه یک مشکل مربوط به Database باشد، نتیجه یک طراحی ناامن در مرز بین Application و Database است.

اشتباه اصلی زمانی اتفاق می‌افتد که Application نتواند بین Command و Data تفاوت قائل شود.

در یک پروژه .NET، راهکار عملی معمولاً ساده است:

User Input
    ↓
Validation
    ↓
Application Logic
    ↓
Parameterized Query / ORM
    ↓
Database

و چیزی که باید از آن دوری کنیم:

User Input
    ↓
String Concatenation
    ↓
SQL Query
    ↓
Database

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

هیچ‌وقت داده‌ای که از کاربر دریافت کرده‌اید را مستقیماً برای ساخت SQL Query به رشته SQL اضافه نکنید. از Parameterization استفاده کنید.

امنیت Database معمولاً با یک راهکار عجیب و پیچیده شروع نمی‌شود؛ خیلی وقت‌ها با همین تصمیم ساده در زمان نوشتن اولین Query مشخص می‌شود.

دیدگاه‌ خود را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

پیمایش به بالا