使用 Python 防止 SQL 注入攻击

举报
Yuchuan 发表于 2021/12/29 21:50:11 2021/12/29
【摘要】 您已经成功实现了一个组合动态 SQL 的函数,而不会使您的系统面临 Python SQL 注入的风险!您在查询中同时使用了文字和标识符,而没有影响安全性。

目录

每隔几年,开放 Web 应用程序安全项目 (OWASP) 就会对最关键的Web 应用程序安全风险进行排名。自第一份报告以来,注入风险一直居于首位。在所有注入类型中,SQL 注入是最常见的攻击媒介之一,可以说是最危险的。由于 Python 是世界上最流行的编程语言之一,因此了解如何防止 Python SQL 注入至关重要。

在本教程中,您将学习:

  • 什么是Python SQL 注入以及如何防止它
  • 如何使用文字和标识符作为参数组合查询
  • 如何安全地在数据库中执行查询

本教程适用于所有数据库引擎的用户。这里的例子使用的是 PostgreSQL,但结果可以在其他数据库管理系统(如SQLiteMySQL、 Microsoft SQL Server 、 Oracle 等)中重现。

了解 Python SQL 注入

SQL注入攻击是一个如此常见的安全漏洞,传奇的xkcd网络漫画专门为它写了一个漫画:

xkcd关于SQL注入潜在影响的幽默网络漫画

生成和执行SQL 查询是一项常见任务。然而,世界各地的公司在编写 SQL 语句时经常犯可怕的错误。虽然ORM 层通常组成 SQL 查询,但有时您必须自己编写。

当您使用 Python 直接在数据库中执行这些查询时,您可能会犯可能危及系统的错误。在本教程中,您将学习如何成功实现组成动态 SQL 查询的函数,而不会使您的系统面临 Python SQL 注入的风险。

设置数据库

首先,您将设置一个新的 PostgreSQL 数据库并用数据填充它。在整个教程中,您将使用该数据库亲眼目睹 Python SQL 注入的工作原理。

创建数据库

首先,打开你的 shell 并创建一个由用户拥有的新 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作为 user连接到数据库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)

该表有两列:usernameadmin。该admin列指示用户是否具有管理权限。您的目标是瞄准该admin领域并尝试滥用它。

设置 Python 虚拟环境

现在您有了一个数据库,是时候设置您的 Python 环境了。有关如何执行此操作的分步说明,请查看Python 虚拟环境:入门

在新目录中创建虚拟环境:

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

运行此命令后,venv将创建一个名为的新目录。该目录将存储您在虚拟环境中安装的所有软件包。

连接到数据库

要在 Python 中连接到数据库,您需要一个数据库适配器。大多数数据库适配器遵循 Python 数据库 API 规范PEP 249 的2.0 版。每个主要的数据库引擎都有一个领先的适配器:

Database Adapter
PostgreSQL Psycopg
SQLite sqlite3
Oracle cx_oracle
MySql MySQLdb

要连接到 PostgreSQL 数据库,您需要安装Psycopg,它是 Python 中最流行的 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

现在您已准备好创建与数据库的连接。这是您的 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是具有数据库权限的用户。在这种情况下,您希望以所有者身份连接到数据库,因此您传递了 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 中使用查询参数

在上一节中,您创建了一个数据库,建立了一个连接,并执行了一个查询。您使用的查询是static。换句话说,它没有参数。现在您将开始在查询中使用参数。

首先,您将实现一个检查用户是否为管理员的函数。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

当用户不存在时,aTypeError被引发。这是因为在未找到结果时.fetchone()返回None,并且解包None会引发TypeError. 唯一可以解包元组的地方是adminresult.

要处理不存在的用户,请为 when resultis创建一个特殊情况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。例如,如果您在登录页面中使用此功能,入侵者可以使用 username 登录'; 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';

本节更新admintrue用户haki

最后,有这个代码片段:

select true; --

与前面的示例一样,这一段返回true并注释掉它后面的所有内容。

为什么这更糟?好吧,如果入侵者设法使用此输入执行该功能,那么用户haki将成为管理员:

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

入侵者不再需要使用 hack。他们只需使用用户名登录即可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()注释参数 value 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()

注意:不幸的是,Python API 规范没有解决标识符的绑定问题,只有文字。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 语句

您现在可以创建可以抵御外部攻击的程序。前进并挫败黑客!

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。