数年ごとに、Open Web Application Security Project(OWASP)は、最も重大なWebアプリケーションセキュリティリスクをランク付けします。最初の報告以来、注射のリスクは常にトップでした。すべてのインジェクションタイプの中で、SQLインジェクション は最も一般的な攻撃ベクトルの1つであり、間違いなく最も危険です。 Pythonは世界で最も人気のあるプログラミング言語の1つであるため、PythonSQLインジェクションから保護する方法を知ることは重要です。
このチュートリアルでは、次のことを学びます。
- PythonSQLインジェクション とそれを防ぐ方法
- クエリを作成する方法 パラメータとしてリテラルと識別子の両方を使用
- クエリを安全に実行する方法 データベース内
このチュートリアルは、すべてのデータベースエンジンのユーザーに適しています。 。ここでの例ではPostgreSQLを使用していますが、結果は他のデータベース管理システム(SQLite、MySQL、Microsoft SQL Server、Oracleなど)で再現できます。
PythonSQLインジェクションについて
SQLインジェクション攻撃は非常に一般的なセキュリティの脆弱性であるため、伝説的な xkcd ウェブコミックはそれにコミックを捧げました:
SQLクエリの生成と実行は一般的なタスクです。ただし、SQLステートメントの作成に関しては、世界中の企業がひどい間違いを犯すことがよくあります。 ORMレイヤーは通常SQLクエリを作成しますが、独自に作成する必要がある場合もあります。
Pythonを使用してこれらのクエリをデータベースに直接実行すると、システムを危険にさらす可能性のある間違いを犯す可能性があります。このチュートリアルでは、動的SQLクエリをなしで作成する関数を正常に実装する方法を学習します。 システムをPythonSQLインジェクションのリスクにさらします。
データベースのセットアップ
開始するには、新しいPostgreSQLデータベースをセットアップし、データを入力します。チュートリアル全体を通して、このデータベースを使用して、PythonSQLインジェクションがどのように機能するかを直接確認します。
データベースの作成
まず、シェルを開き、ユーザーpostgres
が所有する新しいPostgreSQLデータベースを作成します。 :
$ 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)
テーブルには2つの列があります:username
およびadmin
。 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に準拠しています。すべての主要なデータベースエンジンには、主要なアダプタがあります。
データベース | アダプター |
---|---|
PostgreSQL | Psycopg |
SQLite | sqlite3 |
Oracle | cx_oracle |
MySQL | MySQLdb |
PostgreSQLデータベースに接続するには、PythonでPostgreSQL用に最も一般的なアダプタであるPsycopgをインストールする必要があります。 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()
を実行しました。 タプルを受け取りました。クエリは1つの結果しか返すことができないため、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
素晴らしい!この関数は、存在しないユーザー名も処理できるようになりました。
PythonSQLインジェクションを使用したクエリパラメータの活用
前の例では、文字列補間を使用してクエリを生成しました。次に、クエリを実行し、結果の文字列をデータベースに直接送信しました。ただし、このプロセス中に見落とした可能性のあることがあります。
username
を思い出してください is_admin()
に渡した引数 。この変数は正確に何を表していますか? username
実際のユーザーの名前を表す単なる文字列です。ただし、これから説明するように、侵入者はこの種の監視を簡単に悪用し、PythonSQLインジェクションを実行することで大きな害を及ぼす可能性があります。
次のユーザーが管理者であるかどうかを確認してください:
>>>>>> is_admin("'; select true; --")
True
待って…どうしたの?
実装をもう一度見てみましょう。データベースで実行されている実際のクエリを印刷します:
>>>>>> print("select admin from users where username = '%s'" % "'; select true; --")
select admin from users where username = ''; select true; --'
結果のテキストには、3つのステートメントが含まれています。 Python SQLインジェクションがどのように機能するかを正確に理解するには、各部分を個別に検査する必要があります。最初のステートメントは次のとおりです。
select admin from users where username = '';
これは意図したクエリです。セミコロン(;
)クエリを終了するため、このクエリの結果は重要ではありません。次は2番目のステートメントです:
select true;
この声明は侵入者によって作成されました。常にTrue
を返すように設計されています 。
最後に、次の短いコードが表示されます:
--'
このスニペットは、その後に続くものをすべて解読します。侵入者はコメント記号を追加しました(--
)最後のプレースホルダーの後に入力した可能性のあるすべてのものをコメントに変換します。
この引数を使用して関数を実行すると、常にTrue
が返されます。 。たとえば、ログインページでこの機能を使用すると、侵入者はユーザー名'; select true; --
、アクセスが許可されます。
これが悪いと思うなら、悪化する可能性があります!テーブル構造の知識を持つ侵入者は、PythonSQLインジェクションを使用して永続的な損傷を引き起こす可能性があります。たとえば、侵入者は更新ステートメントを挿入して、データベース内の情報を変更できます。
>>>>>> 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インジェクションを防止しようとするときに、考慮すべき特殊文字やシナリオがたくさんあります。幸運なことに、最新のデータベースアダプターには、クエリパラメーターを使用してPythonSQLインジェクションを防止するための組み込みツールが付属しています。 。これらは、パラメータを使用してクエリを作成するために、単純な文字列補間の代わりに使用されます。
注: さまざまなアダプタ、データベース、およびプログラミング言語が、さまざまな名前でクエリパラメータを参照します。一般的な名前には、バインド変数が含まれます 、置換変数 、および置換変数 。
脆弱性についての理解が深まったので、文字列補間の代わりにクエリパラメータを使用して関数を書き直す準備ができました:
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()
の2番目の引数として 。接続では、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
の値を処理しました 文字列として、文字列を終了してPythonSQLインジェクションを導入する可能性のある文字をエスケープしました。
安全なクエリパラメータの受け渡し
データベースアダプタは通常、クエリパラメータを渡すためのいくつかの方法を提供します。 名前付きプレースホルダー 通常は読みやすさの点で最適ですが、一部の実装では他のオプションを使用することでメリットが得られる場合があります。
クエリパラメータを使用する正しい方法と間違った方法のいくつかを簡単に見てみましょう。次のコードブロックは、避けたいクエリの種類を示しています。
# 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
を渡します いかなる種類のチェックや検証も実行せずに、クライアントからデータベースに直接送信します。この種のコードは、PythonSQLインジェクションを招待するのに適しています。
対照的に、これらのタイプのクエリは安全に実行できるはずです。
# 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
を使用します クエリを実行するとき、PythonSQLインジェクションからの保護を提供します。
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
この実装には2つの違いがあります。まず、sql.SQL()
を使用しました クエリを作成します。次に、sql.Identifier()
を使用しました 引数の値に注釈を付けるtable_name
。 (識別子 列またはテーブルの名前です。)
注: 人気のあるパッケージdjango-debug-toolbar
のユーザー psycopg.sql.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
をスローします 例外。次の手順では、この例外を、関数がPythonSQLインジェクション攻撃から安全であることを示すものとして使用します。
注: 例外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を構成する関数を正常に実装しました システムをPythonSQLインジェクションのリスクにさらします!セキュリティを損なうことなく、クエリでリテラルと識別子の両方を使用しました。
学んだこと:
- PythonSQLインジェクション であり、それをどのように活用できるか
- PythonSQLインジェクションを防ぐ方法 クエリパラメータの使用
- SQLステートメントを安全に作成する方法 パラメータとしてリテラルと識別子を使用する
これで、外部からの攻撃に耐えられるプログラムを作成できるようになりました。出て行ってハッカーを阻止してください!