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

Command PATH security in Go by Russ Cox

本日公開した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では、この考え方は最初に第7版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コンパイラ(gccclang)を呼び出します。ここまでは問題なく動作します。では、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 を呼び出す際にも似たようなコードを使っていますが、この場合PATHの探索すら行われません。なぜなら cgo は常にGOROOTから取得されるからです。

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

これは先ほどのスニペットよりもさらに安全です。悪意ある cgo.exe が存在していたとしても、それが実行される可能性は一切ありません。

しかし実は、cgo 自身も、自分が作成した一時ファイルに対してホストのCコンパイラを呼び出しており、つまり次のようなコードを自ら実行しています。

// badpkgディレクトリ内のcgoで実行される
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ユーザーと同様にこの問題の影響を受けます。(もし自分がこれに当てはまるなら、今日を機にPATHからドットを取り除き、Goモジュールを使い始めるとよいでしょう。)

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

修正

go get コマンドが悪意ある gcc.exe をダウンロードして実行してしまうのは、明らかに許容できません。しかし、それを許してしまっている実際の誤りはどこにあるのでしょうか。そして、その修正方法は何でしょうか。

考えられる答えの一つは、cgogo コマンドの実行されたディレクトリではなく、信頼できないソースディレクトリの中でホストのCコンパイラを探索していることが誤りだ、というものです。これが誤りだとすれば、修正方法は go コマンドを変更して、ホストのCコンパイラのフルパスを cgo に渡すようにすることです。そうすれば cgo は信頼できないディレクトリの中でPATH探索を行う必要がなくなります。

もう一つ考えられる答えは、PATH探索の際にドットを見に行くこと自体が誤りだ、というものです。これはWindowsで自動的に起こる場合もあれば、Unixシステムで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.LookPathexec.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 で、(exec.Commandexec.LookPath によるPATH探索において)カレントディレクトリを常に優先するというWindowsの挙動を変更すべきかどうかを議論してきました。変更に賛成する論拠は、この記事で説明したような種類のセキュリティ問題を防げるという点です。これを補強する論拠として、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 として提出しました。

By Russ Cox