The Go Blog

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

Russ Cox
2021年1月19日

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

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

goコマンドの設計目標の一つは、go buildgo docgo getgo installgo listを含むほとんどのコマンドが、インターネットからダウンロードされた任意のコードを実行しないことです。いくつかの明白な例外があります。明らかにgo rungo testgo generateは、それらの役割として任意のコードを実行します。しかし、再現可能なビルドやセキュリティなど様々な理由から、他のコマンドは実行してはなりません。したがって、go getが任意のコードを実行するように騙される可能性がある場合、それはセキュリティバグと見なされます。

もし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ファイルと、パッケージに含まれるCソースをビルドするためにホストCコンパイラ(gccまたはclang)を呼び出します。これらはすべてうまく機能します。しかし、goコマンドはホストCコンパイラをどこで見つけるのでしょうか?もちろん、PATHを検索します。幸いなことに、GoコマンドはCコンパイラをパッケージのソースディレクトリで実行しますが、PATHルックアップはgoコマンドが呼び出された元のディレクトリから行われます。

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

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

goコマンドはcgoを呼び出すために同様のコードを使用しており、この場合はPATHルックアップすらありません。なぜなら、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で実行されているため、システムgccを見つける代わりに、badpkg\gcc.exeが存在すればそれを実行します。

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

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

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

修正

go getコマンドが悪意のあるgcc.exeをダウンロードして実行するのは明らかに許容できません。しかし、それを許してしまう本当の過ちは何でしょうか?そして、修正策は何でしょうか?

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

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

私たちは両方が間違いであると判断し、両方の修正を適用しました。goコマンドは、ホストCコンパイラの完全なパスをcgoに渡すようになりました。それに加えて、cgogo、およびGoディストリビューション内の他のすべてのコマンドは、以前であればドットから実行可能ファイルを使用していた場合にエラーを報告するos/execパッケージのバリアントを使用するようになりました。go/buildおよびgo/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のデフォルトでのセキュリティ強化

exec.Commandexec.LookPathでのPATHルックアップにおいて、常に現在のディレクトリを優先するというWindowsの動作を変更すべきかどうかについて、golang.org/issue/38736で議論してきました。変更賛成の議論は、このブログ記事で議論された種類のセキュリティ問題を解決するということです。補足的な議論として、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にジェネリクスを追加するための提案
ブログインデックス