geekdoc-python-zh/docs/realpython/prevent-python-sql-injectio...

25 KiB
Raw Blame History

用 Python 防止 SQL 注入攻击

原文:https://realpython.com/prevent-python-sql-injection/

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

在本教程中,你将学习:

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

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

免费奖励: 掌握 Python 的 5 个想法,这是一个面向 Python 开发者的免费课程,向您展示将 Python 技能提升到下一个水平所需的路线图和心态。

了解 Python SQL 注入

SQL 注入攻击是一个如此常见的安全漏洞,以至于传奇人物 xkcd webcomic 专门为此画了一幅漫画:

A humorous webcomic by xkcd about the potential effect of SQL injection

"Exploits of a Mom" (Image: [xkcd](https://xkcd.com/327/))

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

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

Remove ads

建立数据库

首先,您将建立一个新的 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.

现在,您以用户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)

该表有两列:usernameadminadmin列表示用户是否拥有管理权限。你的目标是针对admin领域,并试图滥用它。

设置 Python 虚拟环境

现在您已经有了一个数据库,是时候设置您的 Python 环境了。关于如何做到这一点的分步说明,请查看 Python 虚拟环境:初级教程

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

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

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

连接到数据库

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

数据库ˌ资料库 适配器
一种数据库系统 心理战
SQLite sqlite3
神谕 cx_oracle
关系型数据库 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 是对数据库有权限的用户。在这种情况下,您希望作为所有者连接到数据库,因此您传递了用户postgres

  • password 是您在user中指定的任何人的密码。在大多数开发环境中,用户无需密码就可以连接到本地数据库。

建立连接后,您用autocommit=True配置了会话。激活autocommit意味着您不必通过发出commitrollback来手动管理交易。这是大多数 ORM 的默认 行为。这里也使用这种行为,这样您就可以专注于编写 SQL 查询,而不是管理事务。

注意: Django 用户可以从 django.db.connection 获取 ORM 使用的连接实例:

from django.db import connection

Remove ads

执行查询

现在您已经连接到数据库,可以执行查询了:

>>> 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。唯一可以解包元组的地方是从result填充admin的地方。

为了处理不存在的用户,当resultNone时创建一个特例:

# 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

太好了!该功能现在也可以处理不存在的用户名。

Remove ads

利用 Python SQL 注入开发查询参数

在前面的例子中,您使用了字符串插值来生成一个查询。然后,执行查询并将结果字符串直接发送到数据库。然而,在这个过程中你可能忽略了一些东西。

回想一下您传递给is_admin()username参数。这个变量到底代表什么?你可能会认为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';

该部分为用户hakiadmin更新为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论点了解多少?您知道它应该是一个表示用户名的字符串,但是您实际上并不检查或强制执行这个断言。这可能很危险!这正是攻击者试图入侵您的系统时所寻找的。

Remove ads

精心制作安全查询参数

在上一节中,您看到了入侵者如何通过使用精心编制的字符串来利用您的系统并获得管理员权限。问题是您允许从客户端传递的值直接执行到数据库,而不执行任何检查或验证。 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 注入。

Remove ads

使用 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是在psycopg 2 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

在这个代码块中,您使用sql.Literal()limit进行了注释。和前面的例子一样,在使用简单方法时,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异常,您没有被攻击!

Remove ads

结论

您已经成功实现了一个组成动态 SQL 的函数,而没有让您的系统面临 Python SQL 注入的风险!您在查询中使用了文字和标识符,而没有损害安全性。

你已经学会:

  • 什么是 Python SQL 注入以及如何利用它
  • 如何防止 Python SQL 注入使用查询参数
  • 如何安全地编写使用文字和标识符作为参数的 SQL 语句

您现在能够创建能够抵御外部攻击的程序。前进,挫败黑客!******