1-5. SQL Injection - 文字列結合の罠を絶対に踏まない
所要時間: 35-50分 ゴール: SQLi の仕組みを攻撃側・防御側両方の視点で理解する。Go で安全なクエリを書ける。ブラインドSQLi、time-based attack まで把握する コミット内容: NG/OK 両方のサンプルコードを
~/learn/security/day05_sqli/に残す
前章とのつながり
このレッスンの位置づけ
前章 1-4 OWASP Top 10 の A03 (Injection) の代表選手を深掘りする回。Top 10 で全体地図を眺めた後、開発者が 日常的に直面する 2大脆弱性 SQLi と XSS に深く潜る。
キーフレーズ: “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 の根本原因「データとコードが混ざる」を 物理的に解消 している。
プロセス:
- SQL本体(placeholder付き)を先にDBに送る:
SELECT * FROM users WHERE name = ?- DB はこれを 構文解析・実行計画作成 する(この時点でクエリ構造は確定)
- データを別途送る:
name = "alice"- 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構造そのもの は変数化できない。
動的にテーブル名やカラム名を切り替える必要がある場合:
- 許可リスト(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
クエリビルダーを使う(squirrel、bun、gorm builder): 内部でエスケープ・検証してくれる
絶対にユーザー入力をそのまま埋め込まない
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 完全防御の階層
- prepared statement / placeholder ← これが基本
- 入力検証: ID は数値か?メアドの形式か?(allowlist が基本)
- 最小権限の DB ユーザー: アプリ用ユーザーには
DROP権限を与えない- DBアカウント分離: 読み取り専用APIは読み取り専用ユーザーで接続
- WAF: 既知の SQLi パターンを検知(ベストエフォート、頼り切らない)
- エラーメッセージを返さない: 詳細なSQLエラーをHTTPレスポンスに出さない(情報漏洩)
- 監視: 異常クエリの検出、ログ集約
- 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から短時間に大量の異常パターン(
'、UNION、SLEEPを含む)リクエスト- 認可失敗(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. セッション録画を終了
exit2. 今日の発見
- 自分のコードでヒヤッとした書き方はあったか:
- 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 の生SQL | Raw / Unsafe 系メソッドは要注意 |
| エラーメッセージ | 本番では汎用化 |
| 検証ツール | DVWA, Juice Shop, sqlmap(自分の環境のみ) |
「実務OK」基準
- 文字列結合で SQL を書いている同僚のコードを即座に指摘できる
- prepared statement を「魔法」ではなく「データとコードの分離」として説明できる
- ORM 利用時も
Raw系メソッドに警戒できる - エラーメッセージや実行時間が情報漏洩につながる発想を持つ
メタ認知: 自分の理解度を診断する
30秒セルフテスト
- prepared statement が SQLi を防ぐ 本質的な理由 を1文で(ヒント: 文字列ではない何か)
- ORM を使っていれば絶対安全? → No の理由
ORDER BYのカラム名を動的に変えたい時、prepared statement 使える? どうする?- ブラインド SQLi と time-based SQLi の違い
- エラーメッセージを本番で隠す理由は?
2問以上詰まったら → セッション①のシナリオと「prepared statement の仕組み」セクションを再読。
次のレッスン
1-6 XSS(クロスサイトスクリプティング) へ。SQLi が「サーバー側」のインジェクションなら、XSS は「ブラウザ側」のインジェクション。同じ「データとコードの分離」の話を、別の文脈で繰り返す。
間隔反復ポイント
SQLi は コードレビューで月1回は遭遇する 話題。3週間後にもう一度「アンチパターン集」を眺めると、
fmt.Sprintf("WHERE id = %s", id)を見た瞬間に脊髄反射で指摘できる体になる。