Go 1.19 で os/exec パッケージの挙動が変わった話

Go 1.19 のリリースノートを眺めてみると

Command and LookPath no longer allow results from a PATH search to be found relative to the current directory. This removes a common source of security problems but may also break existing programs that depend on using, say, exec.Command("prog") to run a binary named prog (or, on Windows, prog.exe) in the current directory. See the os/exec package documentation for information about how best to update such programs.

とある。 さっそく試してみよう。

まず Windows 環境で gpgpdump.exe コマンドを PATH で指定されたフォルダ以外,具体的には以下のソースファイルと同じフォルダに置く。

package main

import (
    "fmt"
    "os/exec"
)

func main() {
    cmd := "gpgpdump.exe"
    out, err := exec.Command(cmd, "version").CombinedOutput()
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("output by %v:\n%v\n", cmd, string(out))
}

これを Go 1.19 コンパイル環境下で実行すると

> go run sample.go
exec: "gpgpdump.exe": cannot run executable found relative to current directory

「カレントディレクトリに指定の実行ファイルあるけど起動しちゃらん(←超意訳,出雲弁)」とエラーになった。

Windows ではパス指定なしでコマンドを起動する際に,カレントフォルダに同名の実行ファイルが存在すると優先的にそれを起動してしまう。 Go 標準の os/exec パッケージもこの挙動に合わせていたのだが,2020年の CVE-2020-27955 で問題になった。 この挙動を悪用して悪意のコマンドを実行される可能性があるというわけだ。

この脆弱性を回避するために,様々な試行錯誤が行われたが Go 1.19 の改修が決定打になるだろう。 カレントフォルダにある同名の実行ファイルを無視するのではなく,エラーとして「起動させない」というのがポイント。

なお,今まで通りパスなしのコマンド指定時にカレントフォルダの実行ファイルを起動したいなら exec.ErrDot エラーを明示的に潰すことで実現できる。 こんな感じ。

package main

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

func main() {
    cmd := exec.Command("gpgpdump.exe", "version")
    if cmd.Err != nil {
        fmt.Println(cmd.Err)
        if !errors.Is(cmd.Err, exec.ErrDot) {
            return
        }
        cmd.Err = nil
    }
    out, err := cmd.CombinedOutput()
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("output by %v:\n%v\n", cmd, string(out))
}

これを実行すると

> go run sample2.go
exec: "gpgpdump.exe": cannot run executable found relative to current directory
output by .\gpgpdump.exe version:
gpgpdump v0.14.0
repository: https://github.com/goark/gpgpdump

となる。 エラーを無視してカレントディレクトリ . を付加した状態で実行されているのがお分かりだろうか。

ちなみに,同じコードを Windows 以外の環境で実行すると(.exe の拡張子は外してね)

$ go run sample2b.go 
exec: "gpgpdump": executable file not found in $PATH

と PATH 上に実行ファイルが見つからない旨の普通のエラーが表示される。 これでアプリケーション側は OS ごとに処理を分ける必要がなくなったわけだ。 めでたい!

ところで Windows には NoDefaultCurrentDirectoryInExePath なる環境変数があるそうで,これが有効になっているとパスなしのコマンド指定時にカレントフォルダの同名実行ファイルを無視するらしい。

os/exec パッケージは律儀にこの環境変数にも対応している。

On Windows, Command and LookPath now respect the NoDefaultCurrentDirectoryInExePath environment variable, making it possible to disable the default implicit search of “.” in PATH lookups on Windows systems.

標準パッケージのソースコード os/exec/lp_windows.go を眺めると

// LookPath searches for an executable named file in the
// directories named by the PATH environment variable.
// LookPath also uses PATHEXT environment variable to match
// a suitable candidate.
// If file contains a slash, it is tried directly and the PATH is not consulted.
// Otherwise, on success, the result is an absolute path.
//
// In older versions of Go, LookPath could return a path relative to the current directory.
// As of Go 1.19, LookPath will instead return that path along with an error satisfying
// errors.Is(err, ErrDot). See the package documentation for more details.
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, `:\/`) {
        f, err := findExecutable(file, exts)
        if err == nil {
            return f, nil
        }
        return "", &Error{file, err}
    }

    // On Windows, creating the NoDefaultCurrentDirectoryInExePath
    // environment variable (with any value or no value!) signals that
    // path lookups should skip the current directory.
    // In theory we are supposed to call NeedCurrentDirectoryForExePathW
    // "as the registry location of this environment variable can change"
    // but that seems exceedingly unlikely: it would break all users who
    // have configured their environment this way!
    // https://docs.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-needcurrentdirectoryforexepathw
    // See also go.dev/issue/43947.
    var (
        dotf   string
        dotErr error
    )
    if _, found := syscall.Getenv("NoDefaultCurrentDirectoryInExePath"); !found {
        if f, err := findExecutable(filepath.Join(".", file), exts); err == nil {
            if godebug.Get("execerrdot") == "0" {
                return f, nil
            }
            dotf, dotErr = f, &Error{file, ErrDot}
        }
    }

    path := os.Getenv("path")
    for _, dir := range filepath.SplitList(path) {
        if f, err := findExecutable(filepath.Join(dir, file), exts); err == nil {
            if dotErr != nil {
                // https://go.dev/issue/53536: if we resolved a relative path implicitly,
                // and it is the same executable that would be resolved from the explicit %PATH%,
                // prefer the explicit name for the executable (and, likely, no error) instead
                // of the equivalent implicit name with ErrDot.
                //
                // Otherwise, return the ErrDot for the implicit path as soon as we find
                // out that the explicit one doesn't match.
                dotfi, dotfiErr := os.Lstat(dotf)
                fi, fiErr := os.Lstat(f)
                if dotfiErr != nil || fiErr != nil || !os.SameFile(dotfi, fi) {
                    return dotf, dotErr
                }
            }

            if !filepath.IsAbs(f) && godebug.Get("execerrdot") != "0" {
                return f, &Error{file, ErrDot}
            }
            return f, nil
        }
    }

    if dotErr != nil {
        return dotf, dotErr
    }
    return "", &Error{file, ErrNotFound}
}

NoDefaultCurrentDirectoryInExePath 環境変数がない場合だけカレントフォルダ . を付加してチェックしているのが分かる。 ご苦労さんなことである。

少々姑息ではあるが,これを利用してカレントフォルダの同名実行ファイルを無視するよう構成することもできる。 こんな感じ。

package main

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

func main() {
    os.Setenv("NoDefaultCurrentDirectoryInExePath", "1")
    cmd := exec.Command("gpgpdump.exe", "version")
    if cmd.Err != nil {
        fmt.Println(cmd.Err)
        if !errors.Is(cmd.Err, exec.ErrDot) {
            return
        }
        cmd.Err = nil
    }
    out, err := cmd.CombinedOutput()
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("output by %v:\n%v\n", cmd, string(out))
}

これを実行すると

> go run sample3.go
exec: "gpgpdump.exe": executable file not found in %PATH%

となる。 前のコードの実行結果で出力されるエラーメッセージの違いを確かめてほしい。 Windows 以外でこの環境変数が悪さをすることはないだろうから Linux 等と挙動を合わせたいなら,おまじない的にセットしておくのもいいかもしれない。

やっぱ Windows は面倒くさいな(笑)

ブックマーク

参考図書

photo
プログラミング言語Go
アラン・ドノバン (著), ブライアン・カーニハン (著), 柴田芳樹 (著)
丸善出版 2016-06-20 (Release 2021-07-13)
Kindle版
B099928SJD (ASIN)
評価     

Kindle 版出た! 一部内容が古びてしまったが,この本は Go 言語の教科書と言ってもいいだろう。感想はこちら

reviewed by Spiegel on 2021-05-22 (powered by PA-APIv5)