PythonHaru / 逆張りセキュリティ講座 第2回(インジェクション編)
はじめに
「最速で不正アクセスされるGoコードの書き方」を、あえて逆張りで解説するシリーズの第2回です。
第1回(認証編)では、SQLを文字列連結で組むとどうなるかを見ました。今回はその仲間たち、SQL以外のインジェクションを扱います。
⚠️ これは「悪い例」を教える講座です。本番環境では絶対に真似しないでください。 試すのは自分のローカル環境だけにしてください。他人のサービスへの攻撃の試行は、不正アクセス禁止法などに触れるおそれがあります。
各回の流れは変わりません。
- わざと脆弱に書く(皮肉つき)
- 何が起きるかを見る
- 正しい書き方に直す
シリーズ一覧はこちらです。
今回の題材は3つです。
- OSコマンドインジェクション
- パストラバーサル
- テンプレート経由のXSS
環境
- Go 1.24以降(修正版で
os.Rootを使います) - 標準ライブラリのみ
① OSコマンドインジェクション
やってはいけない書き方
ホストに ping を打つ、便利な診断APIです。
func ping(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
out, _ := exec.Command("sh", "-c", "ping -c 1 "+host).CombinedOutput()
w.Write(out)
}
皮肉ポイント:シェルを経由して、文字列連結でコマンドを組みましょう
入力がそのままシェルに渡るので、シェルの区切り文字などを混ぜられると、意図しないコマンドがサーバーの権限で実行されます。ファイルの読み取りや外部への通信など、アプリが動いている権限でできることはすべて対象です。
直した版
func ping(w http.ResponseWriter, r *http.Request) {
ip := net.ParseIP(r.URL.Query().Get("host"))
if ip == nil {
http.Error(w, "invalid host", http.StatusBadRequest)
return
}
out, _ := exec.Command("ping", "-c", "1", ip.String()).CombinedOutput()
w.Write(out)
}
直した点は2つです。
-
シェルを経由しない:
sh -cをやめ、コマンドと引数を分けて渡す -
入力を許可する形に絞る:
net.ParseIPでIPアドレスとして正しい値だけ通す(ハイフンで始まる文字列がオプションとして解釈される事故も防げます)
そもそも、OSコマンドを呼ばずに済む設計(標準ライブラリで同じことができないか)を先に考えるのが一番安全です。
※ ping -c 1 はLinux・macOS向けの書き方です。Windowsでは -n 1 を使います。
② パストラバーサル
やってはいけない書き方
アップロード済みファイルをダウンロードさせるAPIです。
func download(w http.ResponseWriter, r *http.Request) {
data, err := os.ReadFile("./uploads/" + r.URL.Query().Get("file"))
if err != nil {
http.NotFound(w, r)
return
}
w.Write(data)
}
皮肉ポイント:ファイル名はそのままパスにつなげましょう
ファイル名に「ひとつ上のフォルダ」を意味する指定を混ぜられると、uploads の外にあるファイル(設定ファイル、認証情報、ソースコードなど)まで読めてしまいます。
直した版(Go 1.24以降)
func download(w http.ResponseWriter, r *http.Request) {
root, err := os.OpenRoot("./uploads")
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
defer root.Close()
f, err := root.Open(r.URL.Query().Get("file"))
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
io.Copy(w, f)
}
os.Root は、指定したディレクトリの外には出られないようにしてファイルを開く仕組みです。入力の文字列を自分でチェックするより、ディレクトリの外に出られない仕組みに任せるほうが確実です。
古いGoバージョンの場合は、filepath.Base でファイル名の部分だけを取り出す方法もあります。ただし、サブフォルダを扱う場合は別の対策が必要です。さらに設計から変えるなら、「ファイル名ではなくDBのIDで指定させる」形が堅実です。
③ テンプレート経由のXSS
やってはいけない書き方
名前を表示するだけのページです。
import "text/template" // ← これが罠
func hello(w http.ResponseWriter, r *http.Request) {
t := template.Must(template.New("t").Parse("<h1>Hello, {{.}}</h1>"))
t.Execute(w, r.URL.Query().Get("name"))
}
皮肉ポイント:HTMLを出すのに text/template を使いましょう
text/template はHTMLのエスケープをしません。名前の部分にスクリプトを混ぜられると、閲覧した人のブラウザで実行されます(クロスサイトスクリプティング、XSS)。ログイン中の人のセッションを悪用される入口になります。
直した版
import "html/template" // ← import を1行変えるだけ
func hello(w http.ResponseWriter, r *http.Request) {
t := template.Must(template.New("t").Parse("<h1>Hello, {{.}}</h1>"))
t.Execute(w, r.URL.Query().Get("name"))
}
関数の中身は1文字も変わらず、import だけが違います。html/template は出力される場所(HTML本文、属性、URLなど)に応じて自動でエスケープしてくれます。
さらに皮肉な書き方として、テンプレートの文字列そのものにユーザー入力をつなげる方法があります。これをやると、入力がテンプレートの構文として解釈されてしまいます。テンプレートは固定の文字列にして、ユーザー入力は必ずデータとして渡すようにしてください。
まとめ:3つのインジェクションと直し方
| 悪い例 | 起きること | 直し方 |
|---|---|---|
sh -c に文字列連結で渡す |
任意のコマンド実行 | シェルを使わず引数を分ける+入力を絞る |
| 入力をそのままファイルパスに連結 | 公開範囲外のファイルを読まれる |
os.Root で範囲を固定する |
HTMLに text/template を使う |
ブラウザでスクリプトが実行される |
html/template を使う |
共通点は1つ。ユーザー入力を「命令」として扱ってしまっていることです。入力はいつも「ただのデータ」として渡す、が合言葉です。
チェックリスト
-
sh -cやbash -cに、ユーザー入力を連結して渡していないか -
exec.Commandの引数を分けているか、入力を許可する形に絞っているか - ファイル名やパスにユーザー入力をそのまま連結していないか
-
HTMLを出すテンプレートは
html/templateか - テンプレートの文字列自体にユーザー入力を混ぜていないか
次回予告
第3回は認可編。「ログインさえさせれば大丈夫」と思っている人が踏みがちな、他人のデータが見えてしまう書き方(IDOR)や、権限チェックの抜けを、また皮肉たっぷりに書いていきます。
ストックリストに入れておくと、続きを見逃しません。