CVE-2026-52811: Gogs: UploadRepoFiles writes outside repo working tree via committed parent sym
(updated )
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. Dependency Scanning using SBOM also detects vulnerabilities from proprietary advisories that this site does not include. Learn more about Dependency Scanning using SBOM →