使用Python防止SQL注入攻擊

文章背景

  每隔幾年,開放式Web應用程序安全項目就會對最關鍵的Web應用程序安全風險進行排名。自第一次報告以來,注入風險高居其位!在所有注入類型中,SQL注入是最常見的攻擊手段之一,而且是最危險的。由於Python是世界上最流行的編程語言之一,因此瞭解如何防止Python SQL注入對於我們來說還是比較重要的

那麼在寫這篇文章的時候我也是查詢了國內外很多資料,最後帶着問題去完善總結:

  • 什麼是Python SQL注入以及如何防止注入
  • 如何使用文字和標識符作爲參數組合查詢
  • 如何安全地執行數據庫中的查詢

文章演示的操作適用於所有數據庫,這裏的示例使用的是PG,但是效果跟過程可以在其他數據庫(例如SQLite,MySQL,Oracle等等系統中)重現

1. 瞭解Python SQL注入

  SQL注入攻擊是一種常見的安全漏洞。在我們日常工作中生成和執行SQL查詢也同樣是一項常見的任務。但是,有時候在編寫SQL語句時常常會犯下可怕錯誤

  當我們使用Python將這些查詢直接執行到數據庫中時,很可能會損害到系統。所以如何成功實現組成動態SQL查詢的函數,而又不會使系統遭受Python SQL注入的威脅呢?
在這裏插入圖片描述

2. 設置數據庫

  首先,建立一個新的PostgreSQL數據庫並用數據填充它。在文章中,將使用該數據庫直接見證Python SQL注入的工作方式及基本操作

2.1 創建數據庫

打開你的shell工具並創建一個用戶擁有的新PostgreSQL數據庫:

$ createdb -O postgres psycopgtest

在這裏,使用了命令行選項-O將數據庫的所有者設置爲用戶postgres。還指定了數據庫的名稱psycopgtest

postgres是一個特殊用戶,通常將保留該用戶用於管理任務,但是對於本文章而言,可以使用postgres。但是,在實際系統中,應該創建一個單獨的用戶作爲數據庫的所有者

新數據庫已準備就緒!現在我們連接它:

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

現在,可以看到以psycopgtest用戶身份連接到數據庫postgres。該用戶也是數據庫所有者,因此將具有數據庫中每個表的讀取權限

2.2 構造數據創建表

這裏我們需要創建一個包含一些用戶信息的表,並向其中添加一些數據:

psycopgtest=# CREATE TABLE users (
    username varchar(30),
    admin boolean
);
CREATE TABLE

psycopgtest=# INSERT INTO users
    (username, admin)
VALUES
    ('zhangsan', true),
    ('lisi', false);
INSERT 0 2

psycopgtest=# SELECT * FROM users;
 username | admin
----------+-------
 zhangsan      | t
 lisi     | f
(2 rows)

我們添加了username和admin兩個列。該admin列指示用戶是否具有管理特權。我們的目標是瞄準該admin領域並嘗試濫用它

2.3 設置Python虛擬環境

現在我們已經有了一個數據庫,是時候設置Python環境。在新目錄中創建虛擬環境:

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

運行此命令後,venv將創建一個名爲的新目錄。該目錄將存儲在虛擬環境中安裝的所有軟件包

2.4 使用Python連接數據庫

  再使用Python連接PostgreSQL數據庫時需要確保我們的環境是否安裝了psycopg2,如果沒有使用pip安裝psycopg2:

pip install psycopg2

安裝完之後,我們編寫創建與數據庫連接的代碼:

import psycopg2

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

psycopg2.connect()函數用來創建與數據庫的連接且接受以下參數:

  • host是數據庫所在服務器的IP地址
  • database是要連接的數據庫的名稱
  • user是具有數據庫權限的用戶
  • password連接數據庫的密碼

我們設置完連接後,使用配置了會話autocommit=True。激活autocommit意味着不必通過發出commit或來手動管理rollback。這是 大多數ORM中的默認 行爲。也可以在這裏使用此行爲,以便可以專注於編寫SQL查詢而不是管理事務

2.5 執行查詢

現在我們已經連接到了數據庫,開始執行我們的查詢:

>>> 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

3. 在SQL中使用查詢參數

  現在我們創建了數據庫並且建立了與數據庫的連接,並執行了查詢。但是我們使用的查詢是靜態的。換句話說,它沒有參數。現在,將開始在查詢中使用參數

首先,將實現一個檢查用戶是否爲管理員的功能。is_admin()接受用戶名並返回該用戶的管理員狀態:

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('lisi')
False
>>> is_admin('zhangsan')
True

到目前爲止,一切都是正常的。該函數返回了兩個用戶的預期結果。但是我們如果查看不存在的用戶呢?看下會怎樣:

>>> is_admin('wangwu')
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

當用戶不存在時可以看到出現了異常,這是因爲如果找不到結果,則.fetchone()返回None,導致引發TypeError

要處理不存在的用戶,我們可以創建一個特例None:

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:
        return False

    admin, = result
    return admin

在這裏,添加了處理的特殊情況None。如果username不存在,則該函數應返回False。再次在某些用戶上測試該功能:

>>> is_admin('lisi')
False
>>> is_admin('zhangsan')
True
>>> is_admin('wangwu')
False

可以發現這個函數現在已經可以處理不存在的用戶名

4. 使用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('lisi')
False
>>> is_admin("'; update users set admin = 'true' where username = 'lisi'; select true; --")
True
>>> is_admin('lisi')
True

讓我們再次分解:

';

就像之前的注入一樣,此代碼段終止了查詢。下一條語句如下:

update users set admin = 'true' where username = 'lisi';

更新admin到true用戶lisi

最後,有以下代碼片段:

select true; --

與前面的示例一樣,該片段返回true並註釋掉其後的所有內容。

如果入侵者設法使用此輸入執行功能,則用戶lisi將成爲管理員:

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

入侵者可以使用用戶名登錄lisi。(如果入侵者確實想破壞,那麼可以使用DROP DATABASE命令)

現在我們恢復lisi的原始狀態:

psycopgtest=# update users set admin = false where username = 'lisi';
UPDATE 1

4.1 製作安全查詢參數

  瞭解了入侵者如何通過使用精心設計的字符串來利用系統並獲得管理員權限。問題是我們允許從客戶端傳遞的值直接執行到數據庫,而無需執行任何類型的檢查或驗證。SQL注入依賴於這種類型的漏洞

  每當在數據庫查詢中使用用戶輸入時,SQL注入就可能存在漏洞。防止Python SQL注入的關鍵是確保該值已按我們開發的預期使用。在上一個示例中,username用作了字符串。實際上,它被用作原始SQL語句

爲了確保我們按預期使用值,需要對值進行轉義。例如,爲防止入侵者將原始SQL替換爲字符串參數,可以對引號進行轉義:

>>> username = username.replace("'", "''")

  這只是一個例子。嘗試防止Python SQL注入時,有很多特殊字符和場景需要考慮。現代的數據庫適配器隨附了一些內置工具,這些工具可通過使用查詢參數來防止Python SQL注入。使用這些參數代替普通字符串插值可組成帶有參數的查詢

現在,我們已經對該漏洞有了一個明確的知曉,可以使用查詢參數而不是字符串插值來重寫該函數:

def is_admin(username: str) -> bool:
    with connection.cursor() as cursor:
        cursor.execute("""
            SELECT
                admin
            FROM
                users
            WHERE
                username = %(username)s
        """, {
            'username': username
        })
        result = cursor.fetchone()

    if result is None:
        return False

    admin, = result
    return admin
  • 我們使用了一個命名參數username來指示用戶名應該去哪裏
  • 將值username作爲第二個參數傳遞給cursor.execute()。username在數據庫中執行查詢時,連接將使用的類型和值

要測試此功能,我們先嚐試一些有效以及無效的值跟一些有隱患的字符串:

>>> is_admin('lisi')
False
>>> is_admin('zhangsan')
True
>>> is_admin('wangwu')
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注入

4.2 傳遞安全查詢參數

  數據庫適配器通常提供幾種傳遞查詢參數的方法。命名佔位符通常是可讀性最好的,但是某些實現可能會受益於使用其他選項

讓我們快速看一下使用查詢參數的一些對與錯方法。以下代碼塊顯示了我們需要避免的查詢類型:

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注入

相比上面,以下類型的查詢可以安全地執行:

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注入的保護

5. 使用SQL組合

但是,如果我們有一個用例需要編寫一個不同的查詢(該參數是其他參數,例如表或列名),該怎麼辦?

繼上一個列子,我們實現一個函數,該函數接受表的名稱並返回該表中的行數:

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(標識符是列或表的名稱)

現在,我們嘗試在users表上執行該函數:

>>> count_rows('users')
2

接下來,讓我們看看錶不存在時會發生什麼:

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

該函數引發UndefinedTable異常。將使用此異常來表明我們的函數可以安全地免受Python SQL注入攻擊

要將所有內容放在一起,添加一個選項以對錶中的行進行計數,直到達到特定限制。對於非常大的表,這個功能很有用。要實現這個操作,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 wangwu; update users set admin = true where name = 'lisi'; --", 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 wangwu; update users set admin = true where name = '" does not exist
LINE 8:                     "(select 1) as wangwu; update users set adm...
                            ^                      

異常顯示psycopg轉義了該值,並且數據庫將其視爲表名。由於不存在具有該名稱的表,因此UndefinedTable引發了異常所以是安全的!

6. 結論

通過實現組成動態SQL,可與你使我們有效的規避系統遭受Python SQL注入的威脅!在查詢過程中同時使用文字和標識符,並不會影響安全性

7. 致謝

  好了,到這裏又到了跟大家說再見的時候了。我只是一個會寫爬蟲的段子手而已,一個希望有朝一日能夠實現財富自由,能夠早日榮歸故里的遊子罷了。希望我的文章能帶給您知識,帶給您幫助,帶給您歡笑!同時也謝謝您能抽出寶貴的時間閱讀,創作不易,如果您喜歡的話,點個贊再走吧。您的支持是我創作的動力,希望今後能帶給大家更多優質的文章

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章