はじめに
ユーザーが指定した名前は、データベースコマンドの一部になるのではなく、値のままであるべきです。この DeskLite の検索処理は現在、名前から直接 SQL テキストを組み立てています。通常のリクエストは動作するように見えますが、引用符を含む名前や、特別な形の入力によって問題が明らかになります。
このラボでは、わずか 5 行の Python 関数を読み取り、そこで使われている少量の SQL を学び、実際の Semgrep の検出結果をたどって、SQLite のパラメーターバインドのパターンを 1 つ適用します。Python や SQL の事前知識は不要です。実行用プログラム、データベース、スキャナー、機能チェックは用意されています。
通常の検索を実行する
このステップでは、実装を読む前に、検索が何を返すべきかを確認します。
DeskLite は、小さな SQLite データベースに 3 件の架空のチケットを保存しています。SQLite は、ローカルファイルを読み取り、問い合わせるデータベースエンジンです。接続とデータは、このコースですでに用意されています。
cd /home/labex/project/ticket-queries
run_lookup.py は、用意されている実行の入口です。依存関係のラボと同様に、python3 で提供されたプログラムを実行します。最後の引数は検索する名前です。
python3 run_lookup.py Alice
ID が 1、タイトルが Printer offline のチケットが 1 件返るはずです。実行用プログラムは、データベースの行を見慣れた JSON レスポンスに整形します。編集するのは小さな検索関数であり、実行用プログラムの整形コードではありません。
意図する境界は単純です。指定した名前によって、その所有者のチケットだけを選びます。データベースコマンドの意味が変わったり、他の所有者のチケットが見えたりしてはいけません。この演習の名前とレコードはすべて架空のものです。用意されたローカルデータベースだけを対象にしてください。
5 行の関数を読み取る
このステップでは、指定した名前が小さな Python 関数を通る流れをたどります。
cat lookup.py
ファイルの内容は次のとおりです。
from fixture import db
def lookup(name):
query = f"SELECT id, title FROM tickets WHERE owner = '{name}'"
return db.execute(query).fetchall()
from fixture import db は、用意されたデータベース接続を db という名前で使えるようにします。関数は、名前を付けた再利用可能な処理です。def lookup(name): は、入力を name と呼ぶ関数を定義します。字下げされた行はこの関数に属します。編集するときも、その字下げを維持してください。
変数は、値に名前を付けるものです。query = ... は、文字列と呼ばれるテキストの値を query に代入します。先頭の f により、これは f 文字列になります。Python は {name} の位置に、その時点の name の値を挿入します。入力が Alice なら、そのテキストが SQL 文字列の一部になります。
db.execute(query) は、接続にクエリの実行を依頼する、関数に似たメソッド呼び出しです。.fetchall() は結果の行を集めます。return は、その行を呼び出し元に返します。用意された実行用プログラムは、ターミナルの引数を lookup に渡し、返された行を表示します。
必要なのは、指定した名前 → クエリ文字列 → データベース呼び出し → 返される行、というデータの流れだけです。次に、その SQL 文字列の意味を読み取ります。
意図した SQL 条件を読み取る
このステップでは、SQL の条件と、返されるべきレコードを関連付けます。
SQL は、データベースの操作を依頼する言語です。このクエリでは、その一部だけを使っています。
SELECT id, title FROM tickets WHERE owner = 'Alice'
SELECT id, title は、結果の 2 つのフィールドを選びます。FROM tickets は、用意されたテーブルを選びます。WHERE owner = 'Alice' は、所有者がそのテキスト値と等しい行だけに結果を絞ります。SQL では、一重引用符が文字列リテラルを示します。この SQL 文字列内の = は、所有者の条件を表します。文字列の外にある Python の query = ... という代入は、完成したテキストに変数名を付けます。
別の通常の名前も試します。
python3 run_lookup.py Bob
タイトルが Spare keyboard の、ID 3 のチケットだけが返るはずです。名前によって変わるのは、所有者の条件に指定する値であり、選択する列やテーブルではありません。
これを、先ほど読んだ f 文字列と結び付けてみましょう。SQLite がクエリを受け取る前に、Python は指定された名前を SQL の引用符の間に挿入します。これにより、コマンドの構造と入力データという、異なる 2 つのものが混ざっています。実際の名前に含まれる引用符が、その境界を乱す場合があります。
まだ編集しないでください。まず固定スキャナーで危険な組み立て方の場所を確認し、その後、引用符を含む正当な名前と、許可されたローカルのテスト入力で動作を確認します。
静的解析の検出結果をたどる
このステップでは、実際の Semgrep の検出結果を読み取り、その場所をクエリの組み立て処理に結び付けます。
静的アプリケーションセキュリティテスト(SAST)は、考えられるすべての入力を実行せずにコードを調べます。用意された course-code-scan ランチャーは、バージョン固定された Semgrep CE 1.145.0 エンジンと、固定されたローカルルールを使います。クラウドアカウントやオンラインのルールセットは使いません。
course-code-scan lookup.py
lookup.py:5 に検出結果があり、ルール ID desklite.sql-parameter-binding と、次の説明が表示されるはずです。
Bind the supplied lookup name as a SQL parameter instead of building SQL text from it.
この行は、データベース呼び出しで query を使っている場所を示します。その値を 1 行上にたどると、f 文字列を組み立てた場所が分かります。これは短いデータフローの調査であり、アプリケーション全体を開発する課題ではありません。
検出があれば終了コードは 1 です。ファイルを解析できない場合は CHECK ERROR となり、終了コードは 2 です。どちらも問題なしという結果ではありません。このローカルルールは、このコースで渡される name が SQL テキストに入る流れを追跡します。その範囲は限定的です。検出結果にはコードレビューが必要であり、1 つのルールで検出がなくても、アプリケーションに他のリスクがないとは証明できません。
次に、検出結果と実際の検索動作を比較します。
引用符を含む文字列とクエリを変える入力を比較する
このステップでは、用意されたローカルのテスト環境だけを使い、入力をデータのまま扱う必要がある理由を観察します。
正当な所有者の 1 人は O'Neil という名前です。二重引用符によって、アポストロフィを含むターミナル引数全体をひとまとまりにします。
python3 run_lookup.py "O'Neil"
Lookup failed: で始まり、SQLite の構文エラーを含む行が表示されるはずです。この失敗は、欠陥を示す意図した証拠です。プログラムが名前をコマンドテキストに直接挿入したため、名前のアポストロフィが SQL の文字列リテラルを終わらせてしまいました。すべてのアポストロフィを拒否すると、実在しうる所有者の検索が壊れます。
次に、対象を限定した次のテスト入力を試します。
python3 run_lookup.py "' OR 1=1 --"
安全でない版では、所有者に一致する結果がない状態ではなく、3 件のチケットすべてが返されます。この SQL の文脈では、引用符がリテラルを閉じ、OR 1=1 が条件を真にし、-- が残りのテキストをコメントにします。入力は名前のままである代わりに、コマンドの構造を変えてしまいました。これが SQL インジェクションです。
外部サービスをテストしたり、データを変更したりしているわけではありません。用意されたデータベースは読み取り専用で、架空のレコードを含みます。必要な修正は、引用符を含む正当な名前が動作し、クエリを変えるような文字列では返す行を増やせない、という両方の観察結果を満たす必要があります。
名前を SQL テキストとは別にバインドする
このステップでは、SQLite のパラメーターバインドのインターフェースを使って検索を修正します。
プレースホルダーは、データの値が入る場所を示します。SQLite の ? プレースホルダーは固定された SQL 構造の一部であり、名前は execute に別途渡します。データベースドライバーは、渡された値に引用符や SQL のような文字が含まれていても、データとして扱います。
nano lookup.py
インポート、関数名、字下げを維持しながら、関数のクエリ行と実行行を、次の対応済みのパターンに置き換えます。Ctrl+O、Enter、Ctrl+X で保存して終了します。
from fixture import db
def lookup(name):
query = "SELECT id, title FROM tickets WHERE owner = ?"
return db.execute(query, (name,)).fetchall()
? の周りに SQL の引用符を付けないでください。値の表現はドライバーが処理します。(name,) は、値を順序付きでまとめるタプルで、要素が 1 つです。末尾のカンマによってタプルとなり、その 1 つの値が 1 つのプレースホルダーに対応します。execute の第 2 引数は、そのタプルをクエリテキストとは別に渡します。
通常の検索を実行します。
python3 run_lookup.py Alice
次に、引用符を含む正当な名前を再び試します。
python3 run_lookup.py "O'Neil"
Alice には ID 1 のチケットが、O'Neil にはタイトルが Login issue の ID 2 のチケットが返るはずです。どちらも同じ固定された SQL 構造を使います。Python が字下げや構文のエラーを報告する場合は、上記の短い関数を復元してください。データベースや実行用プログラムは変更しないでください。
拒否と正当な動作の両方を検証する
このステップでは、修正によって有効な名前を維持しつつ、指定した文字列が検索範囲を広げるのを防いだことを確認します。
クエリを変える入力を繰り返します。
python3 run_lookup.py "' OR 1=1 --"
今度は空の tickets リストが返るはずです。SQLite は、その文字列そのものを名前に持つ所有者を探しますが、そのような所有者はいません。クエリ構造は何も追加されませんでした。
通常の、存在しない所有者も確認します。
python3 run_lookup.py "Missing owner"
これも空のリストになるはずです。値が存在しないことと、アプリケーションが壊れていることは異なります。通常の名前と引用符を含む正当な名前では、引き続きそれぞれのチケットが返ります。
依存関係のラボと同じ実行方法で、提供された機能回帰チェックを実行します。
bash test.sh
次のように表示されるはずです。
PASS: ordinary, quoted, missing, and injection-like names.
これらのチェックは、実際の SQLite 呼び出しと結果の行を確認します。関数の削除、すべての入力に空リストを返す処理、データの削除では、正当な結果を維持できません。文字のブラックリストも不十分です。正当な所有者の名前にアポストロフィが含まれています。意図する防御は、固定された SQL テキストと、バインドする値の分離です。
修正したソースを再スキャンする
このステップでは、静的解析の結果と、直前にテストした実際の検索動作を組み合わせます。
course-code-scan lookup.py
No findings under the fixed local rule. と表示されるはずです。渡された名前は、もう SQL コマンド文字列に流れ込みません。別のバインド値として渡されます。
機能チェックをもう一度実行します。
bash test.sh
同じ合格結果になるはずです。スキャンと動作テストは、異なる問いに答えます。一方は対象のコードパターンを調べ、もう一方は正当な名前が引き続き使え、特殊な入力で結果を増やせないことを確認します。
コースの検証では、実際の SQLite の引数も観察します。そのため、単にスキャナーの検出を隠したり、引用符の文字を手動で置き換えたりするのは、必要な修正ではありません。元のデータベース、テスト用環境、実行用プログラム、関数のインターフェースを維持してください。調査ファイルの作成は不要です。
このラボでは、小さな SQLite バインドのパターンを 1 つと、ローカルの SAST ルールを 1 つ学びました。アプリケーション全体のレビューでは、他の入力元や脆弱性の種類も検討します。ここでは、意図した検索を維持しながら、入力からクエリへの特定のリスクを取り除きました。
まとめ
指定した名前が短い Python の検索処理と SQL 条件を通る流れをたどり、実際のローカル Semgrep の検出結果を実行時の動作と関連付け、SQLite のパラメーターバインドを使いました。通常の名前と引用符を含む名前は引き続き動作し、クエリを変える入力では結果を増やせなくなりました。静的解析と機能の証拠によって、修正した特定の境界を裏付けています。


