Goブログ

GoにおけるコマンドPATHのセキュリティ

Russ Cox
2021年1月19日

本日のGoセキュリティリリースでは、信頼できないディレクトリでのPATHルックアップに関連する問題を修正しました。この問題は、go getコマンドの実行中にリモートコード実行につながる可能性があります。 この記事では、このバグの詳細、適用された修正、自分のプログラムが同様の問題に対して脆弱かどうかを判断する方法、そして脆弱な場合の対処方法について説明します。

Goコマンドとリモートコード実行

goコマンドの設計目標の1つは、go buildgo docgo getgo installgo listなど、ほとんどのコマンドがインターネットからダウンロードされた任意のコードを実行しないことです。 明らかに、go rungo testgo generateは任意のコードを実行します。それがこれらのコマンドの役割です。 しかし、再現性のあるビルドやセキュリティなど、さまざまな理由から、他のコマンドは任意のコードを実行してはいけません。そのため、go getが任意のコードを実行するように仕向けられると、セキュリティバグと見なされます。

go getが任意のコードを実行してはならない場合、残念ながら、コンパイラやバージョン管理システムなど、それが呼び出すすべてのプログラムもセキュリティ境界内にあることを意味します。たとえば、過去には、不明瞭なコンパイラ機能の巧妙な使用やバージョン管理システムのリモートコード実行のバグが、Goのリモートコード実行のバグになったという問題がありました。(ちなみに、Go 1.16では、どのバージョン管理システムをいつ許可するかを正確に設定できるGOVCS設定を導入することで、この状況の改善を目指しています。)

しかし、本日のバグは、gccgitのバグや不明瞭な機能ではなく、完全に私たちの側のミスでした。 このバグは、Goや他のプログラムが他の実行可能ファイルを見つける方法に関連しているため、詳細に入る前に、少し時間をかけてそれを見ていく必要があります。

コマンド、PATH、そしてGo

すべてのオペレーティングシステムには、実行可能パス(Unixでは$PATH、Windowsでは%PATH%、ここでは簡潔にするためにPATHと呼びます)の概念があり、これはディレクトリのリストです。 シェルプロンプトにコマンドを入力すると、シェルはリストされた各ディレクトリを順番に検索し、入力した名前の実行可能ファイルを探します。 見つかった最初のファイルを実行するか、「command not found」のようなメッセージを出力します。

Unixでは、このアイデアはSeventh Edition UnixのBourneシェル(1979)に初めて登場しました。 マニュアルには次のように説明されています。

シェルパラメータ$PATHは、コマンドを含むディレクトリの検索パスを定義します。 各代替ディレクトリ名はコロン(:)で区切られます。 デフォルトパスは:/bin:/usr/binです。 コマンド名に/が含まれている場合、検索パスは使用されません。 それ以外の場合、パス内の各ディレクトリで実行可能ファイルが検索されます。

デフォルトに注目してください。カレントディレクトリ(ここでは空の文字列で表されていますが、「ドット」と呼びます)は、/bin/usr/binの前にリストされています。 MS-DOS、そしてWindowsは、その動作をハードコードすることを選択しました。これらのシステムでは、%PATH%にリストされているディレクトリを考慮する前に、ドットが常に最初に自動的に検索されます。

GramppとMorrisが古典的な論文「UNIX Operating System Security」(1984)で指摘したように、PATHでシステムディレクトリの前にドットを配置すると、ディレクトリにcdしてlsを実行すると、システムユーティリティではなく、そのディレクトリにある悪意のあるコピーが実行される可能性があります。 また、システム管理者がrootとしてログインしている間に、あなたのホームディレクトリでlsを実行するように仕向けることができれば、あなたは好きなコードを実行できます。 この問題やその他同様の問題のため、基本的にすべての最新のUnixディストリビューションでは、新しいユーザーのデフォルトPATHはドットを除外するように設定されています。 しかし、Windowsシステムでは、PATHの内容に関係なく、ドットが最初に検索されます。

たとえば、コマンド

go version

を、標準的に設定されたUnixで入力すると、シェルはPATHにあるシステムディレクトリからgo実行可能ファイルを実行します。 しかし、Windowsでこのコマンドを入力すると、cmd.exeは最初にドットをチェックします。 .\go.exe(または.\go.batなど、他の多くの選択肢)が存在する場合、cmd.exeはPATHにある実行可能ファイルではなく、その実行可能ファイルを実行します。

Goの場合、PATH検索はexec.LookPathによって処理され、exec.Commandによって自動的に呼び出されます。 また、ホストシステムにうまく適合させるために、Goのexec.LookPathは、UnixではUnixのルールを、WindowsではWindowsのルールを実装しています。 たとえば、このコマンド

out, err := exec.Command("go", "version").CombinedOutput()

は、オペレーティングシステムのシェルにgo versionと入力するのと同じ動作をします。 Windowsでは、.\go.exeが存在する場合、それを実行します。

(Windows PowerShellはこの動作を変更し、ドットの暗黙的な検索を廃止しましたが、cmd.exeとWindows CライブラリのSearchPath関数は、従来どおりの動作を続けています。Goはcmd.exeと一致し続けます。)

バグ

go getimport "C"を含むパッケージをダウンロードしてビルドする場合、関連するCコードのGo相当物を準備するためにcgoと呼ばれるプログラムを実行します。 goコマンドは、パッケージソースを含むディレクトリでcgoを実行します。 cgoがGo出力ファイルを生成すると、goコマンド自体が生成されたGoファイルに対してGoコンパイラを、パッケージに含まれるCソースをビルドするためにホストCコンパイラ(gccまたはclang)を呼び出します。 これはすべてうまく動作します.しかし、goコマンドはどこでホストCコンパイラを見つけるのでしょうか? もちろん、PATHで検索します.幸いなことに、Cコンパイラはパッケージソースディレクトリで実行されますが、PATHルックアップはgoコマンドが呼び出された元のディレクトリから実行されます.

cmd := exec.Command("gcc", "file.c")
cmd.Dir = "badpkg"
cmd.Run()

そのため、Windowsシステムにbadpkg\gcc.exeが存在する場合でも、このコードスニペットはそれを見つけません。 exec.Commandで行われるルックアップは、badpkgディレクトリについては知りません。

goコマンドは同様のコードを使用してcgoを呼び出しますが、その場合はパスルックアップすら行われません。なぜなら、cgoは常にGOROOTから来るからです.

cmd := exec.Command(GOROOT+"/pkg/tool/"+GOOS_GOARCH+"/cgo", "file.go")
cmd.Dir = "badpkg"
cmd.Run()

これは前のスニペットよりもさらに安全です。存在する可能性のある不正なcgo.exeを実行する可能性はありません。

しかし、cgo自体も、作成した一時ファイルに対してホストCコンパイラを呼び出すことが判明しました。つまり、cgo自体がこのコードを実行します.

// running in cgo in badpkg dir
cmd := exec.Command("gcc", "tmpfile.c")
cmd.Run()

ここで、cgo自体はgoコマンドが実行されたディレクトリではなく、`badpkg`で実行されているため、`badpkg\gcc.exe`というファイルが存在する場合は、システムの`gcc`を見つける代わりに、それを実行します。

そのため、攻撃者は`cgo`を使用し、`gcc.exe`を含む悪意のあるパッケージを作成することができ、攻撃者のパッケージをダウンロードしてビルドするために`go` `get`を実行するWindowsユーザーは、システムパスにある`gcc`ではなく、攻撃者が提供した`gcc.exe`を実行することになります.

Unixシステムでは、まずドットが通常PATHに含まれていないこと、次にモジュールアンパックが書き込むファイルに実行ビットを設定しないことから、この問題を回避できます。 しかし、PATHでシステムディレクトリの前にドットがあり、GOPATHモードを使用しているUnixユーザーは、Windowsユーザーと同じように脆弱になります。(もしこれがあなたに当てはまるなら、今日がパスからドットを削除し、Goモジュールの使用を開始する良い日です。)

(RyotaKがこの問題を報告してくれたことに感謝します。)

修正

go getコマンドが悪意のあるgcc.exeをダウンロードして実行することは明らかに受け入れられません。 しかし、それを可能にする実際の間違いは何でしょうか? そして、修正は何でしょうか?

考えられる答えの1つは、間違いは、cgoがホストCコンパイラの検索を、goコマンドが呼び出されたディレクトリではなく、信頼できないソースディレクトリで行っていることです。 これが間違いである場合、修正は、goコマンドがホストCコンパイラのフルパスをcgoに渡すように変更することです。そうすれば、cgoは信頼できないディレクトリでPATHルックアップを行う必要がなくなります。

もう1つの考えられる答えは、Windowsで自動的に行われるか、Unixシステムで明示的なPATHエントリが原因で、PATHルックアップ中にドットを検索するのが間違いであることです。 ユーザーはコンソールまたはシェルウィンドウに入力したコマンドを見つけるためにドットを検索したいかもしれませんが、入力したコマンドのサブプロセスのサブプロセスを見つけるためにドットを検索したいとは思わないでしょう。 これが間違いである場合、修正は、cgoコマンドがPATHルックアップ中にドットを検索しないように変更することです。

私たちはどちらも間違いだと判断したので、両方の修正を適用しました。 goコマンドは、ホストCコンパイラのフルパスをcgoに渡すようになりました。 さらに、cgogo、およびGoディストリビューションの他のすべてのコマンドは、以前にドットからの実行可能ファイルを使用していた場合にエラーを報告する、os/execパッケージのバリアントを使用するようになりました。 パッケージgo/buildgo/importは、goコマンドや他のツールを呼び出す際に同じポリシーを使用します。 これは、潜んでいる可能性のある同様のセキュリティ問題をすべてシャットアウトするはずです。

念のため、goimportsgoplsなどのコマンド、およびgoコマンドをサブプロセスとして呼び出すライブラリgolang.org/x/tools/go/analysisgolang.org/x/tools/go/packagesにも同様の修正を行いました。 これらのプログラムを信頼できないディレクトリで実行する場合(たとえば、信頼できないリポジトリをgit checkoutしてcdし、これらのプログラムを実行する場合、Windowsを使用するか、PATHにドットを含むUnixを使用する場合)、これらのコマンドのコピーも更新する必要があります。 コンピューター上の信頼できないディレクトリがgo getによって管理されるモジュールキャッシュにあるディレクトリのみである場合は、新しいGoリリースのみが必要です。

新しいGoリリースに更新した後、次のコマンドを使用して最新のgoplsに更新できます.

GO111MODULE=on \
go get golang.org/x/tools/gopls@v0.6.4

また、次のコマンドを使用して最新のgoimportsまたは他のツールに更新できます.

GO111MODULE=on \
go get golang.org/x/tools/cmd/goimports@v0.1.0

golang.org/x/tools/go/packagesに依存するプログラムは、作成者が更新する前でさえ、go `get`中に依存関係の明示的なアップグレードを追加することで更新できます。

GO111MODULE=on \
go get example.com/cmd/thecmd golang.org/x/tools@v0.1.0

go/buildを使用するプログラムの場合は、更新されたGoリリースを使用して再コンパイルすれば十分です。

繰り返しになりますが、これらの他のプログラムを更新する必要があるのは、Windows ユーザー、または PATH にドット(.)が含まれていて*かつ*信頼できないソースディレクトリ(悪意のあるプログラムが含まれている可能性がある)でこれらのプログラムを実行する Unix ユーザーのみです。

自分のプログラムは影響を受けますか?

独自のプログラムで `exec.LookPath` または `exec.Command` を使用している場合、懸念する必要があるのは、自分(またはユーザー)が信頼できないコンテンツを含むディレクトリでプログラムを実行する場合のみです。その場合、システムディレクトリではなくドットから実行可能ファイルを使用して、サブプロセスが開始される可能性があります。(繰り返しますが、ドットから実行可能ファイルを使用するのは、Windows では常に発生し、Unix ではまれな PATH 設定の場合のみ発生します。)

懸念がある場合は、より制限された `os/exec` のバリアントを golang.org/x/sys/execabs として公開しています。プログラムでこれを使用するには、単に

import "os/exec"

import exec "golang.org/x/sys/execabs"

に置き換え、再コンパイルします。

os/exec のデフォルトでのセキュリティ保護

golang.org/issue/38736 では、PATH ルックアップで現在のディレクトリを常に優先する Windows の動作(`exec.Command` および `exec.LookPath` 中)を変更する必要があるかどうかについて議論してきました。変更に賛成する意見は、このブログ投稿で説明されている種類のセキュリティ問題を解消できるというものです。変更を支持するもう1つの意見は、Windows の `SearchPath` API と `cmd.exe` は依然として常に現在のディレクトリを検索しますが、`cmd.exe` の後継である PowerShell は検索しないということです。これは、元の動作が間違いであったことを暗に認めていると考えられます。変更に反対する意見は、現在のディレクトリにあるプログラムを意図的に検索する既存の Windows プログラムが壊れる可能性があるというものです。そのようなプログラムがいくつ存在するかはわかりませんが、PATH ルックアップが現在のディレクトリを完全にスキップし始めると、原因不明のエラーが発生します。

`golang.org/x/sys/execabs` で採用したアプローチは、妥当な妥協点かもしれません。これは、古い PATH ルックアップの結果を見つけ、現在のディレクトリからの結果を使用するのではなく、明確なエラーを返します。`prog.exe` が存在する場合に `exec.Command("prog")` から返されるエラーは、次のようになります。

prog resolves to executable in current directory (.\prog.exe)

動作が変更されるプログラムの場合、このエラーは発生したことを非常に明確に示します。現在のディレクトリからプログラムを実行することを意図するプログラムは、代わりに `exec.Command("./prog")` を使用できます(この構文は、Windows でも、すべてのシステムで機能します)。

このアイデアは、新しい提案として golang.org/issue/43724 に提出されています。

次の記事:VS Code Go 拡張機能で Gopls がデフォルトで有効化
前の記事:Go にジェネリクスを追加する提案
ブログインデックス