Go 1.19 で os/exec パッケージの挙動が変わった話
とある。 さっそく試してみよう。
まず 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
パッケージは律儀にこの環境変数にも対応している。
標準パッケージのソースコード 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 は面倒くさいな(笑)
ブックマーク
参考図書
- プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)
- Alan A.A. Donovan (著), Brian W. Kernighan (著), 柴田 芳樹 (翻訳)
- 丸善出版 2016-06-20
- 単行本(ソフトカバー)
- 4621300253 (ASIN), 9784621300251 (EAN), 4621300253 (ISBN), 9784621300251 (ISBN)
- 評価
著者のひとりは(あの「バイブル」とも呼ばれる)通称 “K&R” の K のほうである。この本は Go 言語の教科書と言ってもいいだろう。