PythonHaru / 逆張りセキュリティ講座 第1回(認証編)
はじめに
最近、不正アクセスのニュースが絶えません。 そこで今回は逆張りして、「最速で不正アクセスされるGoコードの書き方」を解説します。
⚠️ これは「悪い例」を教える講座です。本番環境では絶対に真似しないでください。 試すのは自分のローカル環境だけにしてください。他人のサービスへの攻撃の試行は、不正アクセス禁止法などに触れるおそれがあります。
各回の流れはこうです。
- わざと脆弱に書く(皮肉つき)
- 何が起きるかを見る
- 正しい書き方に直す
シリーズ一覧はこちらです。
第1回は認証編。ログイン処理を題材にします。
環境
- Go 1.22以降
-
database/sql+ SQLite(ドライバは何でもOK) -
golang.org/x/crypto/bcrypt(修正版で使用)
① 最速で破られるログイン(やってはいけない)
まずは「最速で不正アクセスされる」ログイン処理です。
func login(w http.ResponseWriter, r *http.Request) {
q := fmt.Sprintf("SELECT id FROM users WHERE name='%s' AND password='%s'",
r.FormValue("name"), r.FormValue("password"))
var id int
if err := db.QueryRow(q).Scan(&id); err != nil {
http.Error(w, "NG", 401)
return
}
fmt.Fprintf(w, "OK: %d", id)
}
ユーザー登録側も、もちろんこう書きます。
_, err := db.Exec("INSERT INTO users(name, password) VALUES(?, ?)", name, pw)
ちゃんと動くので、一見まったく問題ありません。動くことと安全なことは別です。
皮肉ポイント1:SQLは文字列連結で組みましょう
この書き方だと、ユーザーの入力がそのままSQL文の一部になります。入力欄に細工した文字列を入れるだけで、WHERE の条件が意図しない意味になり、パスワードを知らなくてもログインを通せてしまう可能性があります(SQLインジェクション)。
皮肉ポイント2:パスワードは平文で保存しましょう
DBが漏れた瞬間に、全ユーザーのパスワードがそのまま読まれます。他のサービスと同じパスワードを使い回している人は、そちらにも被害が広がります。
② 直した版
直す点は主に2つです。
登録時:パスワードはハッシュ化して保存する
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "error", 500)
return
}
_, err = db.Exec("INSERT INTO users(name, password_hash) VALUES(?, ?)", name, string(hash))
ログイン時:プレースホルダで入力とSQLを分離し、bcryptで比較する
func login(w http.ResponseWriter, r *http.Request) {
name, pw := r.FormValue("name"), r.FormValue("password")
var id int
var hash string
err := db.QueryRow(
"SELECT id, password_hash FROM users WHERE name = ?", name,
).Scan(&id, &hash)
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) != nil {
http.Error(w, "NG", http.StatusUnauthorized)
return
}
fmt.Fprintf(w, "OK: %d", id)
}
直したポイント
| 悪い例 | 直し方 |
|---|---|
| SQLを文字列連結で組む | プレースホルダ(?)を使う |
| パスワードを平文で保存・比較 | bcryptでハッシュ化して比較 |
| (失敗の理由を細かく返す) | ユーザーなし/パスワード違いを同じ応答にする |
※ プレースホルダの書き方はドライバによって異なります(SQLite・MySQLは ?、PostgreSQLは $1 など)。
※ 厳密には、ユーザーが存在しない場合は bcrypt の比較が走らないため、処理時間の差からユーザー名の有無が推測される余地が残ります。この話は発展編で扱います。
まとめ:今日から使えるチェックリスト
-
SQLを
fmt.Sprintfや文字列連結で組んでいないか - パスワードを平文で保存・ログ出力していないか
- bcryptなど、パスワード用のハッシュ関数を使っているか
- ログイン失敗時の応答が、理由によって変わっていないか
- ログイン試行の回数制限(レート制限)はあるか(次回以降で扱います)
次回予告
第2回はインジェクション編。SQL以外にも潜む、OSコマンド・ファイルパス・テンプレートまわりの「やってはいけない書き方」を、また皮肉たっぷりに書いていきます。
ストックリストに入れておくと、続きを見逃しません。