GHSA-89mr-xqfv-758m: 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).
DetailsThe asymmetric check at `internal/database/repo_editor.go:601-612`:vs. `UpdateRepoFile`'s correct walker at `internal/database/repo_editor.go:163`:`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 fixReplace the leaf check at `repo_editor.go:606` with `hasSymlinkInPath(localPath, path.Join(opts.TreePath, upload.Name))` — the same primitive `UpdateRepoFile` already uses.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.Switch `iox.CopyFile`'s open to `O_WRONLY|O_CREATE|O_TRUNC|O_NOFOLLOW`, closing the lstat→write TOCTOU at the syscall layer.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.PoCTested against gogs HEAD `d7571322` on Ubuntu 24.04. Reproduces on `v0.14.2` (packages renamed `osx`↔`osutil`, `iox.CopyFile`↔`com.Copy`, identical logic).Reproduction prerequisitesgogs ≥ 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.Why curl alone is unreliableBug needs *two* backslash bytes on the wire so Go's `mime.ParseMediaType` quoted-string rule (`\X` → `X`) yields a single `\` in the parsed filename, which `pathx.Clean` then turns into `/`.| Shell form | Wire bytes | Go parses to | upload.Name | Triggers? |
|---|---|---|---|---|
| `-F "...filename=a\b"`  | `a\b`  | `ab`  | `ab`  | no |
| `-F "...filename=a\\b"` (double quotes) | `a\b`  | `ab`  | `ab`  | no |
| `-F '...filename=a\\b'` (single quotes) | `a\\b` | `a\b` | `a/b` | **yes** |The Python below removes the ambiguity.Step 1 — plant the directory symlinkBare repo now contains a mode-120000 entry for `hijack`. Next `UpdateLocalCopyBranch` materialises `<conf.AppDataPath>/tmp/local-r/<repoID>/hijack → /home/git/.ssh`.Step 2 — upload + commitSave as `poc.py`:Step 3 — confirm and use the footholdServer-side traceOther reachable targets (same primitive)| Symlink target | Effect on next event |
|---|---|
| `/home/git/.ssh` | SSH key implant → shell as gogs UID |
| `<RepoRoot>/<owner>/<repo>.git/hooks` | Hook overwrite → arbitrary code on next push |
| `<RepoRoot>/<owner>/<repo>.git` | `core.fsmonitor=<cmd>` in `config` → exec on next git op |
| `~git/custom/conf` | Modify `app.ini` (`SCRIPT_TYPE`, `INSTALL_LOCK`, `SECRET_KEY`) on restart |
| Path of the sqlite DB file | DoS or admin-row replant |Independent confirmation against the sourceImpact**Authenticated RCE** as the gogs runtime UID from one repo write. Chain: plant symlink (one git push) → upload with crafted filename → commit → write to `~git/.ssh/authorized_keys` → ssh in.Lateral targets: gogs sqlite DB (rewrite admin row), bare-repo hook scripts (run on next push by *any* user with `GOGS_AUTH_USER_*` env populated), `app.ini` `SECRET_KEY` (forges session cookies, decrypts stored 2FA secrets and mirror credentials).Persistent: symlink and key both survive restart; removing the attacker's repo access does not undo the SSH foothold.Linux/macOS only. Windows hosts are unaffected for two independent reasons (`filepath.Base` separator handling, git's `core.symlinks` default).
CVSS v4.0: 9