Предотвращение атак SQL-инъекций с помощью Python

Оглавление

Каждые несколько лет проект Open Web Application Security Project (OWASP) составляет рейтинг наиболее критичных рисков для безопасности веб-приложений. Начиная с первого отчета, риски внедрения всегда были на первом месте. Среди всех типов инъекций SQL-инъекция является одним из наиболее распространенных способов атаки и, возможно, самым опасным. Поскольку Python является одним из самых популярных языков программирования в мире, знание того, как защитить Python от SQL-инъекций, имеет решающее значение.

В этом уроке вы узнаете:

  • Что такое SQL-инъекция в Python и как ее предотвратить
  • Как составлять запросы с использованием как литералов, так и идентификаторов в качестве параметров
  • Как безопасно выполнять запросы в базе данных

Это руководство предназначено для пользователей всех движков баз данных. В приведенных здесь примерах используется PostgreSQL, но результаты могут быть воспроизведены в других системах управления базами данных (таких как SQLite, MySQL, Microsoft SQL Server, Oracle и так далее включен).

Понимание SQL-инъекции в Python

Атаки с использованием SQL-инъекций являются настолько распространенной уязвимостью системы безопасности, что легендарный веб-комик xkcd посвятил ей целый комикс:

A humorous webcomic by xkcd about the potential effect of SQL injection" Подвиги мамы" (Изображение: xkcd)

Генерация и выполнение SQL-запросов является обычной задачей. Однако компании по всему миру часто допускают ужасные ошибки, когда дело доходит до составления SQL-инструкций. Хотя уровень ORM обычно создает SQL-запросы, иногда вам приходится писать свои собственные.

Когда вы используете Python для выполнения этих запросов непосредственно к базе данных, есть вероятность, что вы можете допустить ошибки, которые могут поставить под угрозу вашу систему. В этом руководстве вы узнаете, как успешно реализовать функции, которые составляют динамические SQL-запросы , не подвергая вашу систему риску внедрения Python SQL.

Настройка базы данных

Для начала вам нужно настроить новую базу данных PostgreSQL и заполнить ее данными. На протяжении всего урока вы будете использовать эту базу данных, чтобы воочию убедиться в том, как работает внедрение SQL в Python.

Создание базы данных

Сначала откройте свою оболочку и создайте новую базу данных 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: учебник для начинающих.

Создайте свою виртуальную среду в новом каталоге:

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

После выполнения этой команды будет создан новый каталог с именем venv. В этом каталоге будут храниться все пакеты, которые вы устанавливаете в виртуальной среде.

Подключение к базе данных

Для подключения к базе данных на Python вам понадобится адаптер базы данных. Большинство адаптеров баз данных соответствуют версии 2.0 спецификации API базы данных Python PEP 249. У каждого основного ядра базы данных есть ведущий адаптер:

Database Adapter
PostgreSQL Psycopg
SQLite sqlite3
Oracle 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 и инструкцией “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

Отлично! Функция теперь может обрабатывать и несуществующие имена пользователей.

Использование параметров запроса с помощью SQL-инъекции Python

В предыдущем примере вы использовали интерполяцию строк для создания запроса. Затем вы выполнили запрос и отправили полученную строку непосредственно в базу данных. Однако есть кое-что, что вы, возможно, упустили из виду во время этого процесса.

Вспомните аргумент username, который вы передали в is_admin(). Что именно представляет эта переменная? Вы можете предположить, что username - это просто строка, представляющая реальное имя пользователя. Однако, как вы сейчас увидите, злоумышленник может легко воспользоваться такого рода оплошностью и нанести серьезный ущерб, выполнив SQL-инъекцию на Python.

Попробуйте проверить, является ли следующий пользователь администратором или нет:

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

Подождите… Что только что произошло?

Давайте еще раз взглянем на реализацию. Распечатайте фактический запрос, выполняемый в базе данных:

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

Результирующий текст содержит три утверждения. Чтобы точно понять, как работает SQL-инъекция в Python, вам необходимо изучить каждую часть в отдельности. Первое утверждение выглядит следующим образом:

select admin from users where username = '';

Это ваш предполагаемый запрос. Точка с запятой (;) завершает запрос, поэтому результат этого запроса не имеет значения. Далее следует второе утверждение:

select true;

Этот оператор был создан злоумышленником. Он предназначен для того, чтобы всегда возвращать True.

Наконец, вы видите этот короткий фрагмент кода:

--'

Этот фрагмент кода устраняет все, что следует за ним. Злоумышленник добавил символ комментария (--), чтобы превратить все, что вы могли поместить после последнего заполнителя, в комментарий.

Когда вы выполняете функцию с этим аргументом, она всегда будет возвращать True. Если, например, вы используете эту функцию на своей странице входа в систему, злоумышленник может войти в систему под именем пользователя '; select true; --, и ему будет предоставлен доступ.

Если вы считаете, что это плохо, то может быть еще хуже! Злоумышленники, знакомые со структурой вашей таблицы, могут использовать SQL-инъекцию Python, чтобы нанести непоправимый ущерб. Например, злоумышленник может ввести инструкцию update для изменения информации в базе данных:

>>> 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. Ключ к предотвращению внедрения SQL на Python заключается в том, чтобы убедиться, что значение используется так, как задумано разработчиком. В предыдущем примере вы предполагали, что username будет использоваться как строка. На самом деле, это было использовано как необработанный оператор SQL.

Чтобы убедиться, что значения используются по назначению, вам нужно экранировать значение. Например, чтобы злоумышленники не могли вставлять необработанный SQL вместо строкового аргумента, вы можете использовать кавычки:

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

Это всего лишь один пример. Существует множество специальных символов и сценариев, о которых следует подумать, пытаясь предотвратить внедрение SQL в Python. К счастью для вас, современные адаптеры баз данных оснащены встроенными инструментами для предотвращения внедрения SQL на Python с помощью параметров запроса. Они используются вместо обычной строковой интерполяции для составления запроса с параметрами.

Примечание: В разных адаптерах, базах данных и языках программирования параметры запроса называются по-разному. К общим именам относятся переменных привязки, переменные-заменители и переменных-заменителей.

Теперь, когда вы лучше понимаете уязвимость, вы готовы переписать функцию, используя параметры запроса вместо интерполяции строк:

 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 как строку и экранировало любые символы, которые могли бы завершить строку и привести к внедрению SQL-кода на Python.

Передача безопасных параметров запроса

Адаптеры баз данных обычно предлагают несколько способов передачи параметров запроса. Именованные заполнители обычно лучше всего подходят для удобства чтения, но в некоторых реализациях может быть полезно использовать другие варианты.

Давайте кратко рассмотрим некоторые правильные и неправильные способы использования параметров запроса. В следующем блоке кода показаны типы запросов, которых вам следует избегать:

# 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-кода на Python.

Напротив, выполнение этих типов запросов должно быть безопасным для вас:

# 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 на Python.

Использование 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. На следующих шагах вы будете использовать это исключение как указание на то, что ваша функция защищена от атаки с использованием SQL-инъекций Python.

Примечание: Исключение 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 без риска для вашей системы из-за внедрения SQL на Python! Вы использовали в своем запросе как литералы, так и идентификаторы без ущерба для безопасности.

Ты научился:

  • Что такое SQL-инъекция Python и как ее можно использовать
  • Как предотвратить внедрение SQL на Python с помощью параметров запроса
  • Как безопасно составлять инструкции SQL, которые используют литералы и идентификаторы в качестве параметров

Теперь вы можете создавать программы, способные противостоять атакам извне. Действуйте и дайте отпор хакерам!

Back to Top