Go でサブプロセスを起動する際は LookPath に気をつけろ!

公開:2020/11/07
更新:2020/11/09
5 min読了の目安(約4500字TECH技術記事

先日 Git for Windows 2.29.2 (2) がリリースされたのだが,この中で Git LFS の脆弱性の修正が行われている。

この脆弱性は Windows 環境特有のもので

On Windows, if Git LFS operates on a malicious repository with a git.bat or git.exe file in the current directory, that program is executed, permitting the attacker to execute arbitrary code.

Windows では PATH が通ってなくても(パスなしで指定すれば)カレント・フォルダの実行モジュールを起動できるので,そこに malware を紛れ込ませてユーザに起動させることが可能,というわけ。3年前くらいに流行った DLL 読み込みに関する脆弱性のバリエーションと考えると分かりやすいだろう。

もの知らずで申し訳ないが,私は今回の件まで Git LFSGo 製とは知らなかった(笑) じゃあ Go では外部コマンドの呼び出しをどうやっているのかというと os/exec 標準パッケージが用意されている。

package main

import (
    "fmt"
    "log"
    "os/exec"
)

func main() {
    out, err := exec.Command("date").Output()
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("The date is %s\n", out)
}

(実行結果はこちら

この exec.Command() 関数の中身を見ると

func Command(name string, arg ...string) *Cmd {
    cmd := &Cmd{
        Path: name,
        Args: append([]string{name}, arg...),
    }
    if filepath.Base(name) == name {
        if lp, err := LookPath(name); err != nil {
            cmd.lookPathErr = err
        } else {
            cmd.Path = lp
        }
    }
    return cmd
}

てな感じで,直接コマンド名を渡してるわけではなく,いったん exec.LookPath() 関数でパスに展開してから渡している。この関数が問題なのだ。

exec.LookPath() 関数は OS 毎に別実装になっていて,たとえば Windows では lp_windows.go というファイルにこんな感じで実装されている(長めのコードでゴメン)。

func LookPath(file string) (string, error) {
    var exts []string
    x := os.Getenv(`PATHEXT`)
    if x != "" {
        for _, e := range strings.Split(strings.ToLower(x), `;`) {
            if e == "" {
                continue
            }
            if e[0] != '.' {
                e = "." + e
            }
            exts = append(exts, e)
        }
    } else {
        exts = []string{".com", ".exe", ".bat", ".cmd"}
    }

    if strings.ContainsAny(file, `:\/`) {
        if f, err := findExecutable(file, exts); err == nil {
            return f, nil
        } else {
            return "", &Error{file, err}
        }
    }
    if f, err := findExecutable(filepath.Join(".", file), exts); err == nil {
        return f, nil
    }
    path := os.Getenv("path")
    for _, dir := range filepath.SplitList(path) {
        if f, err := findExecutable(filepath.Join(dir, file), exts); err == nil {
            return f, nil
        }
    }
    return "", &Error{file, ErrNotFound}
}

注目は

if f, err := findExecutable(filepath.Join(".", file), exts); err == nil {
    return f, nil
}

の部分で,パス指定のないコマンド名に対してわざわざカレント・フォルダ . を付加して優先的にチェックしてるのだ。なんちうおせっかいな orz

ちなみに UNIX 版(lp_unix.go ファイル)では,環境変数 PATH で明示しない限り,そんなことはしない。

func LookPath(file string) (string, error) {
    if strings.Contains(file, "/") {
        err := findExecutable(file)
        if err == nil {
            return file, nil
        }
        return "", &Error{file, err}
    }
    path := os.Getenv("PATH")
    for _, dir := range filepath.SplitList(path) {
        if dir == "" {
            dir = "."
        }
        path := filepath.Join(dir, file)
        if err := findExecutable(path); err == nil {
            return path, nil
        }
    }
    return "", &Error{file, ErrNotFound}
}

拡張子のチェックもしないし,シンプルって素晴らしい!

Windows 環境でパスの通っていないカレントのコマンドを安直に実行しないようにするには exec.Command() 関数にコマンド名を渡す前にパス名に展開するか, exec.Cmdインスタンスの Path 要素にパスに展開したコマンド名を直接セットするしかないだろう。 Git LFS では LookPath() 関数をカスタマイズしたものを実装し,直接 Path 要素をセットし直しているようだ。

というわけで os/exec パッケージでサブプロセス起動を正確に制御したい場合には LookPath() 関数に注意しましょう,ということで。

どっとはらい