Database
 sql >> база данни >  >> RDS >> Database

Предотвратяване на атаки с инжектиране на SQL с Python

На всеки няколко години Проектът за сигурност на отворените уеб приложения (OWASP) класира най-критичните рискове за сигурността на уеб приложенията. От първия доклад рисковете от инжектиране винаги са били на върха. Сред всички видове инжектиране, SQL инжекция е един от най-често срещаните вектори на атака и може би най-опасният. Тъй като Python е един от най-популярните езици за програмиране в света, знанието как да се защити срещу Python SQL инжекция е от решаващо значение.

В този урок ще научите:

  • Какво Python SQL инжекция е и как да го предотвратим
  • Как да съставяме заявки с литерали и идентификатори като параметри
  • Как да безопасно изпълнявате заявки в база данни

Този урок е подходящ за потребители на всички машини за бази данни . Примерите тук използват PostgreSQL, но резултатите могат да бъдат възпроизведени в други системи за управление на бази данни (като SQLite, MySQL, Microsoft SQL Server, Oracle и т.н.).

Безплатен бонус: 5 Thoughts On Python Mastery, безплатен курс за разработчици на Python, който ви показва пътната карта и начина на мислене, от който ще се нуждаете, за да изведете уменията си в Python на следващото ниво.


Разбиране на Python SQL инжекция

SQL Injection атаките са толкова често срещана уязвимост в сигурността, че легендарният xkcd webcomic му посвети комикс:

Генерирането и изпълнението на SQL заявки е често срещана задача. Въпреки това компаниите по целия свят често правят ужасни грешки, когато става въпрос за съставяне на SQL изявления. Докато ORM слоят обикновено съставя SQL заявки, понякога трябва да напишете свои собствени.

Когато използвате Python за изпълнение на тези заявки директно в база данни, има вероятност да направите грешки, които могат да компрометират вашата система. В този урок ще научите как да прилагате успешно функции, които съставят динамични SQL заявки без поставяне на вашата система в риск от инжектиране на Python SQL.



Настройване на база данни

За да започнете, ще настроите нова база данни PostgreSQL и ще я попълните с данни. По време на урока ще използвате тази база данни, за да видите от първа ръка как работи инжектирането на Python SQL.


Създаване на база данни

Първо отворете вашата обвивка и създайте нова PostgreSQL база данни, собственост на потребителя postgres :

$ createdb -O postgres psycopgtest

Тук сте използвали опцията на командния ред -O за да зададете собственик на базата данни на потребителя postgres . Вие също така посочихте името на базата данни, което е psycopgtest .

Забележка: postgres е специален потребител , който обикновено бихте запазили за административни задачи, но за този урок е добре да използвате postgres . В реална система обаче трябва да създадете отделен потребител, който да бъде собственик на базата данни.

Вашата нова база данни е готова за работа! Можете да се свържете с него с помощта на psql :

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

Вече сте свързани с базата данни psycopgtest като потребителя postgres . Този потребител е и собственик на базата данни, така че ще имате разрешения за четене на всяка таблица в базата данни.



Създаване на таблица с данни

След това трябва да създадете таблица с малко потребителска информация и да добавите данни към нея:

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)

Таблицата има две колони:username и admin . admin колоната показва дали потребителят има административни привилегии или не. Вашата цел е да се насочите към admin поле и се опитайте да злоупотребите с него.



Настройване на виртуална среда на Python

Сега, когато имате база данни, е време да настроите вашата Python среда. За инструкции стъпка по стъпка как да направите това, вижте Python Virtual Environments:A Primer.

Създайте своята виртуална среда в нова директория:

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

След като изпълните тази команда, се появява нова директория, наречена venv ще бъде създадена. Тази директория ще съхранява всички пакети, които инсталирате във виртуалната среда.



Свързване с базата данни

За да се свържете с база данни в Python, имате нужда от адаптер за база данни . Повечето адаптери на базата данни следват версия 2.0 на спецификацията PEP 249 на Python Database API. Всеки основен двигател на база данни има водещ адаптер:

База данни Адаптер
PostgreSQL Psycopg
SQLite sqlite3
Оракул cx_oracle
MySql MySQLdb

За да се свържете с PostgreSQL база данни, ще трябва да инсталирате Psycopg, който е най-популярният адаптер за PostgreSQL в Python. 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

Сега сте готови да създадете връзка с вашата база данни. Ето началото на вашия Python скрипт:

import psycopg2

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

Използвахте psycopg2.connect() за създаване на връзката. Тази функция приема следните аргументи:

  • host е IP адресът или DNS на сървъра, където се намира вашата база данни. В този случай хостът е вашата локална машина или localhost .

  • database е името на базата данни за свързване. Искате да се свържете с базата данни, която сте създали по-рано, psycopgtest .

  • user е потребител с разрешения за базата данни. В този случай искате да се свържете с базата данни като собственик, така че предавате на потребителя postgres .

  • password е паролата за този, който сте посочили в user . В повечето среди за разработка потребителите могат да се свързват с локалната база данни без парола.

След като настроихте връзката, конфигурирахте сесията с autocommit=True . Активиране на autocommit означава, че няма да се налага ръчно да управлявате транзакции чрез издаване на commit или rollback . Това е поведението по подразбиране в повечето ORM. Използвате това поведение и тук, за да можете да се съсредоточите върху съставянето на SQL заявки, вместо върху управлението на транзакции.

Забележка: Потребителите на Django могат да получат екземпляра на връзката, използвана от ORM от django.db.connection :

from django.db import connection


Изпълнение на заявка

Сега, когато имате връзка с базата данни, сте готови да изпълните заявка:

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

Използвахте connection обект, за да създадете cursor . Точно като файл в Python, cursor е реализиран като контекстен мениджър. Когато създавате контекста, cursor се отваря, за да използвате за изпращане на команди към базата данни. Когато контекстът излезе, cursor затваря и вече не можете да го използвате.

Забележка: За да научите повече за контекстните мениджъри, вижте Python Context Managers и изявлението „with“.

Докато сте в контекста, сте използвали cursor за да изпълните заявка и да извлечете резултатите. В този случай сте издали заявка за преброяване на редовете в users маса. За да извлечете резултата от заявката, изпълнихте cursor.fetchone() и получи кортеж. Тъй като заявката може да върне само един резултат, сте използвали fetchone() . Ако заявката трябваше да върне повече от един резултат, тогава ще трябва или да повторите над cursor или използвайте един от другите fetch* методи.




Използване на параметри на заявка в SQL

В предишния раздел създадохте база данни, установихте връзка с нея и изпълнихте заявка. Заявката, която използвахте, беше статична . С други думи, той е имал без параметри . Сега ще започнете да използвате параметри във вашите заявки.

Първо, ще внедрите функция, която проверява дали потребителят е администратор или не. 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

Дотук добре. Функцията върна очаквания резултат и за двамата потребители. Но какво да кажем за несъществуващ потребител? Разгледайте това проследяване на Python:

>>>
>>> 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 от result .

За да работите с несъществуващи потребители, създайте специален случай, когато result е None :

# 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 . Ако username не съществува, тогава функцията трябва да върне False . Още веднъж тествайте функцията на някои потребители:

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

Страхотен! Функцията вече може да обработва и несъществуващи потребителски имена.



Използване на параметри на заявка с Python SQL инжекция

В предишния пример използвахте интерполация на низове, за да генерирате заявка. След това изпълнихте заявката и изпратихте резултантния низ директно в базата данни. Има обаче нещо, което може да сте пропуснали по време на този процес.

Помислете отново за username аргумент, който сте предали на is_admin() . Какво точно представлява тази променлива? Може да предположите, че username е просто низ, който представлява действително име на потребител. Както ще видите обаче, натрапник може лесно да използва този вид надзор и да причини големи щети, като извърши инжектиране на Python SQL.

Опитайте се да проверите дали следният потребител е администратор или не:

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

Чакай... Какво се случи току-що?

Нека да разгледаме отново изпълнението. Отпечатайте действителната заявка, която се изпълнява в базата данни:

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

Полученият текст съдържа три твърдения. За да разберете как точно работи инжектирането на Python 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';

Този раздел актуализира admin до true за потребител haki .

И накрая, има този кодов фрагмент:

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 аргумент? Знаете, че това трябва да е низ, представляващ потребителското име, но всъщност не проверявате или налагате това твърдение. Това може да бъде опасно! Точно това търсят нападателите, когато се опитват да хакнат системата ви.


Изработване на параметри за безопасна заявка

В предишния раздел видяхте как натрапник може да експлоатира вашата система и да получи администраторски разрешения, като използва внимателно изработен низ. Проблемът беше, че сте позволили стойността, предадена от клиента, да се изпълнява директно в базата данни, без да извършвате каквато и да е проверка или валидиране. SQL инжекциите разчитат на този тип уязвимост.

Всеки път, когато въвеждането на потребителя се използва в заявка за база данни, има възможна уязвимост за SQL инжектиране. Ключът за предотвратяване на инжектирането на Python SQL е да се уверите, че стойността се използва по предназначение на разработчика. В предишния пример сте предвидили username да се използва като низ. В действителност той беше използван като необработен SQL израз.

За да сте сигурни, че стойностите се използват по предназначение, трябва да избягате стойността. Например, за да предотвратите натрапниците да инжектират необработен SQL на мястото на низов аргумент, можете да избегнете кавички:

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

Това е само един пример. Има много специални символи и сценарии, за които да мислите, когато се опитвате да предотвратите инжектирането на Python SQL. За късмет за вас, съвременните адаптери за база данни се предлагат с вградени инструменти за предотвратяване на инжектиране на Python SQL чрез използване на параметри на заявката . Те се използват вместо обикновена интерполация на низ за съставяне на заявка с параметри.

Забележка: Различните адаптери, бази данни и езици за програмиране се отнасят до параметрите на заявката с различни имена. Често срещаните имена включват променливи за свързване , заместващи променливи , и променливи за заместване .

Сега, когато имате по-добро разбиране на уязвимостта, сте готови да пренапишете функцията, използвайки параметри на заявката вместо интерполация на низ:

 1def is_admin(username: str) -> bool:
 2    with connection.cursor() as cursor:
 3        cursor.execute("""
 4            SELECT
 5                admin
 6            FROM
 7                users
 8            WHERE
 9                username = %(username)s
10        """, {
11            'username': username
12        })
13        result = cursor.fetchone()
14
15    if result is None:
16        # User does not exist
17        return False
18
19    admin, = result
20    return admin

Ето какво е различното в този пример:

  • В ред 9, използвахте именуван параметър 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 като низ и екранизира всички знаци, които могат да прекратят низа и да въведат Python SQL инжекция.



Предаване на параметри за безопасна заявка

Адаптерите за бази данни обикновено предлагат няколко начина за предаване на параметри на заявката. Именувани заместители обикновено са най-добрите за четливост, но някои реализации може да се възползват от използването на други опции.

Нека да разгледаме набързо някои от правилните и грешните начини за използване на параметрите на заявката. Следният кодов блок показва типовете заявки, които искате да избягвате:

# 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 от клиента директно към базата данни, без да се извършва каквато и да е проверка или валидиране. Този вид код е узрял за покана за инжектиране на Python SQL.

За разлика от тях, тези типове заявки трябва да са безопасни за изпълнение:

# 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 при изпълнение на заявката, предлагаща защита от Python SQL инжекция.




Използване на SQL композиция

Досега сте използвали параметри за литерали. Литерал са стойности като числа, низове и дати. Но какво ще стане, ако имате случай на употреба, който изисква съставяне на различна заявка – такава, в която параметърът е нещо друго, като име на таблица или колона?

Вдъхновени от предишния пример, нека внедрим функция, която приема името на таблица и връща броя на редовете в тази таблица:

# BAD EXAMPLE. DON'T DO THIS!
def count_rows(table_name: str) -> int:
    with connection.cursor() as cursor:
        cursor.execute("""
            SELECT
                count(*)
            FROM
                %(table_name)s
        """, {
            'table_name': table_name,
        })
        result = cursor.fetchone()

    rowcount, = result
    return rowcount

Опитайте да изпълните функцията на вашата потребителска таблица:

>>>
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 9, in count_rows
psycopg2.errors.SyntaxError: syntax error at or near "'users'"
LINE 5:                 'users'
                        ^

Командата не успя да генерира SQL. Както вече видяхте, адаптерът на базата данни третира променливата като низ или литерал. Името на таблица обаче не е обикновен низ. Тук идва SQL композицията.

Вече знаете, че не е безопасно да използвате интерполация на низ за съставяне на SQL. За щастие Psycopg предоставя модул, наречен psycopg.sql за да ви помогне да съставите безопасно SQL заявки. Нека пренапишем функцията с помощта на psycopg.sql.SQL() :

from psycopg2 import sql

def count_rows(table_name: str) -> int:
    with connection.cursor() as cursor:
        stmt = sql.SQL("""
            SELECT
                count(*)
            FROM
                {table_name}
        """).format(
            table_name = sql.Identifier(table_name),
        )
        cursor.execute(stmt)
        result = cursor.fetchone()

    rowcount, = result
    return rowcount

Има две разлики в това изпълнение. Първо, използвахте sql.SQL() за да съставите заявката. След това сте използвали sql.Identifier() за да анотирате стойността на аргумента table_name . (идентификатор е име на колона или таблица.)

Забележка: Потребители на популярния пакет django-debug-toolbar може да получи грешка в SQL панела за заявки, съставени с psycopg.sql.SQL() . Очаква се корекция за пускане във версия 2.0.

Сега опитайте да изпълните функцията на users таблица:

>>>
>>> count_rows('users')
2

Страхотен! След това нека видим какво се случва, когато таблицата не съществува:

>>>
>>> count_rows('foo')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 11, in count_rows
psycopg2.errors.UndefinedTable: relation "foo" does not exist
LINE 5:                 "foo"
                        ^

Функцията изхвърля UndefinedTable изключение. В следващите стъпки ще използвате това изключение като индикация, че вашата функция е безопасна от атака с инжектиране на Python SQL.

Забележка: Изключението UndefinedTable беше добавен в psycopg2 версия 2.8. Ако работите с по-ранна версия на Psycopg, тогава ще получите различно изключение.

За да обедините всичко, добавете опция за броене на редове в таблицата до определен лимит. Тази функция може да бъде полезна за много големи таблици. За да приложите това, добавете LIMIT клауза към заявката, заедно с параметрите на заявката за стойността на лимита:

from psycopg2 import sql

def count_rows(table_name: str, limit: int) -> int:
    with connection.cursor() as cursor:
        stmt = sql.SQL("""
            SELECT
                COUNT(*)
            FROM (
                SELECT
                    1
                FROM
                    {table_name}
                LIMIT
                    {limit}
            ) AS limit_query
        """).format(
            table_name = sql.Identifier(table_name),
            limit = sql.Literal(limit),
        )
        cursor.execute(stmt)
        result = cursor.fetchone()

    rowcount, = result
    return rowcount

В този кодов блок сте отбелязали limit използвайки sql.Literal() . Както в предишния пример, psycopg ще обвърже всички параметри на заявката като литерали, когато използва прост подход. Въпреки това, когато използвате sql.SQL() , трябва изрично да анотирате всеки параметър с помощта на sql.Identifier() или sql.Literal() .

Забележка: За съжаление спецификацията на API на Python не се занимава с обвързването на идентификаторите, а само с литералите. Psycopg е единственият популярен адаптер, който добави възможността за безопасно композиране на SQL както с литерали, така и с идентификатори. Този факт прави още по-важно да се обръща голямо внимание при обвързването на идентификатори.

Изпълнете функцията, за да се уверите, че работи:

>>>
>>> count_rows('users', 1)
1
>>> count_rows('users', 10)
2

Сега, когато видите, че функцията работи, уверете се, че е и безопасна:

>>>
>>> count_rows("(select 1) as foo; update users set admin = true where name = 'haki'; --", 1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 18, in count_rows
psycopg2.errors.UndefinedTable: relation "(select 1) as foo; update users set admin = true where name = '" does not exist
LINE 8:                     "(select 1) as foo; update users set adm...
                            ^

Това проследяване показва, че psycopg избяга стойността и базата данни я третира като име на таблица. Тъй като таблица с това име не съществува, UndefinedTable беше повдигнато изключение и не сте били хакнати!



Заключение

Успешно внедрихте функция, която съставя динамичен SQL без излагате вашата система на риск от инжектиране на Python SQL! Използвали сте и литерали, и идентификатори в заявката си, без да компрометирате сигурността.

Научихте:

  • Какво Python SQL инжекция е и как може да се експлоатира
  • Как да предотвратя инжектирането на Python SQL използване на параметри на заявката
  • Как да безопасно съставяте SQL изрази които използват литерали и идентификатори като параметри

Вече можете да създавате програми, които могат да издържат на атаки отвън. Вървете напред и осуетете хакерите!



  1. Database
  2.   
  3. Mysql
  4.   
  5. Oracle
  6.   
  7. Sqlserver
  8.   
  9. PostgreSQL
  10.   
  11. Access
  12.   
  13. SQLite
  14.   
  15. MariaDB
  1. Оттеглени функции, които да извадите от кутията си с инструменти – част 3

  2. Използване на Salesforce SOQL от Linux

  3. Методи за автоматизация на Azure

  4. Решения за предизвикателство за генератор на числови серии – част 4

  5. SQL ТАБЛИЦА