CVE-2026-52811: Gogs: UploadRepoFiles writes outside repo working tree via committed parent sym
Summary
(*Repository).UploadRepoFiles checks for symlinks only on the leaf of the upload target (osx.IsSymlink(targetPath)). The siblings UpdateRepoFile, DeleteRepoFile, and GetDiffPreview use hasSymlinkInPath, which lstats every component — UploadRepoFiles is the lone outlier. An attacker with repo-write access plus a multipart upload whose filename contains a literal backslash (preserved by filepath.Base on Linux, then converted to / by pathx.Clean) redirects the write through a previously-committed directory symlink. iox.CopyFile opens the destination with os.Create (no O_NOFOLLOW), so the kernel follows the parent symlink and writes attacker bytes anywhere the gogs UID can write — ~git/.ssh/authorized_keys → SSH foothold, or <repo>.git/hooks/post-receive → next-push RCE.
Windows builds are unaffected: filepath.Base treats \ as a separator (strips the multi-segment trick) and git defaults core.symlinks=false at checkout (committed mode-120000 entries become text files, not real symlinks).
Details
The asymmetric check at internal/database/repo_editor.go:601-612:
targetPath := path.Join(dirPath, upload.Name)
if osx.IsSymlink(targetPath) { // ← LEAF-ONLY
return errors.Newf("cannot overwrite symbolic link: %s", upload.Name)
}
if err = iox.CopyFile(tmpPath, targetPath); err != nil { ... }
vs. UpdateRepoFile’s correct walker at internal/database/repo_editor.go:163:
if hasSymlinkInPath(localPath, opts.OldTreeName) || hasSymlinkInPath(localPath, opts.NewTreeName) {
return errors.New("cannot update file with symbolic link in path")
}
hasSymlinkInPath (internal/database/repo_editor.go:120-131) lstats every component; osx.IsSymlink (internal/osx/osx.go:35-41) is os.Lstat mode-bit on the leaf — fine inside the loop, wrong as a single call.
Multi-segment upload.Name reaches the loop because: (1) c.Req.FormFile("file") returns *multipart.FileHeader whose Filename is filepath.Base(filename) — Linux only treats / as separator, so backslashes are preserved; (2) NewUpload calls pathx.Clean (internal/pathx/pathx.go:13-16) which does strings.ReplaceAll(p, "\\", "/") — converting backslashes to forward slashes; (3) upload.Name = "evil/foo" is persisted and joined into path.Join(dirPath, upload.Name). iox.CopyFile at internal/iox/iox.go:24 uses os.Create(dst) = OpenFile(dst, O_RDWR|O_CREATE|O_TRUNC, ...) — no O_NOFOLLOW, kernel follows symlinks in path. Git’s default core.symlinks=true on Linux materialises pushed mode-120000 trees as real symlinks at the next UpdateLocalCopyBranch.
Suggested fix
- Replace the leaf check at
repo_editor.go:606withhasSymlinkInPath(localPath, path.Join(opts.TreePath, upload.Name))— the same primitiveUpdateRepoFilealready uses. - Walk
opts.TreePathbefore theos.MkdirAll(dirPath, ...)at line 583 so that pre-existing symlinked components don’t letMkdirAllcreate directories outside the repo. - Switch
iox.CopyFile’s open toO_WRONLY|O_CREATE|O_TRUNC|O_NOFOLLOW, closing the lstat→write TOCTOU at the syscall layer. - In
database.NewUpload, afterpathx.Clean, refusenamecontaining/or\outright. Browsers strip path components from file inputs; only attacker tooling sends multi-segment values.
PoC
Tested against gogs HEAD d7571322 on Ubuntu 24.04. Reproduces on v0.14.2 (packages renamed osx↔osutil, iox.CopyFile↔com.Copy, identical logic).
References
Code Behaviors & Features
Detect and mitigate CVE-2026-52811 with GitLab Dependency Scanning
Secure your software supply chain by verifying that all open source dependencies used in your projects contain no disclosed vulnerabilities. Learn more about Dependency Scanning →