جلوگیری از حملات تزریق SQL با پایتون

امیرحسین بیگدلو 4 ماه قبل

هر چند سال یکبار، پروژه امنیتی برنامه وب باز (OWASP) بحرانی ترین خطرات امنیتی برنامه های وب را رتبه بندی می کند. از همان اول، خطرات تزریق یا injection همیشه بالا بوده است. در میان تمام انواع تزریق، تزریق SQL یکی از رایج ترین حملات و احتمالاً خطرناک ترین است. از آنجایی که پایتون یکی از محبوب ترین زبان های برنامه نویسی در جهان است، دانستن نحوه محافظت در برابر تزریق SQL پایتون بسیار مهم است.

 

این آموزش برای کاربران تمامی موتورهای پایگاه داده مناسب است. مثال‌های اینجا از PostgreSQL استفاده می‌کنند، اما نتایج را می‌توان در سایر سیستم‌های مدیریت پایگاه داده (مانند SQLite، MySQL، Microsoft SQL Server، Oracle و غیره) بازتولید کرد.

 

دوره پیشنهادی: دوره آموزش پایتون (python)

 

 #  درک آسیب پذیری sql injection

حملات SQL Injection چنان آسیب پذیری امنیتی رایجی هستند که وب کامیک xkcd یک کمیک را به آن اختصاص داده است:

 

آسیب پذیری تزریق sql

 

ساخت و اجرای کوئری های SQL یک کار رایج است. با این حال، شرکت‌ها در سرتاسر جهان معمولاً هنگام نوشتن دستورات SQL اشتباهات وحشتناکی مرتکب می‌شوند. در حالی که استفاده از ORM ها از انواع انواع حملات را در زمان نوشتن کوئری sql جلوگیری میکنند، اما بعضی اوقات لازم است که خودتان بدون استفاده از ORM کوئری sql بنویسید.

 

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

 

دوره پیشنهادی: دوره آموزش زبان SQL

 

 #  راه اندازی دیتابیس

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

 

 

 +  ساخت یک پایگاه داده

ابتدا شل خود را باز کنید و یک پایگاه داده PostgreSQL جدید متعلق به کاربر postgres ایجاد کنید:

$ createdb -O postgres psycopgtest

 

در اینجا از گزینه خط فرمان -O برای تنظیم مالک پایگاه داده روی user postgres استفاده کردید. اسم دیتابیس رو هم مشخص کردید که psycopgtest هست.

 

پایگاه داده جدید شما آماده است! می توانید با استفاده از psql به آن متصل شوید:

$ psql -U postgres -d psycopgtest
psql (11.2, server 10.5)
Type "help" for help.

 

اکنون به عنوان کاربر postgres به پایگاه داده psycopgtest متصل هستید. این کاربر همچنین مالک پایگاه داده است، بنابراین شما مجوزهای خواندن را در هر جدول در پایگاه داده خواهید داشت.

 

 

 +  ایجاد کردن جدول

در مرحله بعد، باید یک جدول ایجاد کرده و در آن یکسری اطلاعات را ذخیره کنید:

psycopgtest=# CREATE TABLE users (
    username varchar(30),
    admin boolean
);
CREATE TABLE

psycopgtest=# INSERT INTO users
    (username, admin)
VALUES
    ('ran', true),
    ('haki', false);
INSERT 0 2

psycopgtest=# SELECT * FROM users;
 username | admin
----------+-------
 ran      | t
 haki     | f
(2 rows)

 

یک جدول به نام users ایجاد کردیم که دارای دو ستون است: username و admin. ستون admin نشان می دهد که آیا یک کاربر دارای امتیازات مدیریتی است یا خیر. هدف شما حمله به قسمت admin و تلاش برای سوء استفاده از آن است.

 

مقاله پیشنهادی: 9 مهارتی که هر برنامه نویس پایتون باید داشته باشد

 

 +  راه اندازی یک محیط مجازی پایتون

اکنون که یک پایگاه داده دارید، وقت آن است که محیط پایتون خود را راه اندازی کنید. برای آشنایی با محیط های مجازی در پایتون میتوانید ویدیو آموزش محیط های مجازی virtualenv در پایتون را مشاهده کنید:

(~/src) $ mkdir psycopgtest
(~/src) $ cd psycopgtest
(~/src/psycopgtest) $ python3 -m venv venv

 

پس از اجرای این دستور، دایرکتوری جدیدی به نام venv ایجاد می شود. این دایرکتوری تمام بسته هایی را که نصب می کنید در محیط مجازی ذخیره می کند.

 

 

 +  اتصال به پایگاه داده با پایتون

برای اتصال به پایگاه داده در پایتون، به یک آداپتور پایگاه داده نیاز دارید. اکثر آداپتورهای پایگاه داده از Database API PEP 249 پیروی می کنند. هر موتور پایگاه داده اصلی یک آداپتور اصلی دارد:

Database Adapter
PostgreSQL Psycopg
SQLite sqlite3
Oracle cx_oracle
MySql MySQLdb

 

برای اتصال به پایگاه داده PostgreSQL، باید Psycopg را نصب کنید، که محبوب ترین آداپتور PostgreSQL در پایتون است. Django ORM به طور پیش فرض از آن استفاده می کند و SQLAlchemy نیز از آن پشتیبانی می کند.

 

در ترمینال خود، محیط مجازی را فعال کنید و از pip برای نصب psycopg استفاده کنید:

(~/src/psycopgtest) $ source venv/bin/activate
(~/src/psycopgtest) $ python -m pip install psycopg2>=2.8.0
Collecting psycopg2
  Using cached https://....
  psycopg2-2.8.2.tar.gz
Installing collected packages: psycopg2
  Running setup.py install for psycopg2 ... done
Successfully installed psycopg2-2.8.2

 

اکنون شما آماده ایجاد یک اتصال به پایگاه داده خود هستید. در اینجا شروع اسکریپت پایتون شما است:

import psycopg2

connection = psycopg2.connect(
    host="localhost",
    database="psycopgtest",
    user="postgres",
    password=None,
)
connection.set_session(autocommit=True)

 

شما از psycopg2.connect برای ایجاد اتصال استفاده کردید. این تابع آرگومان های زیر را می پذیرد:

  • host آدرس IP یا DNS سروری است که پایگاه داده شما در آن قرار دارد
  • database نام پایگاه داده ای است که باید به آن متصل شوید
  • user کاربری با مجوزهای پایگاه داده است
  • password رمز عبوری است که برای user مشخص کرده اید

 

پس از تنظیم اتصال، سشن را با autocommit=True پیکربندی کردید. فعال کردن Autocommit به این معنی است که شما مجبور نیستید به صورت دستی تراکنش ها را با صدور یک commit یا rollback مدیریت کنید. این رفتار پیش فرض در اکثر ORM ها است. شما در اینجا نیز از این رفتار استفاده می‌کنید تا بتوانید به جای مدیریت تراکنش‌ها، روی نوشتن کوئری‌های SQL تمرکز کنید.

 

مقاله پیشنهادی: 7 اشتباه برنامه نویسان پایتون

 

 +  اجرا کردن یک کوئری

اکنون که به پایگاه داده متصل هستید، آماده اجرای یک کوئری هستید:

>>> with connection.cursor() as cursor:
...     cursor.execute('SELECT COUNT(*) FROM users')
...     result = cursor.fetchone()
... print(result)
(2,)

 

شما از آبجکت connection برای ایجاد cursor استفاده کردید. درست مانند یک فایل در پایتون، cursor به عنوان context manager پیاده سازی می شود. هنگامی که context را ایجاد می کنید، cursor برای شما باز می شود تا از آن برای ارسال دستورات به پایگاه داده استفاده کنید. وقتی context خارج می شود، cursor بسته می شود و دیگر نمی توانید از آن استفاده کنید.

 

در حالی که داخل context هستید، از cursor برای اجرای یک کوئری و واکشی نتایج استفاده می کنید. در این مورد، شما درخواستی برای شمارش ردیف‌های جدول users صادر کردید. برای واکشی نتیجه از کوئری، cursor.fetchone() را اجرا کردید و یک تاپل دریافت کردید. از آنجایی که cursor فقط می تواند یک نتیجه را برگرداند، شما از fetchone() استفاده کردید. اگر قرار بود کوئری بیش از یک نتیجه را برگرداند، باید یا روی cursor پیمایش کنید یا از یکی از متدهای fetch* دیگر استفاده کنید.

 

مقاله پیشنهادی: ساخت میکروسرویس با nameko در پایتون

 

 #  استفاده کوئری پارامترها

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

 

ابتدا، می‌خواهیم تابعی را پیاده‌سازی کنیم که بررسی می‌کند آیا کاربر admin است یا خیر. is_admin یک نام کاربری را می پذیرد و وضعیت مدیریت آن کاربر را برمی گرداند:

# BAD EXAMPLE. DON'T DO THIS!
def is_admin(username: str) -> bool:
    with connection.cursor() as cursor:
        cursor.execute("""
            SELECT
                admin
            FROM
                users
            WHERE
                username = '%s'
        """ % username)
        result = cursor.fetchone()
    admin, = result
    return admin

 

این تابع یک کوئری را برای واکشی مقدار ستون admin برای یک نام کاربری مشخص اجرا می کند. از fetchone برای برگرداندن یک تاپل با یک نتیجه استفاده کردیم. سپس، این تاپل را در متغیر admin باز کردیم. برای تست عملکرد خود، برخی از نام های کاربری را بررسی می کنیم:

>>> is_admin('haki')
False
>>> is_admin('ran')
True

 

تا اینجا همه چیز خوب است. تابع نتیجه ای را که انتظار داشتیم را برگرداند. اما برای کاربرهایی که وجود ندارند چطور؟ در این حالت یک traceback پایتون اتفاق خواهد افتاد:

>>> is_admin('foo')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 12, in is_admin
TypeError: cannot unpack non-iterable NoneType object

 

هنگامی که کاربر وجود ندارد، یک TypeError ایجاد می شود. این به این دلیل است که fetchone زمانی که هیچ نتیجه ای پیدا نمی شود، None را برمی گرداند و باز کردن None یک TypeError ایجاد می کند. تنها جایی که می‌توانید یک تاپل را باز کنید، جایی است که در admin چیزی باشد.

 

برای رسیدگی به کاربران ناموجود، یک مورد خاص برای زمانی که نتیجه هیچ است ایجاد کنید:

# BAD EXAMPLE. DON'T DO THIS!
def is_admin(username: str) -> bool:
    with connection.cursor() as cursor:
        cursor.execute("""
            SELECT
                admin
            FROM
                users
            WHERE
                username = '%s'
        """ % username)
        result = cursor.fetchone()

    if result is None:
        # User does not exist
        return False

    admin, = result
    return admin

 

در اینجا، یک مورد ویژه برای رسیدگی به None اضافه کرده‌اید. اگر نام کاربری وجود نداشته باشد، تابع باید False را برگرداند. یک بار دیگر، عملکرد را روی برخی از کاربران آزمایش کنید:

>>> is_admin('haki')
False
>>> is_admin('ran')
True
>>> is_admin('foo')
False

 

عالی! این تابع اکنون می تواند نام های کاربری ناموجود را نیز مدیریت کند.

 

مقاله پیشنهادی: آموزش کامل تابع count در پایتون

 

 #  انجام حمله sql injection

در مثال قبلی، از string interpolation برای ایجاد یک کوئری استفاده کردید. سپس، کوئری را اجرا کرده و رشته حاصل را مستقیماً به پایگاه داده ارسال کردید. با این حال، چیزی وجود دارد که ممکن است در طول این فرآیند نادیده گرفته باشید.

 

به آرگومان username که به is_admin ارسال کردید فکر کنید. این متغیر دقیقاً چه چیزی را نشان می دهد؟ ممکن است فرض کنید که username فقط یک رشته است که نشان دهنده نام واقعی کاربر است. همانطور که می‌بینید، یک مزاحم به راحتی می‌تواند از این نوع نظارت سوء استفاده کند و با انجام تزریق SQL پایتون آسیب‌های عمده‌ای وارد کند.

 

بیایید بررسی کنیم که آیا کاربر زیر admin است یا خیر:

>>> is_admin("'; select true; --")
True

 

بیایید نگاهی دیگر به اجرای کد بالا بیندازیم. کوئری کامل در حال اجرا در پایگاه داده را چاپ کنید:

>>> print("select admin from users where username = '%s'" % "'; select true; --")
select admin from users where username = ''; select true; --'

 

متن نتیجه شامل سه عبارت است. برای درک دقیق نحوه عملکرد تزریق SQL پایتون، باید هر قسمت را به صورت جداگانه بررسی کنید. بیانیه اول به شرح زیر است:

select admin from users where username = '';

 

این کوئری مورد نظر شماست. نقطه ویرگول (;) کوئری را خاتمه می دهد، بنابراین نتیجه این کوئری مهم نیست. مورد بعدی عبارت دوم است:

select true;

 

این دستور توسط هکر ساخته شده است. این دستور طراحی شده است تا همیشه True را برگرداند.

 

در نهایت، این بیت کوتاه کد را مشاهده می کنید:

--'

 

این بخش هر چیزی که بعد از آن می آید را خنثی می کند. هکر نماد کامنت (--) را اضافه کرد تا هر چیزی را که ممکن است بعد از آن باشد به یک کامنت تبدیل کند.

 

وقتی تابع را با این آرگومان اجرا می کنید، همیشه True برمی گرداند. به عنوان مثال، اگر از این تابع در صفحه ورود خود استفاده کنید، یک نفوذگر می تواند با نام کاربری '; select true; -- وارد شده، و به آنها اجازه دسترسی داده خواهد شد.

 

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

>>> is_admin('haki')
False
>>> is_admin("'; update users set admin = 'true' where username = 'haki'; select true; --")
True
>>> is_admin('haki')
True

 

بیایید مرحله به مرحله جلو برویم:

';

 

این قطعه کوئری را مانند تزریق قبلی خاتمه می دهد. دستور بعدی به شرح زیر است:

update users set admin = 'true' where username = 'haki';

 

این بخش دسترسی ادمین را برای کاربر haki به true آپدیت می کند.

 

در نهایت، این قطعه کد وجود دارد:

select true; --

 

مانند مثال قبلی، این قطعه true را برمی‌گرداند و هر چیزی را که به دنبال آن است را کامنت میکند.

 

چرا این بدتر است؟ خب، اگر نفوذگر بتواند تابع را با این ورودی اجرا کند، کاربر haki تبدیل به یک ادمین می شود:

psycopgtest=# select * from users;
 username | admin
----------+-------
 ran      | t
 haki     | t
(2 rows)

 

مزاحم دیگر مجبور نیست از هک استفاده کند. آنها می توانند با نام کاربری haki وارد شوند. (اگر متجاوز واقعاً می‌خواست آسیبی وارد کند، حتی می‌توانست دستور DROP DATABASE را صادر کند.)

 

قبل از اینکه فراموش کنید، haki را به حالت اولیه بازگردانید:

psycopgtest=# update users set admin = false where username = 'haki';
UPDATE 1

 

چرا این اتفاق می افتد؟ خب، در مورد آرگومان username چه می دانید؟ می دانید که باید رشته ای باشد که نام کاربری را نشان می دهد، اما درستی این ادعا را بررسی نمی کنید. این می تواند خطرناک باشد! این دقیقاً همان چیزی است که مهاجمان هنگام تلاش برای هک کردن سیستم شما به دنبال آن هستند.

 

دوره پیشنهادی: دوره آموزش RabbitMQ

 

 +  ایمن کردن پارامترهای کوئری

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

 

هر زمانی که ورودی کاربر در کوئری پایگاه داده استفاده می شود، یک آسیب پذیری احتمالی برای sql injection وجود دارد. نکته کلیدی برای جلوگیری از تزریق SQL در پایتون این است که مطمئن شوید مقدار مورد نظر برنامه‌نویس مورد استفاده قرار می‌گیرد. در مثال قبلی، قصد داشتید از نام کاربری به عنوان رشته استفاده شود. در واقع، از آن به عنوان یک عبارت خام SQL استفاده شد.

 

برای اطمینان از درستی عبارت ورودی باید کاراکترها را escape کنید. به عنوان مثال، برای جلوگیری از تزریق SQL خام توسط مزاحمان به جای آرگومان رشته، می توانید علامت نقل قول را escape کنید:

>>> # BAD EXAMPLE. DON'T DO THIS!
>>> username = username.replace("'", "''")

 

این فقط یک نمونه است. هنگام تلاش برای جلوگیری از تزریق SQL پایتون، کاراکترها و سناریوهای ویژه زیادی وجود دارد که باید به آنها فکر کنید. شانس با شما یار است، آداپتورهای پایگاه داده مدرن، دارای ابزارهای داخلی برای جلوگیری از تزریق SQL پایتون با استفاده از پارامترهای کوئری هستند. اینها به جای string interpolation ساده برای نوشتن یک کوئری با پارامترها استفاده می شوند.

 

اکنون که درک بهتری از آسیب‌پذیری دارید، آماده هستید که تابع را با استفاده از پارامترهای query به جای string interpolation بازنویسی کنید:

def is_admin(username: str) -> bool:
    with connection.cursor() as cursor:
        cursor.execute("""
            SELECT
                admin
            FROM
                users
            WHERE
                username = %(username)s
        """, {
            'username': username
        })
        result = cursor.fetchone()
    if result is None:
        # User does not exist
        return False
    admin, = result
    return admin

 

بخش های زیر را تغییر دادیم:

  • در خط 9, شما از یک پارامتر برای username استفاده کردید تا مشخص کنید username کجا باید برود. توجه کنید که چگونه پارامتر username دیگر با یک علامت نقل قول احاطه نشده است
  • در خط 11, شما مقدار username را به عنوان آرگومان دوم به cursor.execute() ارسال کردید. اتصال از نوع و مقدار username در هنگام اجرای کوئری در پایگاه داده استفاده می کند

 

برای آزمایش این تابع، مقداری معتبر و نامعتبر از جمله رشته خطرناک قبلی را امتحان کنید:

>>> is_admin('haki')
False
>>> is_admin('ran')
True
>>> is_admin('foo')
False
>>> is_admin("'; select true; --")
False

 

تابع نتیجه مورد انتظار را برای همه مقادیر برگرداند. علاوه بر این، رشته خطرناک دیگر کار نمی کند. برای درک دلیل، می توانید کوئری ایجاد شده توسط execute() را ببینید:

>>> with connection.cursor() as cursor:
...    cursor.execute("""
...        SELECT
...            admin
...        FROM
...            users
...        WHERE
...            username = %(username)s
...    """, {
...        'username': "'; select true; --"
...    })
...    print(cursor.query.decode('utf-8'))
SELECT
    admin
FROM
    users
WHERE
    username = '''; select true; --'

 

اتصال، مقدار username را به عنوان یک رشته در نظر گرفت و از هر کاراکتری که ممکن است رشته را خاتمه دهد و تزریق SQL پایتون را ایجاد کند، مقابله کرد.

 

 

 +  ارسال پارامترهای امن به کوئری

آداپتورهای پایگاه داده معمولاً چندین راه را برای ارسال پارامترهای کوئری ارائه می دهند. Named placeholders معمولاً برای خوانایی بهترین هستند، اما برخی از پیاده‌سازی‌ها ممکن است از استفاده از گزینه‌های دیگر سود ببرند.

 

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

# BAD EXAMPLES. DON'T DO THIS!
cursor.execute("SELECT admin FROM users WHERE username = '" + username + '");
cursor.execute("SELECT admin FROM users WHERE username = '%s' % username);
cursor.execute("SELECT admin FROM users WHERE username = '{}'".format(username));
cursor.execute(f"SELECT admin FROM users WHERE username = '{username}'");

 

هر یک از این عبارات بدون انجام هیچ گونه بررسی یا اعتبارسنجی، username را مستقیماً از کاربر به پایگاه داده منتقل می کند. این نوع کد برای پذیرایی از SQL injection آماده است.

 

در مقابل، این نوع کوئری ها باید برای اجرای شما ایمن باشند:

# SAFE EXAMPLES. DO THIS!
cursor.execute("SELECT admin FROM users WHERE username = %s'", (username, ));
cursor.execute("SELECT admin FROM users WHERE username = %(username)s", {'username': username});

 

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

 

 

 #  جمع بندی

شما با موفقیت تابعی را پیاده سازی کرده اید که SQL را بدون اینکه سیستم شما را در معرض خطر تزریق SQL پایتون قرار دهد، اجرا میکند!

 

منبع: RealPython

مطالب مشابه



مونگارد