1-5. SQL Injection - 文字列結合の罠を絶対に踏まない

所要時間: 35-50分 ゴール: SQLi の仕組みを攻撃側・防御側両方の視点で理解する。Go で安全なクエリを書ける。ブラインドSQLi、time-based attack まで把握する コミット内容: NG/OK 両方のサンプルコードを ~/learn/security/day05_sqli/ に残す


前章とのつながり

このレッスンの位置づけ

前章 1-4 OWASP Top 10A03 (Injection) の代表選手を深掘りする回。Top 10 で全体地図を眺めた後、開発者が 日常的に直面する 2大脆弱性 SQLi と XSS に深く潜る。

  • 次章 1-6 XSS: 同じ「インジェクション」だが ブラウザ側 で起きる。比較して理解すると一気に染み込む
  • Level 2 2-5 入力検証: SQLi 対策の「上流」を学ぶ

キーフレーズ: “Code and Data Separation(コードとデータの分離)“。SQLi も XSS もこの1点が全て。

実害シーン: SQLi の被害規模

  • TalkTalk (2015): 17歳のハッカーが SQLi 一発でDB全取得 → 15.7万件流出、CEO 引責辞任、企業価値半減
  • Sony Pictures (2011): PSN事件、SQLi 起点で 7700万件
  • Heartland (2008): 1.3億件のカード情報流出、米国史上最大級

これら すべて"SELECT * FROM users WHERE id = " + userInput という 1行を書くか書かないか で防げた。世界中のエンジニアが「知らなかった」だけで何千億円が失われた。


大前提: SQLi は最古にして最強の脆弱性

1998年に Phrack 誌で広く紹介されてから四半世紀以上経つのに、今も発生し続けている 。データベースを扱うバックエンドエンジニアにとって、SQLi を知らないのは「車を運転できるけど右側通行か左側通行かわかってない」レベルで危険。

実際に起きた有名事件:

  • Sony Pictures (2011): 7700万件流出(PlayStation Network事件)。SQLi が侵入起点で、その後の長期間侵入を許した
  • TalkTalk (2015): 15.7万件流出。17歳の攻撃者が SQLi 一発でDB全取得。CEO は引責辞任、企業価値は半減
  • Yahoo (2012): 45万件のパスワードハッシュ流出。SQLi が原因
  • Heartland Payment Systems (2008): 1.3億件のクレジットカード情報流出。米国史上最大級のカード情報漏洩

これらは全部 prepared statement を使っていなかった ことが直接原因。たった1行の書き方で防げる脆弱性が、世界中で何千億円分の被害を生み続けている。

このレッスンで「文字列連結でSQLを組み立てない」を体に刻む。


セッション①: SQLi の仕組みと攻撃方法(25-30分)

0. 検証用脆弱アプリの起動

このレッスンで使う攻撃デモ環境を起動する。Docker が必要。

# 作業ディレクトリ
mkdir -p ~/log ~/learn/security/day05_sqli
cd ~/learn/security/day05_sqli
script ~/log/security_day05.log
 
# DVWA を起動(手元で SQLi / XSS を試せる脆弱アプリ)
docker run --rm -d --name dvwa -p 8080:80 vulnerables/web-dvwa
# → ブラウザで http://localhost:8080 を開く(初期ユーザー: admin / password)
 
# または OWASP Juice Shop
# docker run --rm -d --name juiceshop -p 3000:3000 bkimminich/juice-shop

検証環境は隔離する

DVWA / Juice Shop は 本気で脆弱なアプリ。インターネット公開しないで、ローカルか隔離環境で動かすこと。レッスン終わったら必ず停止: docker stop dvwa (または juiceshop)

1. SQLi の仕組み - 文字列結合の罠

// NG: 致命的脆弱性
name := r.URL.Query().Get("name")
query := "SELECT * FROM users WHERE name = '" + name + "'"
rows, _ := db.Query(query)

ユーザーが name=alice で来た時:

SELECT * FROM users WHERE name = 'alice'

ユーザーが name=' OR '1'='1 で来た時:

SELECT * FROM users WHERE name = '' OR '1'='1'
                                          ^^^^^^^
                                          常にtrue

全ユーザー行が返る

SQLi の本質

ユーザーが入力した文字列が SQL構文の一部として解釈される こと。

「データ」と「コード」が混在し、攻撃者が データを偽装してコードを流し込める 状態。

同じ問題は LDAP・NoSQL・XPath・テンプレートエンジン・シェルなど あらゆる「文字列を解釈する」場所で発生 する(A03 Injection 全般)。

根治の唯一の方法: 「データ」と「コード」を完全に分離して言語に渡す。それが prepared statement。

2. 攻撃の段階

SQLi 攻撃の典型的な進化

段階1: 認証バイパス

name=' OR 1=1--

ログイン処理で「ユーザー名が一致したらログイン成功」のクエリを常に成立させる。

段階2: 情報抜き出し(UNION-based)

id=1 UNION SELECT username, password FROM users--

既存クエリの結果に、別のクエリ結果を合体させて取得。

段階3: スキーマ探索

id=1 UNION SELECT table_name, NULL FROM information_schema.tables--

どんなテーブルがあるか調査。

段階4: 破壊

'; DROP TABLE users;--

古典的だが、複数文実行が有効な環境では実害ある。

段階5: OSコマンド実行 MSSQL の xp_cmdshell、PostgreSQL の COPY ... FROM PROGRAM などDBが持つ拡張機能を経由してサーバーOSにコマンドを実行。最悪パターン。

xkcd #327「Little Bobby Tables」

“Did you really name your son Robert'); DROP TABLE Students;--?” “Oh, yes. Little Bobby Tables, we call him.” “Well, we’ve lost this year’s student records. I hope you’re happy.”

SQLi界隈で最も有名なネタ。理解度の試金石。

3. ブラインド SQLi(盲目攻撃)

エラーや結果が表示されないアプリでも、攻撃可能なバリエーション。

Boolean-based blind

id=1 AND (SELECT SUBSTRING(password,1,1) FROM users WHERE name='admin')='a'
  • 「もし admin のパスワード1文字目が a なら、このクエリはtrue」
  • アプリは true なら正常レスポンス、false なら空レスポンス
  • 1文字ずつ試して 正解の文字を絞り込む

Boolean-based blind の本質

アプリの 「成功/失敗の二値レスポンス」を使って1ビットずつ情報を抜く。1パスワード抜くのに数千クエリ必要だが、ツール(sqlmap)で自動化される。

Time-based blind

id=1 AND IF(SUBSTRING(password,1,1)='a', SLEEP(5), 0)
  • 「もし admin のパスワード1文字目が a なら5秒待て」
  • レスポンスが5秒遅ければ正解、即時なら不正解
  • 完全に出力が無いアプリでも攻撃可能

Time-based blind の怖さ

WAF(Web Application Firewall)や IDS をすり抜けやすい。レスポンス内容に異常が出ないため、ログを見ても気づきにくい。

検知するなら 「異常に長いSQLレスポンスタイム」アラート を仕掛ける。

4. ハンズオン: 攻撃を実感する

注意

自分が所有/許可された環境でのみ実行する。他人のサイトに対する SQLi 試行は 不正アクセス禁止法違反(日本)、Computer Fraud and Abuse Act 違反(米国)など、即犯罪。

# DVWA(Damn Vulnerable Web Application)を Docker で立てる
docker run --rm -it -p 8080:80 vulnerables/web-dvwa
 
# ブラウザで http://localhost:8080 を開く
# 初期パスワード: admin / password
# Security Level を Low にして SQL Injection ページへ

DVWA で試すクエリ

User ID 欄に以下を入力して挙動を確認:

  • 1 → 普通の結果
  • 1 OR 1=1 → 全件返ってくる
  • ' UNION SELECT user, password FROM users-- → 認証情報抜き取り
  • 1 AND SLEEP(3) → 3秒遅延(time-based の体感)

「PHP Source」ボタンでサーバー側コードを見て、文字列連結している様子を確認する。「あ、これだけのバグなんだ」と腑に落とす のがこのハンズオンの目的。

# OWASP Juice Shop も同じく学習用脆弱アプリ
docker run --rm -p 3000:3000 bkimminich/juice-shop
# http://localhost:3000 で起動

sqlmap

SQLi を自動化する有名ツール。sqlmap -u "http://example.com?id=1" でテーブル一覧から認証情報取得まで自動実行する。

学習目的で 自分の脆弱アプリ に対して動かすと、攻撃の楽さに震える。


セッション②: 防御 - prepared statement と周辺対策(25-30分)

5. prepared statement / placeholder の本質

// OK: prepared statement
query := "SELECT * FROM users WHERE name = ?"
rows, err := db.Query(query, name)  // name を「データ」としてDBドライバが渡す

なぜ prepared statement は安全か

SQLi の根本原因「データとコードが混ざる」を 物理的に解消 している。

プロセス:

  1. SQL本体(placeholder付き)を先にDBに送る: SELECT * FROM users WHERE name = ?
  2. DB はこれを 構文解析・実行計画作成 する(この時点でクエリ構造は確定)
  3. データを別途送る: name = "alice"
  4. DB はデータをそのままパラメータに 値として 当てはめる

ユーザー入力が ' OR 1=1-- でも、DB はそれを「' OR 1=1-- という文字列値」として扱う。SQL構文として解釈されない。

副次的メリット: クエリプランがキャッシュされるので パフォーマンスも良い

6. Go の database/sql で安全に書く

package main
 
import (
	"context"
	"database/sql"
	"log"
 
	_ "github.com/lib/pq" // PostgreSQL ドライバ
)
 
func main() {
	db, err := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
 
	ctx := context.Background()
 
	// === OK: placeholder で渡す ===
	name := "alice"
	rows, err := db.QueryContext(ctx, "SELECT id, email FROM users WHERE name = $1", name)
	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()
 
	for rows.Next() {
		var id int
		var email string
		if err := rows.Scan(&id, &email); err != nil {
			log.Fatal(err)
		}
		log.Printf("user: %d %s", id, email)
	}
 
	// === OK: 単一行 ===
	var email string
	err = db.QueryRowContext(ctx,
		"SELECT email FROM users WHERE id = $1", 42).Scan(&email)
	if err != nil {
		log.Fatal(err)
	}
 
	// === OK: INSERT/UPDATE ===
	_, err = db.ExecContext(ctx,
		"INSERT INTO logs(user_id, action) VALUES ($1, $2)",
		42, "login")
	if err != nil {
		log.Fatal(err)
	}
}

placeholder の記号は DB ドライバで違う

  • MySQL / SQLite: ?(位置指定)
  • PostgreSQL: $1, $2, …(番号指定、複数回参照可能)
  • Oracle: :name(名前指定)

慣れたくない場合は sqlx パッケージ:name 形式の名前付きパラメータをサポートしていて読みやすい。

7. NG コード集

NG パターン1: fmt.Sprintf で組み立て

query := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", name)
rows, _ := db.Query(query)
// → 完全に脆弱。`name` の中身がそのまま埋め込まれる

NG パターン2: 文字列連結

rows, _ := db.Query("SELECT * FROM users WHERE id = " + idStr)
// → 数値型でも、文字列のまま渡せばSQLi可能

NG パターン3: 「エスケープすれば安全」と思っている

escaped := strings.ReplaceAll(name, "'", "''")
query := "SELECT * FROM users WHERE name = '" + escaped + "'"
// → 自前エスケープは網羅できない(文字エンコーディング、Unicode変換でバイパスされる)

エスケープは絶対に自分で書かない。DBドライバが提供する placeholder 機能を使う。

NG パターン4: ORM の生SQL使用

// GORM の例
db.Raw("SELECT * FROM users WHERE name = '" + name + "'").Scan(&users)
// → ORM の防御機能を自分で殺している

ORM 使っていても、Raw / Exec で文字列結合したら同じこと。

8. プレースホルダで置けない場面の対処

プレースホルダは「値」にしか使えない

SELECT * FROM users ORDER BY ? -- これは効かない(カラム名を変数化できない)

プレースホルダは「値の場所」を表すもので、テーブル名・カラム名・SQL構造そのもの は変数化できない。

動的にテーブル名やカラム名を切り替える必要がある場合:

  1. 許可リスト(allowlist)で検証
var allowedColumns = map[string]bool{
    "name": true, "email": true, "created_at": true,
}
if !allowedColumns[orderBy] {
    return errors.New("invalid order column")
}
query := "SELECT * FROM users ORDER BY " + orderBy
  1. クエリビルダーを使う(squirrel、bun、gorm builder): 内部でエスケープ・検証してくれる

  2. 絶対にユーザー入力をそのまま埋め込まない

9. ORM の落とし穴

ORM だから安全とは限らない

  • 生SQL(Raw / Exec / db.Sql)を呼ぶメソッドは要注意
  • like 検索の % ワイルドカードは自分でエスケープ: ユーザー入力に % を含まれた時の意図しないマッチ
  • MongoDB / NoSQL にも NoSQL injection が存在: {"$ne": null} のような演算子を JSON に注入される
  • クエリビルダーでも Where("name = " + name) のような書き方はNG。Where("name = ?", name) の形を徹底
// NG: GORM
db.Where("name = " + name).First(&user)
 
// OK: GORM
db.Where("name = ?", name).First(&user)

10. JS/TS でも同じ話(FE出身者向け)

// NG (Node.js + mysql2)
const [rows] = await db.execute(`SELECT * FROM users WHERE name = '${name}'`);
 
// OK
const [rows] = await db.execute("SELECT * FROM users WHERE name = ?", [name]);
// Prisma (TypeScript ORM)
// OK: 通常の Prisma API は自動的に安全
const user = await prisma.user.findFirst({ where: { name } });
 
// 注意: $queryRaw / $executeRaw を使うときは template literal を使う
// OK: tagged template literal はパラメータ化される
await prisma.$queryRaw`SELECT * FROM users WHERE name = ${name}`;
 
// NG: 文字列結合で生SQL
await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE name = '${name}'`);

Prisma の安全策

$queryRaw は tagged template で ${var}自動的にパラメータ化 する。$queryRawUnsafe は意図的に「危険なやつ」と命名されていて、文字列をそのまま渡す。命名がわかりやすい。

11. 多層防御(defense in depth)

prepared statement は 第一防御線 だが、ベルト&サスペンダーで以下も組み合わせる:

SQLi 完全防御の階層

  1. prepared statement / placeholder ← これが基本
  2. 入力検証: ID は数値か?メアドの形式か?(allowlist が基本)
  3. 最小権限の DB ユーザー: アプリ用ユーザーには DROP 権限を与えない
  4. DBアカウント分離: 読み取り専用APIは読み取り専用ユーザーで接続
  5. WAF: 既知の SQLi パターンを検知(ベストエフォート、頼り切らない)
  6. エラーメッセージを返さない: 詳細なSQLエラーをHTTPレスポンスに出さない(情報漏洩)
  7. 監視: 異常クエリの検出、ログ集約
  8. DBレベル暗号化: 万が一漏れても直読みできない(pgcrypto等)

12. エラーメッセージの注意

DBエラーをそのまま返さない

ERROR: column "passwords" does not exist
LINE 1: SELECT passwords FROM users WHERE id = 1

これを HTTP レスポンスに出すと、攻撃者にスキーマ情報を漏らす。

対策:

  • 本番環境では 汎用的なエラーメッセージInternal Server Error)を返す
  • 詳細はサーバー側ログにのみ出す
  • スタックトレースを HTTP で見せない

13. ログとモニタリング

SQLi 検出に有効な監視項目

  • 同一IPから短時間に大量の異常パターン('UNIONSLEEP を含む)リクエスト
  • 認可失敗(403)の急増
  • DB クエリの異常な実行時間(time-based blind の兆候)
  • 同一クエリ構造で異常な結果件数

練習課題

# 1. 脆弱コードを書いて壊す(自分で実装)
mkdir -p ~/learn/security/day05_sqli
cd ~/learn/security/day05_sqli
 
# 2. SQLite で試す(PostgreSQL/MySQLが無くてもOK)
cat > vulnerable.go << 'EOF'
package main
 
import (
	"database/sql"
	"fmt"
	"log"
	"os"
 
	_ "modernc.org/sqlite" // 純Go版SQLite
)
 
func main() {
	if len(os.Args) < 2 {
		log.Fatal("usage: vulnerable <name>")
	}
	name := os.Args[1]
 
	db, _ := sql.Open("sqlite", ":memory:")
	defer db.Close()
	db.Exec(`CREATE TABLE users (id INTEGER, name TEXT, secret TEXT)`)
	db.Exec(`INSERT INTO users VALUES (1, 'alice', 'alice_secret')`)
	db.Exec(`INSERT INTO users VALUES (2, 'bob', 'bob_secret')`)
 
	// NG: 文字列連結
	query := "SELECT id, name, secret FROM users WHERE name = '" + name + "'"
	fmt.Println("query:", query)
	rows, err := db.Query(query)
	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()
	for rows.Next() {
		var id int
		var n, s string
		rows.Scan(&id, &n, &s)
		fmt.Printf("=> %d %s %s\n", id, n, s)
	}
}
EOF
 
go mod init sqli-demo
go get modernc.org/sqlite
 
# 通常: alice の secret だけ返る
go run vulnerable.go "alice"
 
# 攻撃: 全部返る
go run vulnerable.go "' OR 1=1--"
# 3. 安全版を書く
cat > safe.go << 'EOF'
package main
 
import (
	"database/sql"
	"fmt"
	"log"
	"os"
 
	_ "modernc.org/sqlite"
)
 
func main() {
	if len(os.Args) < 2 {
		log.Fatal("usage: safe <name>")
	}
	name := os.Args[1]
 
	db, _ := sql.Open("sqlite", ":memory:")
	defer db.Close()
	db.Exec(`CREATE TABLE users (id INTEGER, name TEXT, secret TEXT)`)
	db.Exec(`INSERT INTO users VALUES (1, 'alice', 'alice_secret')`)
	db.Exec(`INSERT INTO users VALUES (2, 'bob', 'bob_secret')`)
 
	// OK: placeholder
	rows, err := db.Query("SELECT id, name, secret FROM users WHERE name = ?", name)
	if err != nil {
		log.Fatal(err)
	}
	defer rows.Close()
	for rows.Next() {
		var id int
		var n, s string
		rows.Scan(&id, &n, &s)
		fmt.Printf("=> %d %s %s\n", id, n, s)
	}
}
EOF
 
# 攻撃を試みても何も返らない
go run safe.go "' OR 1=1--"
# 普通の検索は通る
go run safe.go "alice"

観察ポイント

  • NG版で攻撃文字列を渡した時、全件返ることを目で確認する
  • OK版で同じ攻撃文字列を渡した時、結果が空になることを確認する
  • 「データとコードの分離」が起きている瞬間 を理解する

締め: 振り返り(10分)

1. セッション録画を終了

exit

2. 今日の発見

- 自分のコードでヒヤッとした書き方はあったか:
- prepared statement の動作原理を1分で説明できるか:
- ORM だから安全と油断していた箇所はないか:
- ブラインドSQLi の怖さ:

チェックリスト

  • SQLi の「文字列結合の罠」を絵で説明できる
  • prepared statement の動作原理を言える
  • Go の db.Query(sql, args...) 形式で書ける
  • PostgreSQL の $1、MySQLの? のように DB別 placeholder を知っている
  • テーブル名・カラム名は placeholder で渡せないことを知っている
  • DVWA か脆弱コードで実際に攻撃して挙動を確認した
  • エラーメッセージをHTTPで返さない設計を知っている
  • ブラインドSQLi(Boolean / time-based)を知っている

詰まった時のチートシート

やりたいこと答え
Go で安全なクエリdb.Query("... WHERE x = ?", x)
PostgreSQL の placeholder$1, $2, ...
MySQL/SQLite の placeholder?
文字列連結絶対NG
カラム名の動的指定allowlist で検証してから
ORM の生SQLRaw / Unsafe 系メソッドは要注意
エラーメッセージ本番では汎用化
検証ツールDVWA, Juice Shop, sqlmap(自分の環境のみ)

「実務OK」基準

  • 文字列結合で SQL を書いている同僚のコードを即座に指摘できる
  • prepared statement を「魔法」ではなく「データとコードの分離」として説明できる
  • ORM 利用時も Raw 系メソッドに警戒できる
  • エラーメッセージや実行時間が情報漏洩につながる発想を持つ

メタ認知: 自分の理解度を診断する

30秒セルフテスト

  1. prepared statement が SQLi を防ぐ 本質的な理由 を1文で(ヒント: 文字列ではない何か)
  2. ORM を使っていれば絶対安全? → No の理由
  3. ORDER BY のカラム名を動的に変えたい時、prepared statement 使える? どうする?
  4. ブラインド SQLi と time-based SQLi の違い
  5. エラーメッセージを本番で隠す理由は?

2問以上詰まったら → セッション①のシナリオと「prepared statement の仕組み」セクションを再読。

次のレッスン

1-6 XSS(クロスサイトスクリプティング) へ。SQLi が「サーバー側」のインジェクションなら、XSS は「ブラウザ側」のインジェクション。同じ「データとコードの分離」の話を、別の文脈で繰り返す。

間隔反復ポイント

SQLi は コードレビューで月1回は遭遇する 話題。3週間後にもう一度「アンチパターン集」を眺めると、fmt.Sprintf("WHERE id = %s", id) を見た瞬間に脊髄反射で指摘できる体になる。