На всеки няколко години Проектът за сигурност на отворените уеб приложения (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 изрази които използват литерали и идентификатори като параметри
Вече можете да създавате програми, които могат да издържат на атаки отвън. Вървете напред и осуетете хакерите!