OSコマンドインジェクション

OSコマンドインジェクション

説明

ユーザー入力がシェルのコマンドに入ると、;、&&、| などがコマンドの構文として解釈される場合があります。Goの os/exec は既定ではシェルを起動しませんが、sh -c の呼び出しやシェルの標準入力への書き込みには、このリスクがあります。

想定される影響

  • アプリケーションプロセスの権限で、意図しないコマンドを実行されるおそれがあります。
  • ファイル、環境変数、認証情報を読み取られたり、データを変更されたりする可能性があります。
  • 負荷の高いコマンドでサービスが停止するおそれがあります。管理者権限を取得できるかは、既存のプロセス権限や追加の脆弱性によって異なります。

対処方法

  • 標準ライブラリの処理を優先してください。外部プログラムが必要な場合は、シェルを使わず、実行ファイルと引数を分けて exec.Command に渡してください。
  • 実行ファイル、サブコマンド、オプションはコードに固定し、ユーザー入力を許可した値に制限してください。引数の分離だけでは、対象プログラムの危険なオプションを防げません。
  • シェルが不可避な場合は、シェルの種類と挿入位置に応じて各値を引用してください。エスケープだけでは、作業の権限や対象の安全性は検証できません。
  • 呼び出し元の権限を確認し、最小権限、時間制限、リソース制限を適用してください。

例

実行ファイルの絶対パスは、配置先の環境に合わせて確認してください。認証・認可、TLS、別途行うリソース分離は省略しています。

変更前

go
package main

import (
    "io"
    "log"
    "net/http"
    "os/exec"
)

func unsafeHandler(w http.ResponseWriter, r *http.Request) {
    // ユーザーが渡すコマンド文字列(例: name; rm -rf /)
    userCmd := r.URL.Query().Get("cmd")

    // 変更前: シェルを起動し、ユーザー入力を標準入力へ直接渡す
    // ;、&&、| などのメタ文字で追加コマンドを実行できる
    c := exec.Command("bash")
    stdin, err := c.StdinPipe()
    if err != nil {
        http.Error(w, "pipe error", http.StatusInternalServerError)
        return
    }
    if err := c.Start(); err != nil {
        http.Error(w, "start error", http.StatusInternalServerError)
        return
    }
    io.WriteString(stdin, userCmd+"\n")
    stdin.Close()
    _ = c.Wait()

    w.Write([]byte("done"))
}

func main() {
    http.HandleFunc("/run", unsafeHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

変更後

go
package main

import (
    "log"
    "net/http"
    "os/exec"
    "regexp"
    "time"
    "context"
)

// 固定した許可コマンド
var allowed = map[string][]string{
    "whoami": {"/usr/bin/whoami"},
    "date":   {"/bin/date", "+%F %T"},
}

// ユーザーのメッセージを引数にする場合の文字を制限
var safeText = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,64}$`)

func safeHandler(w http.ResponseWriter, r *http.Request) {
    action := r.URL.Query().Get("action")

    // 1) 許可リストからプログラムと固定引数を選択
    cmdDef, ok := allowed[action]
    if !ok {
        http.Error(w, "invalid action", http.StatusBadRequest)
        return
    }

    // 2) 任意のユーザー入力を引数に加える場合は許可リストで検証
    msg := r.URL.Query().Get("msg")
    args := []string{}
    if msg != "" {
        if !safeText.MatchString(msg) {
            http.Error(w, "invalid msg", http.StatusBadRequest)
            return
        }
        // 定義済みの printf %s\n <msg> 形式を使う
        cmdDef = []string{"/usr/bin/printf", "%s\n", msg}
    }

    // 3) シェルを使わず引数を分けて渡す
    prog := cmdDef[0]
    if len(cmdDef) > 1 {
        args = cmdDef[1:]
    }

    // 4) 実行時間を制限
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    out, err := exec.CommandContext(ctx, prog, args...).CombinedOutput()
    if err != nil {
        http.Error(w, "exec error", http.StatusInternalServerError)
        return
    }

    w.Write(out)
}

func main() {
    http.HandleFunc("/run", safeHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

解説:

  • 変更前: ユーザーの文字列を bash の標準入力へ渡すため、シェルの構文が実行されます。
  • 変更後: 固定の実行ファイルと引数を、シェルを介さず渡します。有効な action と空ではない msg がある場合は、固定形式の printf へ切り替わります。文字の検証は、この限定的なメッセージ用途向けです。2秒のタイムアウトは、メモリ、出力、同時実行数の制限に代わるものではありません。

参考資料