A AegiFlow
CRITICALCVSS 9.0EPSS 0.5%

CVE-2026-52811

Gogs: UploadRepoFiles writes outside repo working tree via committed parent sym

Published
2026-06-23
Modified
2026-07-21
EPSS percentile
38%
Aliases
GHSA-89mr-xqfv-758m
Sources
github-advisory

Summary

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 ` .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`: ```go 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`: ```go 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 1. Replace the leaf check at `repo_editor.go:606` with `hasSymlinkInPath(localPath, path.Join(opts.TreePath, upload.Name))` — the same primitive `UpdateRepoFile` already uses. 2. Walk `opts.TreePath` *before* the `os.MkdirAll(dirPath, ...)` at line 583 so that pre-existing symlinked components don't let `MkdirAll` create directories outside the repo. 3. Switch `iox.CopyFile`'s open to `O_WRONLY|O_CREATE|O_TRUNC|O_NOFOLLOW`, closing the lstat→write TOCTOU at the syscall layer. 4. In `database.NewUpload`, after `pathx.Clean`, refuse `name` containing `/` 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). ### Reproduction prerequisites - gogs ≥ 0.14.0 on Linux/macOS (`runtime.GOOS != "windows"`). - Two attacker accounts on the gogs instance with write to a repo `attacker/playground` (repo creators are admins of their own repos). - `git` ≥ 2.x with `core.symlinks=true` (Linux/macOS default). - Python 3 stdlib only — `curl -F` does NOT trigger the bug because shell quoting + Go's RFC 2045 quoted-pair parsing both consume the backslash; we build the multipart body byte-exactly. ### W

Affected packages

EcosystemPackageAffected versionsFixed versions
Gogogs.io/gogs0.14.3

Remediation: Upgrade to 0.14.3 or later.

References

Includes data from the GitHub Advisory Database, licensed under CC-BY 4.0.

CVE® is a registered trademark of The MITRE Corporation. CVE content reproduced under the CVE Terms of Use; copyright designation © MITRE.

EPSS scores provided by the FIRST.org Exploit Prediction Scoring System.