値が構文になる(SQL インジェクション)
実装:
db/sqlinject// 実行:go test ./db/sqlinject/
注入というのは、値のはずのものが構文になることだ。だから数え方も決まる。組み立てた問い合わせを字句に切って、値が構文に何トークン寄与したかを見ればいい。普通の名前は1、攻撃は5だった。プレースホルダなら入力が何であってもゼロで、問い合わせの形は変わらない。ただし列名には使えないので、そこだけは許可制になる。
この章で作るもの
外から来た値を SQL に混ぜるやり方を3通り、混ぜる場所を3通り作って、何がどこまで守れるかを測る。
土台はミニSQLで作った字句解析だ。あそこでは文字列をトークンの列に切った。同じ道具が、そのまま測る道具になる。
注入というのは、要するに値のはずのものが構文になったということだ。だから測り方が1つに決まる。組み立てた問い合わせを字句に切って、外から来た値が構文に何トークン寄与したかを数える。
SELECT * FROM users WHERE name = '……'
↑ ここに外から来た値を入れる
値が esh2n のとき
SELECT * FROM users WHERE name = 'esh2n'
~~~~~~~
'esh2n' 値の寄与 1 トークン
値が ' OR '1'='1 のとき
SELECT * FROM users WHERE name = '' OR '1'='1'
~~ ~~ ~~~ ~ ~~~
'' OR '1' = '1' 値の寄与 5 トークン
↑
キーワードが値から出てきている順に見ていく。
- 注入とは、値が構文に寄与することだ: 普通の値は1トークン、攻撃は5トークン
- プレースホルダは値を問い合わせに入れない: 入力が何でも形が変わらない
- 引用符を二重にする守りは、引用符の無い場所では何もしない: 変換する対象がいない
- 列名にはプレースホルダを使えない: そこだけは許可制になる
① 注入とは、値が構文に寄与することだ
まず字句に切る道具を用意する。ミニSQLの字句解析器は最小構成で文字列も演算子も持っていないので、注入を見るのに足りるぶんだけを別に持つ。
// Kind はトークンの種類。
type Kind int
const (
// Word は識別子かキーワード。
Word Kind = iota
// Num は数値。
Num
// Str は引用符で囲まれた文字列。全体で1つ。
Str
// Op は演算子や区切り。
Op
// Comment はコメント。ここから行末は読まれない。
Comment
)
// Token は1つのトークン。From は元の文字列での開始位置。
type Token struct {
Kind Kind
Text string
From int
}
var keywords = map[string]bool{
"select": true, "from": true, "where": true, "or": true, "and": true,
"insert": true, "into": true, "values": true, "drop": true, "table": true,
"order": true, "by": true, "union": true, "delete": true, "update": true,
}
// IsKeyword は、その語が構文を動かすキーワードか。
func IsKeyword(t Token) bool { return t.Kind == Word && keywords[strings.ToLower(t.Text)] }
// Lex は問い合わせをトークンに切る。
//
// 文字列は開き引用符から閉じ引用符までで**1つ**にする。ここが要点で、
// 引用符が閉じられてしまうと、その先は文字列ではなく構文として切られる。
func Lex(q string) []Token {
var out []Token
r := []rune(q)
i := 0
for i < len(r) {
c := r[i]
switch {
case c == ' ' || c == '\t' || c == '\n':
i++
case c == '-' && i+1 < len(r) && r[i+1] == '-':
j := i
for j < len(r) && r[j] != '\n' {
j++
}
out = append(out, Token{Comment, string(r[i:j]), i})
i = j
case c == '\'':
j := i + 1
for j < len(r) {
// '' は文字列の中の引用符1つ。ここでは閉じない。
if r[j] == '\'' && j+1 < len(r) && r[j+1] == '\'' {
j += 2
continue
}
if r[j] == '\'' {
j++
break
}
j++
}
out = append(out, Token{Str, string(r[i:j]), i})
i = j
case c >= '0' && c <= '9':
j := i
for j < len(r) && r[j] >= '0' && r[j] <= '9' {
j++
}
out = append(out, Token{Num, string(r[i:j]), i})
i = j
case isWordRune(c):
j := i
for j < len(r) && isWordRune(r[j]) {
j++
}
out = append(out, Token{Word, string(r[i:j]), i})
i = j
default:
out = append(out, Token{Op, string(c), i})
i++
}
}
return out
}
func isWordRune(c rune) bool {
return c == '_' || c == '*' ||
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}要点は文字列の切り方だ。開き引用符から閉じ引用符までを1つのトークンにする。だから引用符が閉じられてしまうと、その先は文字列ではなく構文として切られる。
同じ場所に2つの値を埋めて、値が寄与したトークンを並べた。
| 値 | 組み立てた問い合わせ | 値の寄与 |
|---|---|---|
esh2n | … WHERE name = 'esh2n' | 'esh2n' の1トークン |
' OR '1'='1 | … WHERE name = '' OR '1'='1' | '' OR '1' = '1' の5トークン |
テストで、普通の値が1トークンに収まること、攻撃が5トークンに割れること、そして割れた側からキーワード OR が出てくることを固定した。
この数え方には利点がある。「危ない文字」を列挙しなくていい。値が1トークンに収まっているかだけを見れば、注入が起きたかどうかが決まる。攻撃の形を知らなくても判定できる。
② プレースホルダは値を問い合わせに入れない
いちばんよく勧められる守りがプレースホルダだ。問い合わせに ? と書いておき、値は別の経路で渡す。
// Mode は値の混ぜ方。
type Mode int
const (
// Concat は文字列連結。値がそのまま問い合わせの一部になる。
Concat Mode = iota
// QuoteEscape は連結する前に ' を '' にする。
QuoteEscape
// Placeholder は値を問い合わせに入れず、別の経路で渡す。
Placeholder
)
func (m Mode) String() string {
switch m {
case QuoteEscape:
return "引用符を二重にする"
case Placeholder:
return "プレースホルダ"
default:
return "文字列連結"
}
}
// Modes は測る対象。
func Modes() []Mode { return []Mode{Concat, QuoteEscape, Placeholder} }
// Slot は値を埋める場所。埋める先によって、使える守りが変わる。
type Slot int
const (
// Quoted は引用符で囲まれた値。WHERE name = 'ここ'
Quoted Slot = iota
// Bare は引用符の無い値。WHERE id = ここ
Bare
// Ident は識別子。ORDER BY ここ
Ident
)
func (s Slot) String() string {
switch s {
case Bare:
return "引用符なしの値"
case Ident:
return "識別子(列名など)"
default:
return "引用符ありの値"
}
}
// Slots は測る対象。
func Slots() []Slot { return []Slot{Quoted, Bare, Ident} }
// Query は組み立てた結果。
type Query struct {
// Text は実際にデータベースへ渡す文字列。
Text string
// Params は別の経路で渡す値。Text には現れない。
Params []string
// span は Text の中で、外から来た値が占めた範囲。
span [2]int
}
// Build は、その混ぜ方でその場所へ値を埋めた問い合わせを返す。
//
// プレースホルダだけは、値を Text に入れない。ここが他の2つと根本的に違う。
func Build(m Mode, s Slot, v string) Query {
if m == Placeholder && s != Ident {
// 値は文字列に入らない。引用符も要らない。値の種類はデータベース側が
// 別経路で受け取るので、問い合わせの形は入力に依らず一定になる。
return Query{Text: bindFrame(s), Params: []string{v}}
}
head, tail := frame(s)
body := v
if m == QuoteEscape {
body = strings.ReplaceAll(v, "'", "''")
}
return Query{Text: head + body + tail, span: [2]int{len(head), len(head) + len(body)}}
}
// bindFrame は、値を別経路で渡すときの形。引用符が消えるのが要点になる。
func bindFrame(s Slot) string {
if s == Bare {
return "SELECT * FROM users WHERE id = ?"
}
return "SELECT * FROM users WHERE name = ?"
}
// frame は、その場所の前後の決まった部分。
func frame(s Slot) (head, tail string) {
switch s {
case Bare:
return "SELECT * FROM users WHERE id = ", ""
case Ident:
return "SELECT * FROM users ORDER BY ", ""
default:
return "SELECT * FROM users WHERE name = '", "'"
}
}ここが他の2つと根本的に違う。値が問い合わせの文字列に入らない。
| 値 | 組み立てた問い合わせ | 別経路 |
|---|---|---|
esh2n | SELECT * FROM users WHERE name = ? | esh2n |
' OR '1'='1 | SELECT * FROM users WHERE name = ? | ' OR '1'='1 |
'; DROP TABLE users;-- | SELECT * FROM users WHERE name = ? | '; DROP TABLE users;-- |
| (空) | SELECT * FROM users WHERE name = ? | (空) |
入力が何であっても、問い合わせの形が1文字も変わらない。テストで、4通りの入力で文字列が完全に一致すること、値が構文に寄与したトークンが常に0であることを固定した。
だからプレースホルダはエスケープではない。危ない文字を安全な文字に置き換えているのではなく、値と構文を最初から別の経路で渡している。構文は問い合わせを書いた時点で確定していて、あとから動かしようがない。
「エスケープした」では足りないでは、正しい変換が出す場所ごとに違って苦労した。こちらは変換ではないので、値の中身によらず一定になる。守り方として質が違う。
動かす
下のデモは、混ぜ方と場所と値を選んで、組み立てた問い合わせと値の寄与を見る。
注入かどうかを「危ない文字が入っているか」で見ると、必ず漏れる。 値が構文に何トークン寄与したかで見れば、攻撃の形を知らなくても判定できる。 プレースホルダが効くのは、値を安全な文字に置き換えているからではなく、 値を問い合わせに入れていないからになる。
③ 引用符を二重にする守りは、引用符の無い場所では何もしない
プレースホルダを使えない事情があるとき、次に出てくるのが引用符を二重にする守りだ。値の中の ' を '' にする。①で見たとおり '' は文字列を閉じないので、これで引用符は閉じられなくなる。
3つの混ぜ方を3つの場所に当てた。
| 場所 | 文字列連結 | 引用符を二重にする | プレースホルダ |
|---|---|---|---|
| 引用符ありの値 | 0 / 2 | 2 / 2 | 2 / 2 |
| 引用符なしの値 | 0 / 1 | 0 / 1 | 1 / 1 |
| 識別子(列名など) | 0 / 1 | 0 / 1 | 使えない |
読みどころは2行目になる。引用符ありの場所では効いた守りが、引用符なしの場所では0になる。
理由は身も蓋もない。WHERE id = 1 OR 1=1 に引用符は1つも無いので、変換する対象がいない。テストで、変換の前後で文字列が完全に一致することを固定した。
| 組み立てた問い合わせ | |
|---|---|
| 文字列連結 | SELECT * FROM users WHERE id = 1 OR 1=1 |
| 引用符を二重にする | SELECT * FROM users WHERE id = 1 OR 1=1 |
同じものが出る。守りを1つ足したはずなのに、何も起きていない。
これは「エスケープした」では足りないの②で見た「引用符を書かないと HTML エスケープでも防げない」と、まったく同じ形だ。変換で守る発想は、変換すべき文字が入力に現れるかどうかに乗っている。現れない経路があれば、そこは素通りになる。
数値の欄だから安全、とはならない。その欄が数値かどうかを決めているのは、問い合わせを組む側であって、送ってくる側ではない。
④ 列名にはプレースホルダを使えない
②でプレースホルダを勧めたが、使えない場所がある。識別子、つまり列名やテーブル名だ。
// FromValue は、外から来た値が構文に寄与したトークンを返す。
//
// 値そのものが1つのトークンに収まっていれば、それは値として扱われている。
// 2つ以上になっていたら、値の一部が構文として読まれたということになる。
func (q Query) FromValue() []Token {
if q.span == [2]int{} {
return nil // 値は Text に入っていない
}
var out []Token
for _, t := range Lex(q.Text) {
to := t.From + len([]rune(t.Text))
// 重なっていれば、そのトークンには値が関わっている。
// 普通の値は、囲みの引用符を含む文字列トークン 1 つに収まる。
if t.From < q.span[1] && to > q.span[0] {
out = append(out, t)
}
}
return out
}
// Injected は注入が起きたか。
//
// 判定は素朴で、値の寄与が2トークン以上か、キーワードかコメントを含むか。
func (q Query) Injected() bool {
ts := q.FromValue()
if len(ts) > 1 {
return true
}
for _, t := range ts {
if IsKeyword(t) || t.Kind == Comment {
return true
}
}
return false
}
// Attack は攻撃に使う値と、それが刺さる場所。
type Attack struct {
Name string
Value string
Slots []Slot
}
// Attacks は測るときの攻撃。
func Attacks() []Attack {
return []Attack{
{"条件を常に真にする", `' OR '1'='1`, []Slot{Quoted}},
{"文を打ち切って足す", `'; DROP TABLE users;--`, []Slot{Quoted}},
{"引用符が要らない", `1 OR 1=1`, []Slot{Bare}},
{"列名に紛れ込ませる", `name; DROP TABLE users;--`, []Slot{Ident}},
}
}
// Hits は、その攻撃がその場所を狙っているか。
func Hits(a Attack, s Slot) bool {
for _, x := range a.Slots {
if x == s {
return true
}
}
return false
}
// Score は、その混ぜ方がその場所で攻撃を何件止めたか。
func Score(m Mode, s Slot) (stopped, total int) {
for _, a := range Attacks() {
if !Hits(a, s) {
continue
}
total++
if !Build(m, s, a.Value).Injected() {
stopped++
}
}
return
}
// Usable は、その場所でその混ぜ方が使えるか。
//
// **プレースホルダは識別子には使えない**。列名やテーブル名は値ではなく
// 構文の一部なので、実行する前に決まっていなければならない。
// ここは変換や委譲で守るところではなく、許可制にするところになる。
func Usable(m Mode, s Slot) bool { return !(m == Placeholder && s == Ident) }
// AllowIdent は、識別子を許可制で選び直す。
func AllowIdent(allow []string, v string) string {
for _, a := range allow {
if a == v {
return a
}
}
return allow[0] // 知らない名前は既定へ倒す
}ORDER BY ? と書いて name を渡すことはできない。列名は値ではなく構文の一部で、問い合わせを解釈する時点で決まっていなければならないからだ。並べ替えの列を利用者に選ばせる画面は珍しくないので、この穴は実務でよく開く。
連結も二重化も止められない。
| 混ぜ方 | 組み立てた問い合わせ | |
|---|---|---|
| 文字列連結 | … ORDER BY name; DROP TABLE users;-- | 注入 |
| 引用符を二重にする | … ORDER BY name; DROP TABLE users;-- | 注入 |
ここでも二重化は1文字も変えていない。引用符が入っていないからだ。
残るのは許可制になる。あらかじめ並べ替えてよい列を書いておき、それ以外は既定へ倒す。
| 入力 | 許可制を通したあと |
|---|---|
created_at | created_at |
name; DROP TABLE users;-- | id(既定) |
テストで、許可した名前が通ること、知らない名前が既定へ倒れること、通したあとは注入と判定されないことを固定した。
3つの混ぜ方のうち、3か所すべてを守れるものは1つも無い。
| 混ぜ方 | 全部止めた場所 |
|---|---|
| 文字列連結 | 0 / 3 |
| 引用符を二重にする | 1 / 3 |
| プレースホルダ | 2 / 3 |
値として渡せる場所は委譲で、渡せない場所は選別で守る。「エスケープした」では足りないの④でリンク先を許可制にしたのと同じ切り分けになる。文字を置き換える発想のまま来ると、ここで手が止まる。
設計の観点
- 注入は「危ない文字」の問題ではない: 値が構文に寄与したかどうかで決まる。文字の一覧を持つと、必ず漏れる
- エスケープと委譲を区別する: 変換は入力の中身に依存する。プレースホルダは依存しない
- 問い合わせの形が入力で変わらないか見る: 変わるなら、そこは構文が動く余地がある
- 引用符の無い欄を数える: 数値の欄は変換の守りが素通りする。安全なのは欄の種類ではなく渡し方だ
- 値として渡せない場所を先に洗う: 列名・テーブル名・並べ替えの向き。そこは許可制にするしかない
- 許可制は既定を持つ: 知らない名前が来たときに何を選ぶかまで決めて、初めて守りになる
- 欄が数値だから安全、とは言えない: 何を数値として扱うかを決めているのは受け取る側になる
対照と実例
| 混ぜ方 | 値と構文の関係 | 入力に依存するか | 使えない場所 |
|---|---|---|---|
| 文字列連結 | 同じ文字列に混ざる | — | どこでも危ない |
| 引用符を二重にする | 混ざるが引用符を閉じられない | する(引用符が無いと無効) | 引用符なしの欄・識別子 |
| プレースホルダ | 別の経路で渡す | しない | 識別子 |
| 許可制 | そもそも入力を使わない | しない | (値には過剰) |
裏どり:
''は文字列を閉じない: 標準 SQL では、文字列の中の引用符は2つ重ねて書く。この章の字句解析もその規則で切っている。二重化の守りはこの規則に乗っている- プレースホルダが効く理由: 値が問い合わせの文字列に入らないので、構文解析が終わったあとに値が渡る。だから値の中身で構文が変わらない。「エスケープを頑張る」のとは仕組みが違う
- 識別子に使えない理由: 列名やテーブル名は、問い合わせを解釈して実行の計画を立てる時点で決まっている必要がある。値は計画が立ったあとに埋まる
- 数字はすべて手元の測定: 3通り × 3か所 × 4攻撃の総当たりを、この章の実装でテストに固定した
- 判定は模型: 「値が寄与したトークン数」と「キーワードが混じったか」で見ている。本物のデータベースの構文解析ではないので、どの守りがどの場所で足りないかを見るためのものになる
簡略化したこと
- 本物のデータベースに繋いでいない: 組み立てた文字列と、その字句だけを見ている。実際に実行して結果が変わるところまでは確かめていない
- 文字コードの取り違えを扱っていない: 文字コードの解釈差を使って引用符を作る攻撃があるが、扱わない
- 攻撃が4つだけ: 時間差で情報を取る形、誤りの文面から探る形、
UNIONで別の表を混ぜる形などは入れていない - 二重化以外のエスケープを扱っていない: バックスラッシュで逃がす方言があり、二重化とは条件が違う
- 権限の設計に触れていない: 注入されても被害を狭める方向(読み取り専用の接続で繋ぐ等)は扱わない
- ミニSQL の字句解析器を使っていない: あちらは文字列も演算子も持たないので、この章では別に持った
参考資料
- SQL Injection Prevention Cheat Sheet(OWASP) — 委譲・許可制・エスケープの優先順位
- 前提: ミニSQL — 字句解析からの3段
- 関連: 「エスケープした」では足りない — 変換で守る場所と、選別で守る場所
- 実装: db/sqlinject