Go 部落格

防路徑穿越的檔案 API

Damien Neil
2025 年 3 月 12 日

當攻擊者欺騙程式開啟非預期檔案時,就會產生路徑穿越漏洞。本文解釋了此類漏洞、一些現有的防禦措施,並介紹了 Go 1.24 中新增的新 os.Root API 如何提供簡單而強大的防禦措施,以防止意外的路徑穿越。

路徑穿越攻擊

“路徑穿越”涵蓋了遵循共同模式的一系列相關攻擊:程式試圖在某個已知位置開啟檔案,但攻擊者導致它開啟位於不同位置的檔案。

如果攻擊者控制檔名的一部分,他們可能會使用相對目錄元件(“..”)來逃離預期位置

f, err := os.Open(filepath.Join(trustedLocation, "../../../../etc/passwd"))

在 Windows 系統上,某些名稱具有特殊含義

// f will print to the console.
f, err := os.Create(filepath.Join(trustedLocation, "CONOUT$"))

如果攻擊者控制本地檔案系統的一部分,他們可能會使用符號連結導致程式訪問錯誤的檔案

// Attacker links /home/user/.config to /home/otheruser/.config:
err := os.WriteFile("/home/user/.config/foo", config, 0o666)

如果程式透過首先驗證預期檔案不包含任何符號連結來防禦符號連結穿越,它仍然可能容易受到 檢查時/使用時(TOCTOU)競態條件的影響,即攻擊者在程式檢查後建立符號連結

// Validate the path before use.
cleaned, err := filepath.EvalSymlinks(unsafePath)
if err != nil {
  return err
}
if !filepath.IsLocal(cleaned) {
  return errors.New("unsafe path")
}

// Attacker replaces part of the path with a symlink.
// The Open call follows the symlink:
f, err := os.Open(cleaned)

另一種 TOCTOU 競態條件涉及在穿越過程中移動構成路徑一部分的目錄。例如,攻擊者提供諸如“a/b/c/../../etc/passwd”之類的路徑,並在開啟操作進行中時將“a/b/c”重新命名為“a/b”。

路徑淨化

在我們全面討論路徑穿越攻擊之前,先從路徑淨化開始。當程式的威脅模型不包含能夠訪問本地檔案系統的攻擊者時,在使用不受信任的輸入路徑之前進行驗證就足夠了。

不幸的是,淨化路徑可能出乎意料地棘手,特別是對於必須同時處理 Unix 和 Windows 路徑的可移植程式。例如,在 Windows 上,filepath.IsAbs(`\foo`) 報告 false,因為路徑 “\foo” 是相對於當前驅動器的。

在 Go 1.20 中,我們添加了 path/filepath.IsLocal 函式,它報告一個路徑是否是“本地的”。一個“本地”路徑是指

  • 不會逃離其評估所在的目錄(不允許“../etc/passwd”);
  • 不是絕對路徑(不允許“/etc/passwd”);
  • 非空(不允許“”);
  • 在 Windows 上,不是保留名稱(不允許“COM1”)。

在 Go 1.23 中,我們添加了 path/filepath.Localize 函式,它將 /- 分隔的路徑轉換為本地作業系統路徑。

接受並操作潛在由攻擊者控制的路徑的程式幾乎都應該使用 filepath.IsLocalfilepath.Localize 來驗證或淨化這些路徑。

超越淨化

當攻擊者可能訪問本地檔案系統的一部分時,路徑淨化是不夠的。

多使用者系統如今已不常見,但攻擊者仍然可以透過多種方式訪問檔案系統。一個解壓縮 tar 或 zip 檔案的工具可能會被誘導提取一個符號連結,然後提取一個穿越該連結的檔名。容器執行時可能會允許不受信任的程式碼訪問本地檔案系統的一部分。

程式可以使用 path/filepath.EvalSymlinks 函式在驗證不受信任的名稱之前解析其中的連結,從而防禦意外的符號連結穿越,但如上所述,這個兩步過程容易受到 TOCTOU 競態條件的影響。

在 Go 1.24 之前,更安全的選擇是使用像 github.com/google/safeopen 這樣的包,它提供了用於在特定目錄中開啟潛在不受信任的檔名的防路徑穿越函式。

引入 os.Root

在 Go 1.24 中,我們在 os 包中引入了新的 API,以防穿越的方式安全地在某個位置開啟檔案。

新的 os.Root 型別表示本地檔案系統中的某個目錄。使用 os.OpenRoot 函式開啟一個根目錄

root, err := os.OpenRoot("/some/root/directory")
if err != nil {
  return err
}
defer root.Close()

Root 提供了對根目錄下檔案進行操作的方法。這些方法都接受相對於根目錄的檔名,並且禁止使用相對路徑元件(“..”)或符號連結逃離根目錄的任何操作。

f, err := root.Open("path/to/file")

Root 允許不會逃離根目錄的相對路徑元件和符號連結。例如,root.Open("a/../b") 是允許的。檔名使用本地平臺的語義進行解析:在 Unix 系統上,這會跟隨“a”中的任何符號連結(只要該連結不逃離根目錄);而在 Windows 系統上,這會開啟“b”(即使“a”不存在)。

Root 當前提供了以下操作集

func (*Root) Create(string) (*File, error)
func (*Root) Lstat(string) (fs.FileInfo, error)
func (*Root) Mkdir(string, fs.FileMode) error
func (*Root) Open(string) (*File, error)
func (*Root) OpenFile(string, int, fs.FileMode) (*File, error)
func (*Root) OpenRoot(string) (*Root, error)
func (*Root) Remove(string) error
func (*Root) Stat(string) (fs.FileInfo, error)

除了 Root 型別,新的 os.OpenInRoot 函式提供了一種簡單的方法,可以在特定目錄中開啟潛在不受信任的檔名

f, err := os.OpenInRoot("/some/root/directory", untrustedFilename)

Root 型別提供了一個簡單、安全、可移植的 API,用於處理不受信任的檔名。

注意事項與考慮事項

Unix

在 Unix 系統上,Root 是使用 openat 系列系統呼叫實現的。一個 Root 包含一個引用其根目錄的檔案描述符,並將在重新命名或刪除後繼續跟蹤該目錄。

Root 可以防禦符號連結穿越,但不限制對掛載點的穿越。例如,Root 不能阻止穿越 Linux 繫結掛載點。我們的威脅模型是 Root 防禦普通使用者可以建立的檔案系統結構(如符號連結),但不處理需要 root 許可權才能建立的檔案系統結構(如繫結掛載點)。

Windows

在 Windows 上,Root 開啟一個引用其根目錄的控制代碼。這個開啟的控制代碼會阻止該目錄被重新命名或刪除,直到 Root 被關閉。

Root 阻止訪問 Windows 的保留裝置名稱,例如 NULCOM1

WASI

在 WASI 上,os 包使用 WASI preview 1 檔案系統 API,這些 API 旨在提供防穿越的檔案系統訪問。然而,並非所有 WASI 實現都完全支援檔案系統沙箱,因此 Root 對穿越的防禦僅限於 WASI 實現提供的功能。

GOOS=js

當 GOOS=js 時,os 包使用 Node.js 檔案系統 API。此 API 不包含 openat 系列函式,因此在此平臺上,os.Root 在符號連結驗證中容易受到 TOCTOU(檢查時/使用時)競態條件的影響。

當 GOOS=js 時,Root 引用的是目錄名而不是檔案描述符,並且在重新命名後不跟蹤目錄。

Plan 9

Plan 9 沒有符號連結。在 Plan 9 上,Root 引用一個目錄名,並對檔名執行詞法淨化。

效能

對包含許多目錄元件的檔名執行的 Root 操作可能比等效的非 Root 操作昂貴得多。解析“..”元件也可能很昂貴。希望限制檔案系統操作開銷的程式可以使用 filepath.Clean 從輸入檔名中刪除“..”元件,並可能希望限制目錄元件的數量。

誰應該使用 os.Root?

如果您滿足以下條件,則應該使用 os.Rootos.OpenInRoot

  • 您正在目錄中開啟檔案;並且
  • 該操作不應該訪問該目錄之外的檔案。

例如,一個將檔案寫入輸出目錄的存檔提取器應該使用 os.Root,因為檔名可能是不可信的,並且將檔案寫入輸出目錄之外是不正確的。

然而,一個將輸出寫入使用者指定位置的命令列程式不應該使用 os.Root,因為檔名是受信任的,並且可以指向檔案系統上的任何位置。

作為一條經驗法則,呼叫 filepath.Join 來組合固定目錄和外部提供檔名的程式碼可能應該改用 os.Root

// This might open a file not located in baseDirectory.
f, err := os.Open(filepath.Join(baseDirectory, filename))

// This will only open files under baseDirectory.
f, err := os.OpenInRoot(baseDirectory, filename)

未來的工作

os.Root API 在 Go 1.24 中是新增的。我們期望在未來的版本中對其進行補充和改進。

當前的實現優先考慮正確性和安全性而非效能。未來的版本將利用特定平臺的 API(例如 Linux 的 openat2)來儘可能提高效能。

Root 還有一些檔案系統操作尚未支援,例如建立符號連結和重新命名檔案。在可能的情況下,我們將新增對這些操作的支援。正在進行中的額外函式列表在 go.dev/issue/67002 中。

下一篇文章:再見核心型別 - 你好,我們熟知並喜愛的 Go!
上一篇文章:從 unique 到 cleanups 和 weak:用於提高效率的新底層工具
部落格索引