Go 部落格
Go 語言中的命令列 PATH 安全
今天的Go 安全版本修復了一個問題,該問題涉及在不受信任的目錄中進行 PATH 查詢,這可能導致在 `go get` 命令執行期間進行遠端執行。我們預計人們會就這到底意味著什麼以及他們的程式中是否存在問題提出疑問。本文詳細介紹了這個錯誤、我們應用的修復、如何判斷您自己的程式是否容易受到類似問題的影響以及如果受到影響您可以怎麼做。
Go 命令與遠端執行
`go` 命令的設計目標之一是大多數命令——包括 `go build`、`go doc`、`go get`、`go install` 和 `go list`——不執行從網際網路下載的任意程式碼。有幾個明顯的例外:顯然 `go run`、`go test` 和 `go generate` 確實會執行任意程式碼——這是它們的工作。但其他命令不能這樣做,原因有很多,包括可重現的構建和安全性。因此,當 `go get` 可以被誘騙執行任意程式碼時,我們將其視為一個安全漏洞。
如果 `go get` 不能執行任意程式碼,那麼不幸的是,這意味著它呼叫的所有程式,例如編譯器和版本控制系統,也在安全邊界之內。例如,我們過去曾遇到過這樣的問題:巧妙地利用晦澀的編譯器功能或版本控制系統中的遠端執行漏洞,變成了 Go 中的遠端執行漏洞。(值得一提的是,Go 1.16 旨在透過引入 GOVCS 設定來改善這種情況,該設定允許配置允許哪些版本控制系統以及何時允許。)
然而,今天的錯誤完全是我們的錯,而不是 `gcc` 或 `git` 的錯誤或晦澀功能。該錯誤涉及 Go 和其他程式如何找到其他可執行檔案,因此我們需要花一點時間來研究它,然後才能深入瞭解細節。
命令、PATH 和 Go
所有作業系統都有一個可執行路徑(Unix 上是 `$PATH`,Windows 上是 `%PATH%`;為簡單起見,我們只使用術語 PATH)的概念,它是一個目錄列表。當您在 shell 提示符下鍵入命令時,shell 會依次在每個列出的目錄中查詢名稱與您鍵入的命令相同的可執行檔案。它會執行找到的第一個檔案,或者列印一條訊息,例如“command not found”。
在 Unix 上,這個想法首次出現在第七版 Unix 的 Bourne shell (1979) 中。手冊解釋道:
shell 引數 `$PATH` 定義了包含命令的目錄的搜尋路徑。每個備用目錄名都由冒號(`:`)分隔。預設路徑是 `:/bin:/usr/bin`。如果命令名包含 `/`,則不使用搜索路徑。否則,會在路徑中的每個目錄中搜索可執行檔案。
請注意預設值:當前目錄(此處用空字串表示,但我們稱之為“點”)列在 `/bin` 和 `/usr/bin` 之前。MS-DOS 和後來的 Windows 選擇硬編碼這種行為:在這些系統上,點總是首先自動搜尋,然後才考慮 `%PATH%` 中列出的任何目錄。
正如 Grampp 和 Morris 在他們的經典論文“UNIX 作業系統安全”(1984 年)中指出的那樣,將點放在 PATH 中系統目錄前面意味著,如果您 `cd` 到一個目錄並執行 `ls`,您可能會從該目錄獲取一個惡意副本而不是系統工具。如果您能誘騙系統管理員在以 `root` 身份登入時在您的主目錄中執行 `ls`,那麼您就可以執行任何您想要的程式碼。由於這個問題和類似的問題,幾乎所有現代 Unix 發行版都將新使用者的預設 PATH 設定為排除點。但 Windows 系統無論 PATH 如何設定,都會繼續首先搜尋點。
例如,當您輸入命令
go version
在典型配置的 Unix 系統上,shell 會從您的 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()
其行為與在作業系統 shell 中鍵入 `go version` 相同。在 Windows 上,如果 `.\go.exe` 存在,它將執行該檔案。
(值得注意的是,Windows PowerShell 改變了這種行為,放棄了對點的隱式搜尋,但 `cmd.exe` 和 Windows C 庫的 SearchPath 函式
仍然像以前一樣執行。Go 繼續與 `cmd.exe` 匹配。)
這個錯誤
當 `go get` 下載並構建包含 `import "C"` 的包時,它會執行一個名為 `cgo` 的程式,以準備相應 C 程式碼的 Go 等效項。`go` 命令在包含包原始檔的目錄中執行 `cgo`。一旦 `cgo` 生成了其 Go 輸出檔案,`go` 命令本身就會在生成的 Go 檔案上呼叫 Go 編譯器,並呼叫主機 C 編譯器(`gcc` 或 `clang`)來構建包中包含的任何 C 原始檔。所有這些都執行良好。但是 `go` 命令在哪裡找到主機 C 編譯器呢?它當然會在 PATH 中查詢。幸運的是,雖然它在包源目錄中執行 C 編譯器,但它從最初呼叫 `go` 命令的目錄中進行 PATH 查詢。
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 編譯器,這意味著它自己執行了這段程式碼。
// running in cgo in badpkg dir
cmd := exec.Command("gcc", "tmpfile.c")
cmd.Run()
現在,因為 cgo 本身在 `badpkg` 中執行,而不是在執行 `go` 命令的目錄中,如果 `badpkg\gcc.exe` 檔案存在,它將執行該檔案,而不是找到系統 `gcc`。
因此,攻擊者可以建立一個使用 cgo 幷包含 `gcc.exe` 的惡意包,然後任何執行 `go get` 下載並構建攻擊者包的 Windows 使用者都將執行攻擊者提供的 `gcc.exe`,而不是系統路徑中的任何 `gcc`。
Unix 系統避免此問題,首先是因為點通常不在 PATH 中,其次是因為模組解包不會在其寫入的檔案上設定執行位。但是,如果 Unix 使用者在 PATH 中將點放在系統目錄之前並使用 GOPATH 模式,他們將與 Windows 使用者一樣容易受到攻擊。(如果這描述了您,那麼今天是將點從您的路徑中刪除並開始使用 Go 模組的好日子。)
修復
`go get` 命令下載並執行惡意的 `gcc.exe` 顯然是不可接受的。但是導致這種情況的實際錯誤是什麼?然後如何修復?
一個可能的答案是,錯誤在於 `cgo` 在不受信任的源目錄中搜索宿主 C 編譯器,而不是在呼叫 `go` 命令的目錄中。如果這是錯誤,那麼修復方法是更改 `go` 命令,將宿主 C 編譯器的完整路徑傳遞給 `cgo`,這樣 `cgo` 就不必在不受信任的目錄中進行 PATH 查詢。
另一個可能的答案是,錯誤在於 PATH 查詢期間查詢當前目錄,無論是在 Windows 上自動發生還是由於 Unix 系統上的顯式 PATH 條目。使用者可能希望在當前目錄中查詢他們在控制檯或 shell 視窗中鍵入的命令,但他們不太可能也希望在那裡查詢鍵入命令的子程序的子程序。如果這是錯誤,那麼修復方法是更改 `cgo` 命令,使其在 PATH 查詢期間不查詢當前目錄。
我們認為兩者都是錯誤,所以我們應用了這兩個修復。`go` 命令現在將完整的宿主 C 編譯器路徑傳遞給 `cgo`。在此之上,`cgo`、`go` 和 Go 發行版中的所有其他命令現在都使用 `os/exec` 包的一個變體,如果它以前會使用來自當前目錄的可執行檔案,則會報告錯誤。`go/build` 和 `go/import` 包對其呼叫 `go` 命令和其他工具也使用相同的策略。這應該能杜絕任何可能潛伏的類似安全問題。
出於謹慎起見,我們還在 `goimports` 和 `gopls` 等命令以及 `golang.org/x/tools/go/analysis` 和 `golang.org/x/tools/go/packages` 庫中進行了類似的修復,這些庫將 `go` 命令作為子程序呼叫。如果您在不受信任的目錄中執行這些程式——例如,如果您 `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,關於是否應該更改 Windows 在 PATH 查詢(在 `exec.Command` 和 `exec.LookPath` 期間)中總是優先選擇當前目錄的行為。支援此更改的論點是它解決了本部落格文章中討論的安全問題。一個輔助論點是,儘管 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 泛型提案
部落格索引