你以為只是 Git Hook?
1. Root Cause:
未經清理的 Path 建構
Self-hosted Git 平台
在
軟體供應鏈
中處於高度特權的位置:它們管理儲存庫(Repository)的檔案系統樹(Filesystem tree)、代表不受信任的輸入執行
git
binary,並經常執行會觸發任意 shell 指令的 hooks。這份報告分析了開源 Git 託管應用程式
Gogs
中最近披露的漏洞鏈、漏洞鏈編號為 CVE-2026-52813 的開源 Git Hosting Application 漏洞鏈,該漏洞可將
organization-name path traversal
提升為完整的
遠端程式碼執行(Remote Code Execution, RCE)
;同時也分析另一個伴隨的
邏輯漏洞
CVE-2026-52810,該漏洞混淆了 Git Smart HTTP protocol 的 pull/push 授權模型
[1]
。以下分析僅限於原始揭露內容中的 source excerpts,並以 sequence diagrams 重建 exploitation flow,而不重現 screenshots。
1. Root Cause:未經清理的 Path 建構
平台透過單一 helper 解析任何帳戶儲存庫在磁碟上的位置,該 helper 會將設定的 repository root 與轉為小寫的帳戶名稱進行 join。一般使用者在註冊時受到嚴格的字元類別限制,因此 directory-traversal 序列無法進入這個 join。然而,Organization 是透過另一個 API request object 建立,該物件省略了相同的 binding 限制,因此同一個 join operation 會接收到完全由攻擊者控制的輸入。
- // repox.go — path resolver shared by users AND organizations
- // filepath.Join performs lexical normalization of ".." segments,
- // so it does NOT act as a containment boundary; it merely
- // concatenates and cleans the path.
- func UserPath(user string) string {
- return filepath.Join(conf.Repository.Root, strings.ToLower(user))
- }
- // Register (used for normal user signup) is constrained by a
- // binding tag: only letters, digits and "-_." are accepted, so
- // "../" can never appear in a registered username.
- type Register struct {
- UserName string `binding:"Required;AlphaDashDot;MaxSize(35)"`
- Email string `binding:"Required;Email;MaxSize(254)"`
- Password string `binding:"Required;MaxSize(255)"`
- }
- // createOrgRequest (used for organization creation) has NO such
- // character-class restriction on UserName — only "Required" is
- // enforced. This asymmetry is the actual root cause: the same
- // UserPath() sink is reachable through a path that skips the
- // sanitizing gate applied to the sibling type.
- type createOrgRequest struct {
- UserName string `json:"username" binding:"Required"`
- FullName string `json:"full_name"`
- }
由於 Organization 名稱會被相同的
UserPath()
sink 使用,因此將
../../../../tmp/test
作為 Organization 名稱送出時,會解析到預期 repository root 之外的位置,而之後在該 Organization 底下建立的任何 repository,都會以 bare Git repository 的形式寫入攻擊者指定的目錄(僅包含 metadata,沒有 working tree)
[1]
。這反映出 Go 漏洞中更廣泛的一類問題:將
filepath.Join
誤認為 jail——類似模式也出現在 2026 年一項影響 local-development tool 的揭露中,其 archive extraction routine 使用
filepath.Join(dest, file.Name)
,卻沒有對 archive entries 執行 post-join containment check
[2]
。
2. 從 Weak Write Primitive 到任意程式碼執行
單獨的 bare repository 是一個 weak primitive:攻擊者無法任意選擇其中的檔案名稱,只能使用固定的 Git metadata layout。已公開的研究透過攻擊平台如何為其網頁檔案編輯器建立實際 working tree 來進一步提升攻擊效果;平台會暫時將其 checkout 到一個可預測、並以目標 repository 的 numeric ID 為索引的 local Path。透過選擇一個會 traversal 到該確切 worktree directory 的 Organization 名稱,攻擊者的 path traversal 可以將 bare repository 放置在合法 worktree
內部
,使其中的
hooks/
directory 可以透過一般 Git operations 進行修改。
so traversal.git/hooks/update cannot be edited via API A->>S: git clone editor.git (native protocol, no UI filter) A->>A: append "id>/tmp/pwned" to traversal.git/hooks/update A->>S: git push (commit touches hooks/update) A->>S: PUT contents/dummy4 on org/traversal (trigger internal commit) S->>FS: internal push into nested traversal.git FS-->>S: Git invokes hooks/update S-->>FS: shell executes attacker payload as service account
攻擊流程:Organization 名稱的 path traversal 與 web editor 的 worktree 機制及 native Git push 串接,進而觸發 malicious update hook。
唯一的障礙是 UI 端的 guard。該 guard 會拒絕任何包含
.git/
區段的編輯 Path,其目的在於阻止透過檔案編輯器直接操作 repository metadata。
- // editor.go — server-side guard against editing inside ".git"
- func isRepositoryGitPath(path string) bool {
- path = strings.ToLower(path)
- return strings.HasSuffix(path, ".git") ||
- strings.Contains(path, ".git/") ||
- strings.Contains(path, `.git\`) ||
- // Windows treats ".git." the same as ".git"
- strings.HasSuffix(path, ".git.") ||
- strings.Contains(path, ".git./") ||
- strings.Contains(path, `.git.\`)
- }
這項檢查只有在 Path 區段
完全
是
.git
時才會觸發;刻意命名為
traversal.git
的 nested repository 不符合上述任何 substring,因此只要選擇一個名稱不是字面上的
.git
的 bare-repository name,就能繞過這項 guard。由於這個 filter 只套用在 web editor 的 HTTP handler,而沒有套用到 native Git smart-protocol push Path,因此攻擊者可以 clone 外層 repository,在 local 端修改
traversal.git/hooks/update
,再進行 push,完全繞過這項檢查。之後對 nested repository 觸發 internal commit,會使 Git 呼叫遭到植入惡意內容的
update
hook,最終以 service account 身分執行指令
[1]
。
3. Protocol-Level Authorization Confusion(CVE-2026-52810)
第二個漏洞與 Path handling 無關,而是利用 authorization middleware 與最終 dispatch request 的 router 之間不一致的 request parsing。Smart HTTP protocol 暴露兩個邏輯 operation——
git-upload-pack
(pull)與
git-receive-pack
(push)——middleware 同時透過 query parameter 與 URL Path 進行分類,而 router 後續則只透過 regular expressions 對 URL Path 進行比對。
- // http.go — authorization classification (middleware)
- // isPull decides Read vs Write access mode for the whole request.
- isPull := c.Query("service") == "git-upload-pack" ||
- strings.HasSuffix(c.Req.URL.Path, "git-upload-pack") ||
- c.Req.Method == "GET"
- // NOTE: an attacker-supplied "?service=git-upload-pack" query
- // string is trusted even on a path that the router will later
- // treat as a receive-pack (write) endpoint.
- mode := database.AccessModeWrite
- if isPull {
- mode = database.AccessModeRead
- }
由於 router 的 endpoint dispatch 不會參考
service
query parameter,因此對
/git-receive-pack
Path 發送一個帶有
?service=git-upload-pack
的 request 時,該 request 會以 read 的身分取得授權,但實際執行時卻會被當成 write endpoint。要利用這項問題,只需要一個輕量級 proxy,在 Git 自己的 push logic 發出 request 前,修改 outgoing push requests 並附加具有誤導性的 query parameter,即可有效地讓 authorization 將 write 降級為 read。
authorization middleware(由 query 驅動)與 routing layer(由 Path 驅動)之間的混淆,使 read-only account 能夠存取 write handler。
在 public repositories 上還會出現一個有趣的副作用:middleware 對 anonymous pulls 有一個 early return,會完全跳過 authenticated-user context 的填入。由於 confusion 迫使這個 branch 即使在語意上是 push 的情況下也會被採用,因此 downstream write handler 會取用一個從未設定的 user object:
- // http.go — early return taken when a request is misclassified as a pull
- // on a repository that does not require sign-in to read.
- if isPull && !repo.IsPrivate && !conf.Auth.RequireSigninView {
- c.Map(&HTTPContext{
- Context: c, // AuthUser intentionally left nil here
- })
- return
- }
- // repo_editor.go later assumes AuthUser is non-nil:
- // EnvAuthUserID + "=" + strconv.FormatInt(opts.AuthUser.ID, 10)
- // -> nil pointer dereference (crash) on fully public repos,
- // but a *successful* unauthorized write on private repos
- // or instances requiring sign-in to view, where this branch
- // is not taken and AuthUser is populated normally.
上游公開的 patch 將 classification 縮小到只依據 URL Path,但 patch 的 exact-match check 與 router 的 case-insensitive matching 之間仍存在大小寫處理落差(例如
git-RECEIVE-pack
),留下繞過方式。這說明針對 routing-layer confusion 所進行的 authorization 修補,必須與 router 本身的 normalization rules 一致,而不能只限制 keyword comparison
[1]
。
4. Comparative Analysis
這種提權模式——由 weak sanitization 控制的 filesystem write primitive,再與無關元件串接以取得程式碼執行——會反覆出現在不同的裝置與基礎架構類別中。在 embedded browser-service component 中,一個同樣驗證不足的檔案 Path parameter 允許取得任意檔案,再與另一個 authentication-bypass 漏洞結合,最終取得完整的裝置控制權(
前期文章中對 WebOS path traversal 與 authentication bypass 串接的分析
)。其中的結構性教訓完全相同:單一 traversal primitive 本身很少具有致命性,但一旦與第二個各自看似不嚴重的漏洞結合,就可能成為關鍵問題。類似模式也出現在 cluster-admission infrastructure 中,一個驗證不足的 filename 被送入 directory-traversal condition;雖然單獨來看嚴重性較低,但與另一個 distinct injection flaw 結合後,便成為更廣泛攻擊鏈的墊腳石(
前期文章中對 Ingress-Nginx admission-controller vulnerability chain 的分析
)。這兩個案例都再次證明,缺乏明確 post-join containment check 的
filepath.Join
-style sinks,如前述 archive-extraction 案例所記錄
[2]
,在不同語言與產品類別中仍是反覆出現的 Root Cause,與具體的 application domain 無關。
5. Conclusion
Gogs 的這個案例顯示,一個未經清理、並送入 path-join operation 的 string field,在應用程式同時提供 editor-worktree 功能與 native Git hook execution 時,可以一路演變成 remote code execution。另一方面,authorization-confusion 漏洞也顯示,分層的 request classification——一個元件讀取 query parameter,而另一個元件只讀取 URL——如果每一層沒有以完全一致的方式進行 input normalization,就天生十分脆弱。因此,有效的修補方式必須在所有能夠抵達共用 filesystem sink 的 entry points 套用相同的 character-class constraints,並將 protocol classification 集中到單一 authoritative function,讓 authorization 與 routing logic 都使用相同的分類結果。